-- allium: 1
-- bDS Translation System
-- Scope: core (Wave 1)
-- Distilled from: src/main/engine/PostEngine.ts (translation methods),
--   postTranslationFileUtils.ts, MediaEngine.ts

use "./post.allium" as post
use "./media.allium" as media

enum PostTranslationStatus {
    draft
    published
}

surface TranslationControlSurface {
    facing _: TranslationOperator

    provides:
        UpsertPostTranslationRequested(post, language, title, content, excerpt)
        DeletePostTranslationRequested(translation)
        ValidateTranslationsRequested(project)
}

surface TranslationRuntimeSurface {
    facing _: TranslationRuntime

    provides:
        PostPublished(post)
        PublishTranslationRequested(translation)
        TranslationEdited(translation, changes)
}

value SupportedLanguage {
    -- en, de, fr, it, es
    code: String
}

surface SupportedLanguageSurface {
    context language: SupportedLanguage

    exposes:
        language.code
}

entity PostTranslation {
    canonical_post: post/Post
    language: SupportedLanguage
    title: String
    excerpt: String?
    content: String?
    status: PostTranslationStatus
    file_path: String
    checksum: String?
    created_at: Timestamp
    updated_at: Timestamp
    published_at: Timestamp?

    -- Derived
    content_location: if status = published: file_path else: content

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

invariant TranslationFilesCarryFullMetadata {
    -- Translation markdown files include status and timestamps alongside
    -- language-specific fields. This allows each translation to be rebuilt
    -- independently. On rebuild, missing fields fall back to canonical post
    -- values for compatibility with legacy files.
    for t in PostTranslations where file_path != "":
        translation_file(t).has_fields(
            id, translation_for, language, title, excerpt?,
            status, created_at, updated_at, published_at
        )
}

surface PostTranslationSurface {
    context translation: PostTranslation

    exposes:
        translation.canonical_post
        translation.language.code
        translation.title
        translation.excerpt when translation.excerpt != null
        translation.content when translation.content != null
        translation.status
        translation.file_path
        translation.checksum when translation.checksum != null
        translation.created_at
        translation.updated_at
        translation.published_at when translation.published_at != null
        translation.content_location
}

invariant UniqueTranslationPerLanguage {
    for a in PostTranslations:
        for b in PostTranslations:
            (a != b and a.canonical_post = b.canonical_post)
                implies a.language != b.language
}

invariant TranslationFilePath {
    -- posts/YYYY/MM/{slug}.{language}.md
    for t in PostTranslations where file_path != "":
        t.file_path = format("posts/{yyyy}/{mm}/{slug}.{lang}.md",
            yyyy: t.canonical_post.created_at.year,
            mm: t.canonical_post.created_at.month_padded,
            slug: t.canonical_post.slug,
            lang: t.language.code)
}

rule UpsertPostTranslation {
    when: UpsertPostTranslationRequested(post, language, title, content, excerpt)
    requires: not post.do_not_translate
    ensures:
        let translation = PostTranslation.created(
            canonical_post: post,
            language: language,
            title: title,
            content: content,
            excerpt: excerpt,
            status: draft,
            file_path: ""
        )
        translation.status = draft
    -- If translation already exists, update it instead
}

rule PublishPostTranslation {
    when: PostPublished(post)
    -- All translations are also published when the canonical post is published
    for t in post.translations:
        ensures: PublishTranslationRequested(t)
}

rule PublishTranslation {
    when: PublishTranslationRequested(translation)
    requires: translation.status = draft
    ensures: translation.status = published
    ensures: translation.published_at = translation.published_at ?? now
    ensures: TranslationFileWritten(translation)
    ensures: translation.content = null
        -- Content moves to filesystem
}

rule ReopenPublishedTranslation {
    when: TranslationEdited(translation, changes)
    requires: translation.status = published
    requires: translation_edit_affects_published_content(changes)
    ensures: translation.status = draft
    ensures: translation.updated_at = now
}

rule DeletePostTranslation {
    when: DeletePostTranslationRequested(translation)
    ensures: not exists translation
    ensures: TranslationFileDeleted(translation)
    ensures: SearchIndexUpdated(translation.canonical_post)
        -- FTS index includes all translations of a post
}

rule ValidateTranslations {
    when: ValidateTranslationsRequested(project)
    -- Checks all posts against configured blog languages
    -- Reports: missing translations, orphan translation files,
    --   posts marked do_not_translate
    for post in project.posts where status = published:
        for lang in project.blog_languages:
            if lang != project.main_language:
                if not post.do_not_translate:
                    if not (lang in post.available_languages):
                        ensures: ValidationIssueReported(post, lang, "missing")

    @guidance
        -- This produces a validation report, not automatic fixes
        -- The report drives targeted re-rendering
}

invariant FtsIncludesTranslations {
    -- Full-text search index for a post includes stemmed content
    -- from all its translations, not just the canonical language
    for post in Posts:
        includes_text(search_index(post), post.title)
        for t in post.translations:
            includes_text(search_index(post), t.title)
}

-- ===========================================================================
-- Auto-Translation System
-- Distilled from: lib/bds/posts/auto_translation.ex
--
-- Two entry points share one translation primitive:
--   1. ScheduleAutoTranslation - reactive, fired after a post is created or
--      updated. One background task per missing language produces a DRAFT
--      translation, then cascades to the post's linked media.
--   2. FillMissingTranslations - batch maintenance action. Scans every
--      published post, AUTO-PUBLISHES the generated translations, fills linked
--      media, reports progress and returns a summary.
-- All AI work is gated by a resolvable endpoint and runs on background tasks.
-- ===========================================================================

config {
    -- Background translations use the "AI" task group named per project.
    auto_translation_task_group_name: String = "AI"
}

surface AutoTranslationControlSurface {
    facing _: TranslationOperator

    provides:
        -- Reactive trigger: emitted by post create/update side effects.
        PostSavedForAutoTranslation(post)
        -- Batch trigger: "fill missing translations" maintenance action.
        FillMissingTranslationsRequested(project)
}

invariant AutoTranslationGatedByEndpoint {
    -- No automatic translation runs unless an endpoint is resolvable for the
    -- current mode. Airplane mode needs url+model; online additionally needs an
    -- api_key. When unconfigured, scheduling is a silent no-op.
    -- See ai.allium AirplaneModeGating for endpoint selection.
    for post in Posts:
        auto_translation_runs(post) implies endpoint_configured(post.project)
}

invariant AutoTranslationSkipsDoNotTranslate {
    -- Posts flagged do_not_translate never schedule background translation,
    -- and the batch scan rejects them before computing missing languages.
    for post in Posts where post.do_not_translate:
        not auto_translation_runs(post)
}

invariant AutoTranslationOnlyMissingLanguages {
    -- The target set is the configured languages (main_language plus
    -- blog_languages, normalized + de-duplicated) minus the post's source
    -- language and any language that already has a translation.
    for post in Posts:
        auto_translation_targets(post) =
            configured_languages(post.project)
                - source_language(post)
                - post.available_languages
}

rule ScheduleAutoTranslation {
    when: PostSavedForAutoTranslation(post)
    requires: not post.do_not_translate
    requires: endpoint_configured(post.project)
    -- One background task per missing language, each producing a DRAFT
    -- translation (not auto-published) followed by a media cascade.
    for language in auto_translation_targets(post):
        ensures: BackgroundTaskSubmitted(
            group: post.project,
            group_name: config.auto_translation_task_group_name)
        ensures: AutoTranslatePost(post, language, auto_publish: false)
        ensures: AutoTranslateMediaCascade(post, language)

    @guidance
        -- Best-effort: missing metadata, unconfigured endpoint, or
        -- do_not_translate all collapse to a silent success with no task.
}

rule AutoTranslatePost {
    when: AutoTranslatePost(post, language, auto_publish)
    requires: trim(editor_body(post)) != ""
    -- Calls the AI endpoint with the post's source language, then upserts a
    -- translation marked auto_generated. Publishes only in the batch path.
    ensures:
        let translation = UpsertPostTranslation(post, language)
        if auto_publish:
            translation.status = published
        else:
            translation.status = draft

    @guidance
        -- An empty body yields a no_content_to_translate error and no
        -- translation is created.
}

rule AutoTranslateMediaCascade {
    when: AutoTranslateMediaCascade(post, language)
    -- After a post translation, each linked media (ordered by sort_order) gets
    -- its own background task when its source language differs from the target
    -- and it lacks a translation in that language.
    for m in post.linked_media:
        if m.language != "" and m.language != language and not (language in m.available_languages):
            ensures: BackgroundTaskSubmitted(
                group: post.project,
                group_name: config.auto_translation_task_group_name)
            ensures: media/UpsertMediaTranslation(m, language)
}

rule FillMissingTranslations {
    when: FillMissingTranslationsRequested(project)
    -- Batch maintenance. No-op (nothing_to_do) when there is at most one
    -- configured language or nothing is missing. Otherwise scans published,
    -- non-do_not_translate posts and their linked media.
    requires: configured_languages(project).count > 1
    for post in project.posts where status = published and not do_not_translate:
        for language in auto_translation_targets(post):
            ensures: AutoTranslatePost(post, language, auto_publish: true)
            ensures: AutoTranslateMediaCascade(post, language)
    ensures: ProgressReported(project)
    ensures: FillMissingTranslationsCompleted(project)

    @guidance
        -- Returns a summary of counts: translated_posts, translated_media,
        -- failed_count, warned_count, and nothing_to_do. nothing_to_do is true
        -- when there is at most one configured language or nothing is missing.
        -- Per-item failures increment failed_count and never abort the batch.
        -- Progress runs through scanning (0.0-0.15) then per-item (0.15-1.0).
}
