-- 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)
        ForceDeleteTemplateRequested(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
    -- Field order also includes projectId after id when present. Scalars use
    -- the canonical post frontmatter quoting rules, timestamps are single-quoted
    -- ISO values, and the complete file ends in one newline.
    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)
    if changes.slug != null:
        ensures: template.slug = unique_slug(slugify(changes.slug), excluding: template)
        if template.file_path != "":
            ensures: template.file_path = format("templates/{slug}.liquid", slug: template.slug)
            ensures: TemplateFileWritten(template)
            ensures: PreviousTemplateFileDeleted(template)
    if template.status = published and not template_changes_affect_rendered_output(changes):
        ensures: template.status = published
        ensures: TemplateFileWritten(template)
            -- Keep published frontmatter synchronized for metadata-only and no-op content updates.
    ensures: template.updated_at = now
    ensures: template.version = template.version + 1
}

rule ReopenPublishedTemplate {
    when: UpdateTemplateRequested(template, changes)
    requires: template.status = published
    requires: changes.content != null
    requires: changes.content != effective_template_content(template)
    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 ForceDeleteTemplate {
    when: ForceDeleteTemplateRequested(template)
    -- Force deletion clears every project-scoped post and tag reference before
    -- removing the template. A same-slug template in another project is unrelated.
    for post in template.referencing_posts:
        ensures: post.template_slug = null
        ensures: post.updated_at = now
        if post.status = published:
            ensures: PublishedPostFrontmatterRewritten(post)
                -- Published files drop templateSlug while retaining their body.
    for tag in template.referencing_tags:
        ensures: tag.post_template_slug = null
        ensures: tag.updated_at = now
    ensures: TagsMetadataWritten(template.project_id)
    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
}

-- ─── Liquid subset (compatibility contract) ─────────────────
--
-- Exact Liquid subset required (distilled from bundled starter templates).
-- No features beyond these sets are accepted. Enforcement happens at
-- publish/validation time (ValidateLiquid); anything outside the sets is
-- rejected even though Liquex would otherwise support it as a built-in.

config {
    -- The 5 allowed tag families as their concrete tag names.
    -- Whitespace-stripped variants ({%- -%}) of each are also accepted.
    -- render takes named parameters: {% render 'partial', param: value %}.
    -- NOT allowed: include, capture, case/when, unless, raw, comment,
    --   cycle, tablerow, increment, decrement, liquid, echo
    liquid_allowed_tags: Set<String> = {
        "if", "elsif", "else", "endif",
        "for", "endfor",
        "assign",
        "render"
    }

    -- Standard filters (4).
    -- default takes a fallback value, append a suffix string.
    -- NOT allowed: 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
    liquid_allowed_standard_filters: Set<String> = {
        "escape", "url_encode", "default", "append"
    }

    -- 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 (tag/category URLs)
    liquid_allowed_custom_filters: Set<String> = {
        "i18n", "markdown", "slugify"
    }

    -- NOT allowed: !=, <, >=, <=, contains.
    -- Logical operators or/and, bare-variable truthiness in {% if %},
    -- the special value blank, dot property access, .size on arrays,
    -- and bracket map lookups are part of expression syntax, not
    -- operator validation.
    liquid_allowed_comparison_operators: Set<String> = { "==", ">" }
}

-- A published template has passed ValidateLiquid, so its content only
-- uses the allowed sets. liquid_tags / liquid_filters /
-- liquid_comparison_operators are black boxes extracting the construct
-- names a template uses.

invariant LiquidTagSubset {
    for t in Templates:
        t.status = published implies
            for tag in liquid_tags(effective_template_content(t)):
                tag in config.liquid_allowed_tags
}

invariant LiquidFilterSubset {
    let allowed = config.liquid_allowed_standard_filters
        + config.liquid_allowed_custom_filters
    for t in Templates:
        t.status = published implies
            for f in liquid_filters(effective_template_content(t)):
                f in allowed
}

invariant LiquidOperatorSubset {
    for t in Templates:
        t.status = published implies
            for op in liquid_comparison_operators(effective_template_content(t)):
                op in config.liquid_allowed_comparison_operators
}

-- The top-level variables available to templates at render time are
-- formalized as the RenderContext value records in template_context.allium.
