-- allium: 1
-- bDS Liquid Template System
-- Scope: core (Wave 1 data, Wave 4 rendering)
-- Distilled from: src/main/engine/TemplateEngine.ts, PageRenderer.ts, schema.ts,
--   bundled starter templates in src/main/engine/templates/

enum TemplateStatus {
    draft
    published
}

entity Template {
    project_id: String
    slug: String
    title: String
    kind: post | list | not_found | partial
    enabled: Boolean
    status: TemplateStatus
    content: String?
    version: Integer
    file_path: String
    created_at: Timestamp
    updated_at: Timestamp

    -- Derived
    content_location: if status = published: file_path else: content
    referencing_posts: Posts where template_slug = this.slug and project_id = this.project_id
    referencing_tags: Tags where post_template_slug = this.slug and project_id = this.project_id

    transitions status {
        draft -> published
        published -> draft
    }
}

surface TemplateManagementSurface {
    facing _: TemplateOperator

    provides:
        CreateTemplateRequested(project, title, kind, content)
        CreateAndPublishTemplateRequested(project, title, kind, content)
        UpdateTemplateRequested(template, changes)
        PublishTemplateRequested(template)
        DeleteTemplateRequested(template)
        RebuildTemplatesFromFilesRequested(project)
}

invariant UniqueTemplateSlug {
    -- Slug uniqueness is scoped per project, not globally.
    for a in Templates:
        for b in Templates:
            a != b and a.project_id = b.project_id implies a.slug != b.slug
}

invariant TemplateFrontmatter {
    -- .liquid files use standard --- YAML frontmatter
    -- Fields: id, slug, title, kind, enabled, version, createdAt, updatedAt
    for t in Templates where status = published:
        parse_frontmatter(read_file(t.file_path)).slug = t.slug
}

invariant TemplateFileLayout {
    for t in Templates where file_path != "":
        t.file_path = format("templates/{slug}.liquid", slug: t.slug)
}

invariant BundledDefaultTemplatesExistOutsideProjectData {
    -- The application ships bundled default templates for:
    --   single-post
    --   post-list
    --   not-found
    -- plus supporting partials and macros.
    -- These bundled templates are available for rendering even when the project
    -- has no template files and no Template rows for those defaults.
}

invariant UserTemplateDirectoryOverridesBundledDefaults {
    -- When a project template file or published Template row resolves the same slug
    -- as a bundled template or partial, the project-owned template wins.
    -- Bundled templates are fallback roots, not copied seed data.
}

invariant RebuildTemplatesIndexesOnlyProjectTemplates {
    -- Rebuild-from-files scans only project.public_dir/templates.
    -- Bundled defaults are render-time fallbacks and are not indexed into Templates
    -- unless the user has created matching project files.
}

rule CreateTemplate {
    when: CreateTemplateRequested(project, title, kind, content)
    let slug = slugify(title)
    -- Creates a draft template: content stored in DB, no file written yet
    ensures:
        let new_template = Template.created(
            project_id: project.id,
            slug: slug,
            title: title,
            kind: kind,
            content: content,
            status: draft,
            enabled: true,
            version: 1,
            file_path: ""
        )
        new_template.status = draft
}

rule CreateAndPublishTemplate {
    -- Alternative creation path: create + immediately publish (file written)
    -- Some implementations may expose this as a single user action
    when: CreateAndPublishTemplateRequested(project, title, kind, content)
    let slug = slugify(title)
    requires: ValidateLiquid(content) = valid
    ensures:
        let new_template = Template.created(
            project_id: project.id,
            slug: slug,
            title: title,
            kind: kind,
            content: null,
            status: published,
            enabled: true,
            version: 1,
            file_path: format("templates/{slug}.liquid", slug: slug)
        )
        TemplateFileWritten(new_template)
}

rule UpdateTemplate {
    when: UpdateTemplateRequested(template, changes)
    ensures: TemplateFieldsUpdated(template, changes)
    ensures: template.updated_at = now
    ensures: template.version = template.version + 1
}

rule ReopenPublishedTemplate {
    when: UpdateTemplateRequested(template, changes)
    requires: template.status = published
    requires: template_changes_affect_rendered_output(changes)
    ensures: template.status = draft
}

rule PublishTemplate {
    when: PublishTemplateRequested(template)
    requires: template.status = draft
    requires: ValidateLiquid(template.content) = valid
        -- The template parser must accept the template
    ensures: template.status = published
    ensures: TemplateFileWritten(template)
        -- Writes frontmatter + liquid to templates/{slug}.liquid
    ensures: template.content = null
}

rule DeleteTemplate {
    when: DeleteTemplateRequested(template)
    requires: template.referencing_posts.count = 0
    requires: template.referencing_tags.count = 0
        -- Cannot delete a template still referenced by posts or tags
    ensures: not exists template
    ensures: TemplateFileDeleted(template)
}

rule CascadeSlugUpdate {
    when: template: Template.slug transitions_to new_slug
    -- When a template slug changes, update all references
    for p in template.referencing_posts:
        ensures: p.template_slug = new_slug
    for t in template.referencing_tags:
        ensures: t.post_template_slug = new_slug
}

rule RebuildTemplatesFromFiles {
    when: RebuildTemplatesFromFilesRequested(project)
    for file in scan_directory(project.public_dir + "/templates", "*.liquid"):
        let parsed = parse_template_file(file)
        ensures: Template.created(parsed)
            -- or updated if slug already exists
    -- Prune stale published templates: a published Template whose file_path is
    -- neither among the scanned files nor present on disk is deleted, clearing
    -- its references first (posts' template_slug, tags' post_template_slug).
    for template in project.templates where status = published and file_path != "":
        if template.file_path not in scanned_files and not file_exists(template.file_path):
            ensures: not exists template
}

-- Exact Liquid subset required (distilled from bundled starter templates)
-- No features beyond this list are used.

invariant LiquidTagSubset {
    -- Only these 5 tags are used:
    --   {% if %} / {% elsif %} / {% else %} / {% endif %}
    --   {% for %} / {% endfor %}
    --   {% assign %}
    --   {% render 'partial', named_param: value %}  (with named parameters)
    --   Whitespace-stripped variants: {%- -%}
    --
    -- NOT used: include, capture, case/when, unless, raw, comment,
    --   cycle, tablerow, increment, decrement, liquid, echo
}

invariant LiquidFilterSubset {
    -- Standard filters (4):
    --   | escape
    --   | url_encode
    --   | default: fallback_value
    --   | append: suffix_string
    --
    -- Custom filters (3):
    --   | i18n: language          — translates a key string for given language
    --   | markdown: post_id, post_data_json_by_id, canonical_post_path_by_slug,
    --              canonical_media_path_by_source_path, language, language_prefix
    --     — renders Markdown to HTML with link rewriting (6 arguments)
    --   | slugify                 — slugifies a string (used for tag/category URLs)
    --
    -- Enforced at publish/validation time (LiquidParser.validate); any other
    -- filter is rejected even though Liquex would otherwise apply it as a
    -- built-in standard filter.
    --
    -- NOT used: date, strip_html, truncate, split, join, size (as filter),
    --   upcase, downcase, replace, remove, sort, map, where, first, last,
    --   reverse, concat, uniq, compact, strip, newline_to_br, json, prepend,
    --   and all math filters
}

invariant LiquidOperatorSubset {
    -- Comparison: ==, >
    -- Logical: or, and
    -- Truthy/falsy: bare variable in {% if variable %}
    -- Special values: blank (nil/empty comparison)
    -- Property access: dot notation (object.property), .size on arrays,
    --   bracket notation for map lookups (map[key])
    --
    -- Enforced at publish/validation time (LiquidParser.validate); any other
    -- comparison operator (!=, <, >=, <=, contains) is rejected even though
    -- Liquex would otherwise evaluate it.
}

invariant LiquidRenderContext {
    -- Template rendering context provides these top-level variables:
    --   language, language_prefix, html_theme_attribute,
    --   page_title, pico_stylesheet_href,
    --   blog_languages (array of {is_current, code, flag, href_prefix}),
    --   alternate_links (array of {hreflang, href}),
    --   menu_items (tree of {href, title, has_children, children}),
    --   calendar_initial_year, calendar_initial_month,
    --   post (single post context: {title, content, id, slug, show_title}),
    --   post_categories, post_tags, tag_color_by_name (map),
    --   backlinks (array of {path, display_slug}),
    --   day_blocks (array of {show_date_marker, date_label, posts, show_separator}),
    --   archive_context ({kind, name, month, year, day}),
    --   show_archive_range_heading, min_date, max_date,
    --   canonical_post_path_by_slug (map), canonical_media_path_by_source_path (map),
    --   post_data_json_by_id (map),
    --   is_list_page, is_first_page, is_last_page,
    --   has_prev_page, has_next_page, prev_page_href, next_page_href,
    --   not_found_message, not_found_back_label
}
