-- allium: 1
-- bDS Post Lifecycle
-- Scope: core (Wave 1)
-- Distilled from: src/main/engine/PostEngine.ts, postFileUtils.ts, schema.ts

use "./project.allium" as project

enum PostStatus {
    draft
    published
    archived
}

value Slug {
    value: String

    -- Generated exactly as bDS2: replace ß with "ss", normalize to Unicode NFD,
    -- discard every non-ASCII code point, lowercase, replace [^a-z0-9]+ with
    -- hyphens, then strip leading/trailing hyphens.
    -- Therefore ä/ö/ü/Ä/Ö/Ü become a/o/u/A/O/U through NFD decomposition;
    -- examples: "Über" -> "uber", "ÄÖÜäöüß" -> "aouaouss".
    -- Uniqueness: tries base, then {slug}-2, {slug}-3, … (unbounded numeric suffix)
}

value PostFilePath {
    -- posts/YYYY/MM/{slug}.md
    -- YYYY and MM derived from created_at
    base_dir: String
    year: String
    month: String
    slug: Slug
}

value PostCanonicalUrl {
    -- /{YYYY}/{MM}/{DD}/{slug}
    -- YYYY/MM/DD from created_at (zero-padded)
    year: String
    month: String
    day: String
    slug: Slug
}

value Frontmatter {
    -- YAML between --- delimiters at start of .md file
    -- Always present: id, title, slug, status, createdAt, updatedAt, tags, categories
    -- Optional (written only when truthy): excerpt, author, language,
    --   doNotTranslate (only when true), templateSlug, publishedAt
}

surface PostControlSurface {
    facing _: PostOperator

    provides:
        CreatePostRequested(project, title, content, tags, categories, author, language, template_slug)
        UpdatePostRequested(post, changes)
        PublishPostRequested(post)
        DeletePostRequested(post)
        ArchivePostRequested(post)
        UnarchivePostRequested(post)
        DiscardPostChangesRequested(post)
        SyncPostFromFileRequested(post)
        ImportOrphanPostFileRequested(project, relative_path)
}

surface PostFilePathSurface {
    context path: PostFilePath

    exposes:
        path.base_dir
        path.year
        path.month
        path.slug
}

surface PostCanonicalUrlSurface {
    context url: PostCanonicalUrl

    exposes:
        url.year
        url.month
        url.day
        url.slug
}

surface FrontmatterSurface {
    context _: Frontmatter
}

entity Post {
    project: project/Project
    title: String
    slug: Slug
    excerpt: String?
    content: String?
    status: PostStatus
    author: String?
    language: String?
    do_not_translate: Boolean
    template_slug: String?
    file_path: String
    checksum: String?
        -- Caller-supplied SHA-256 content change signal. Publish preserves it;
        -- filesystem rebuild/sync clears it to match bDS2.
    tags: List<String>
    categories: List<String>
    created_at: Timestamp
    updated_at: Timestamp
    published_at: Timestamp?

    -- Legacy bDS2-compatible columns. Publishing does not populate them and
    -- application behaviour does not depend on them; imported non-null values
    -- remain passive database data.
    published_title: String?
    published_content: String?
    published_tags: String?
    published_categories: String?
    published_excerpt: String?

    -- Relationships
    translations: PostTranslation with canonical_post = this
    linked_media: PostMediaLink with post = this
    outgoing_links: PostLink with source = this
    incoming_links: PostLink with target = this

    -- Derived
    available_languages: translations -> language
    is_slug_frozen: published_at != null
        -- Slug changes only allowed before first publish
    content_location: if status = published: file_path else: content
        -- Published: body in filesystem. Draft: body in DB field.
    editor_body: if content != null: content else: read_markdown_body(file_path)
        -- Resolver used by editors: prefer the in-DB draft content, else read
        -- the markdown body from the post's file. Empty string when neither
        -- exists. The same resolver applies to PostTranslation records.

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

entity PostLink {
    source: Post
    target: Post
    link_text: String?
}

entity PostMediaLink {
    post: Post
    media_id: String
    sort_order: Integer
}

invariant UniqueSlugPerProject {
    for a in Posts:
        for b in Posts:
            (a != b and a.project = b.project) implies a.slug != b.slug
}

rule CreatePost {
    when: CreatePostRequested(project, title, content, tags, categories, author, language, template_slug)
    let slug = Slug.generate(title ?? "untitled")
    let unique_slug = Slug.ensure_unique(slug, project)
    ensures:
        let new_post = Post.created(
            project: project,
            title: title ?? "",
            slug: unique_slug,
            content: content,
            status: draft,
            author: author,
            language: language,
            tags: tags ?? {},
            categories: categories ?? {},
            template_slug: template_slug,
            do_not_translate: false,
            file_path: ""
        )
        new_post.status = draft
        SearchIndexUpdated(new_post)
}

rule UpdatePost {
    when: UpdatePostRequested(post, changes)
    requires: not post.is_slug_frozen or changes.slug = null
        -- Cannot change slug after first publish
    ensures: post.updated_at = now
    ensures: PostFieldsUpdated(post, changes)
    ensures: SearchIndexUpdated(post)
    @guidance
        -- If post is published and content/metadata changed,
        -- status auto-transitions back to draft
}

rule ReopenPublishedPost {
    when: UpdatePostRequested(post, changes)
    requires: post.status = published
    requires: changes_affect_published_content(changes)
        -- Compares supplied metadata with the current post fields and supplied
        -- content with the canonical file body. Legacy published_* columns are
        -- not consulted because bDS2 does not populate them.
    ensures: post.status = draft
}

rule PublishPost {
    when: PublishPostRequested(post)
    requires: post.status = draft or post.status = archived
    ensures: post.status = published
    ensures: post.published_at = post.published_at ?? now
        -- Preserve original publish date on re-publish
    ensures: PostFileWritten(post)
        -- Writes frontmatter + markdown to posts/YYYY/MM/{slug}.md
    ensures: post.content = null
        -- Content cleared from DB; now lives in filesystem only
    -- post.checksum is preserved unchanged.
    ensures: SearchIndexUpdated(post)
    ensures: PostLinksUpdated(post)
        -- Parse inter-post links, update link graph
    ensures:
        for t in post.translations:
            TranslationFileWritten(t)
}

rule DeletePost {
    when: DeletePostRequested(post)
    ensures: not exists post
    ensures: PostFileDeleted(post)
        -- Remove .md file if it exists
    ensures:
        for t in post.translations:
            not exists t
    ensures: SearchIndexUpdated(post)
}

rule ArchivePost {
    when: ArchivePostRequested(post)
    requires: post.status = draft or post.status = published
    ensures: post.status = archived
    -- Archiving changes only status and updatedAt. A published post keeps its
    -- body solely in the existing file: content remains null and the file is
    -- not rewritten.
}

rule UnarchivePost {
    when: UnarchivePostRequested(post)
    requires: post.status = archived
    ensures: post.status = draft
    ensures: post.content = archived_database_content(post) ?? file_body(post.file_path) ?? ""
    ensures: SearchIndexUpdated(post)
    ensures: EmbeddingUpdated(post)
}

rule SyncPostFromFile {
    when: SyncPostFromFileRequested(post)
    requires: post.file_path != ""
    -- Single-post reimport: re-reads the post's own .md file and upserts the DB
    -- record from it (the filesystem is treated as truth for that one post).
    -- Re-syncs the post's link graph. Errors out if the file is missing.
    ensures: PostFieldsUpdated(post, parse_post_file(post.file_path))
    ensures: post.checksum = null
    ensures: PostLinksUpdated(post)
}

rule ImportOrphanPostFile {
    when: ImportOrphanPostFileRequested(project, relative_path)
    -- Imports a .md file that exists on disk but has no DB record (an orphan,
    -- e.g. surfaced by RunMetadataDiff). Rejects non-markdown / missing files.
    ensures:
        let new_post = Post.created(parse_post_file(relative_path))
        SearchIndexUpdated(new_post)
}

rule DiscardPostChanges {
    when: DiscardPostChangesRequested(post)
    requires: post.file_path != ""
        -- Only posts with a published file on disk can be discarded;
        -- a never-published draft has no file version to restore.
    -- Re-reads the published .md file and upserts the DB record from it,
    -- discarding unsaved draft edits.
    ensures: post.content = null
    ensures: post.status = published
    ensures: PostLinksUpdated(post)
    ensures: SearchIndexUpdated(post)
        -- See engine_side_effects.allium DiscardPostChangesSideEffects
}

-- File format axioms

invariant FrontmatterRoundtrip {
    -- Reading a post file written by the system produces identical
    -- field values to the database record at time of writing
    for post in Posts where status = published:
        parse_frontmatter(read_file(post.file_path)) = frontmatter_fields(post)
}

invariant CanonicalFrontmatterSerialization {
    -- Post field order is id, title, slug, excerpt, status, author, language,
    -- doNotTranslate, templateSlug, createdAt, updatedAt, publishedAt, tags,
    -- categories; nil/empty optional fields and false doNotTranslate are absent.
    -- Translation field order is id, translationFor, language, title, excerpt,
    -- status, createdAt, updatedAt, publishedAt.
    -- Strings containing only Unicode letters/numbers, space, dot, underscore,
    -- slash, or hyphen are bare. Other strings are double quoted with backslash,
    -- quote, and newline escaping. Timestamp values are single-quoted ISO values.
    -- Empty lists use a bare key line. Written bodies have all trailing newlines
    -- removed and the complete file ends in exactly one newline; reads remove
    -- that terminal body newline before comparing or storing content.
}

invariant DateBasedFileLayout {
    for post in Posts where file_path != "":
        post.file_path = format("posts/{yyyy}/{mm}/{slug}.md",
            yyyy: post.created_at.year,
            mm: post.created_at.month_padded,
            slug: post.slug)
}

-- Slug freeze: once published_at is set, the slug is permanently frozen.
-- This follows the established bDS rule: is_slug_frozen = published_at != null
-- Even if the post reverts to draft, the slug cannot be changed.
