-- allium: 1
-- bDS Scripting System
-- Scope: core (Wave 6 — scripting behaviour and file contracts)
-- Distilled from: src/main/engine/ScriptEngine.ts, schema.ts
-- Lua is the normative scripting language for user-authored scripts in the
-- rewrite. The concrete embedding strategy remains an implementation choice;
-- only the behavioural contract is normative here.

config {
    macro_timeout: Duration = 10.seconds
    transform_max_toasts_per_script: Integer = 5
    transform_max_toasts_total: Integer = 20
    transform_max_toast_length: Integer = 300
    blogmark_max_title_length: Integer = 200
    blogmark_max_url_length: Integer = 2048
}

enum ScriptStatus {
    draft
    published
}

entity Script {
    project_id: String
    slug: String
    title: String
    kind: macro | utility | transform
    entrypoint: String          -- named Lua function used as the script entrypoint
    enabled: Boolean
    status: ScriptStatus
    content: String?
    version: Integer
    file_path: String
    created_at: Timestamp
    updated_at: Timestamp

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

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

surface ScriptSurface {
    context script: Script

    exposes:
        script.slug
        script.title
        script.kind
        script.entrypoint
        script.enabled
        script.status
        script.content when script.content != null
        script.version
        script.file_path
        script.created_at
        script.updated_at
        script.content_location
}

surface ScriptManagementSurface {
    facing _: ScriptOperator

    provides:
        CreateScriptRequested(project, title, kind, content, entrypoint)
        CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
        UpdateScriptRequested(script, changes)
        PublishScriptRequested(script)
        DeleteScriptRequested(script)
        RunUtilityRequested(script)
        MacroExpansionRequested(script, template_context)
        BlogmarkReceived(data)
        RebuildScriptsFromFilesRequested(project)
}

surface ScriptRuntimeSurface {
    facing _: ScriptRuntime

    provides:
        ValidateScript(source)
        ExecuteScriptRequested(script, entrypoint, args, progress_sink)

    @guarantee SandboxedExecution
        -- User-authored Lua executes from a sandboxed runtime state.
        -- Filesystem mutation, process control, package loading, and other
        -- unrestricted host capabilities are unavailable unless explicitly
        -- re-exposed by the host application.

    @guarantee ExplicitHostCapabilities
        -- Host-provided functions are exposed only through an explicit bds.*
        -- capability table, never through ambient global access.

    @guarantee CoreHostApiCompatibility
        -- The core API matches the bDS2 capability names below. Methods are
        -- bound to the active project unless an explicit project id is part of
        -- the method. Arguments and return tables use the canonical types in
        -- the generated scripting reference.
        --
        -- bds.app: copy_to_clipboard, get_data_paths,
        -- get_blogmark_bookmarklet, get_system_language,
        -- get_default_project_path, get_title_bar_metrics, log,
        -- notify_renderer_ready, open_folder, read_project_metadata,
        -- select_folder, set_preview_post_target, show_item_in_folder,
        -- trigger_menu_action.
        -- bds.projects: create, delete, delete_with_data, get, get_all,
        -- get_active, set_active, update.
        -- bds.meta: get_project_metadata, update_project_metadata,
        -- set_project_metadata, add_category, remove_category, add_tag,
        -- remove_tag, get_categories, get_tags,
        -- get_publishing_preferences, set_publishing_preferences,
        -- clear_publishing_preferences, sync_on_startup.
        -- bds.posts: create, discard, filter, generate_unique_slug,
        -- get_by_status, get_by_year_month, get_dashboard_stats,
        -- get_linked_by, get_links_to, get_preview_url, update, delete, get,
        -- get_all, get_by_slug, get_categories, get_categories_with_counts,
        -- get_tags, get_tags_with_counts, get_translation, get_translations,
        -- has_published_version, is_slug_available, publish,
        -- publish_translation, rebuild_from_files, rebuild_links,
        -- reindex_text, search.
        -- bds.media: delete_translation, filter, import, get_by_year_month,
        -- get_file_path, update, delete, get, get_all, get_tags,
        -- get_tags_with_counts, get_thumbnail, get_translation,
        -- get_translations, get_url, rebuild_from_files,
        -- regenerate_missing_thumbnails, regenerate_thumbnails, reindex_text,
        -- replace_file, search, upsert_translation.
        -- bds.scripts: create, update, delete, get, get_all, publish,
        -- rebuild_from_files.
        -- bds.templates: create, update, delete, get, get_all, publish,
        -- get_enabled_by_kind, rebuild_from_files, validate.
        -- bds.tags: create, update, delete, get, get_all, get_by_name,
        -- get_posts_with_tag, get_with_counts, merge, rename,
        -- sync_from_posts.
        -- bds.tasks: get, status_snapshot, cancel, get_all, get_running,
        -- clear_completed.
        -- get and get_all inspect every retained task rather than the
        -- status-snapshot history cap; get_all is newest first. get_running
        -- contains only running tasks. cancel succeeds only for a pending or
        -- running task. clear_completed removes completed tasks while keeping
        -- failed and cancelled results.
        -- bds.sync: check_availability, get_repo_state, get_status,
        -- get_history, get_remote_state, fetch, pull, push, commit_all.
        -- bds.publish: upload_site.
        -- bds.chat: detect_post_language, analyze_post, translate_post,
        -- analyze_media_image, detect_media_language,
        -- translate_media_metadata. Despite its historical namespace, this is
        -- the core one-shot AI bridge; conversational chat remains extension.
        -- bds.report_progress(payload) reports managed-job progress. The Rust
        -- host may retain bds.app.progress and bds.app.toast as additive APIs.
        -- bds.embeddings: compute_similarities, dismiss_pair, find_duplicates,
        -- find_similar, get_progress, index_unindexed_posts, suggest_tags.

    @guarantee HostApiFailureValues
        -- Host failures do not expose implementation exceptions across the Lua
        -- boundary. Lookup/create/update methods return nil on failure and
        -- boolean command methods return false, as declared by the generated
        -- method signature. Returned records contain only serializable public
        -- fields; timestamps are ISO-8601 strings and enum atoms are strings.

    @guarantee GeneratedApiReferenceStaysInSync
        -- One machine-readable method/type manifest generates the bundled Lua
        -- API reference and editor completion data. A verification test fails
        -- when the exposed capability table, manifest, or generated reference
        -- diverge. Canonical macro, transform, and utility examples are bundled
        -- under docs/scripting/ and execute against the same host API.

    @guarantee MacroTimeout
        -- Macro execution has a short timeout budget of config.macro_timeout.

    @guarantee ManagedBatchExecution
        -- Utility and transform scripts execute as managed jobs.
        -- The contract does not define a fixed wall-clock limit for those
        -- jobs because batch work can legitimately scale with project size.
        -- Progress reporting, operator cancellation, and host orchestration
        -- govern their lifecycle instead of a fixed timeout.

    @guarantee ProgressFeedback
        -- Long-running utility and transform scripts may emit progress updates
        -- through explicit host APIs during execution.
        -- Progress reporting is cooperative and flows through the supplied
        -- progress sink rather than ambient global side effects.

    @guarantee ScriptOutputRouting
        -- print(…) calls and the explicit bds.app.log(…) API append lines to a
        -- host-routed script output stream: the desktop app's Output panel,
        -- stdout in the CLI, and the host logger when no sink is attached —
        -- never the ambient console.

    @guarantee BatchCancellation
        -- Managed utility and transform jobs can be cancelled by the host
        -- operator boundary.
}

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

invariant ScriptFileLayout {
    for s in Scripts where file_path != "":
        s.file_path = format("scripts/{slug}.lua", slug: s.slug)
}
-- Script files use standard --- YAML frontmatter in field order id, projectId,
-- slug, title, kind, entrypoint, enabled, version, createdAt, updatedAt. Scalars
-- use the canonical post quoting rules, timestamps are single-quoted ISO values,
-- and the complete file ends in one newline.

rule CreateScript {
    when: CreateScriptRequested(project, title, kind, content, entrypoint)
    let slug = slugify(title)
    -- Creates a draft script: content stored in DB, no file written yet
    ensures:
        let new_script = Script.created(
            project_id: project.id,
            slug: slug,
            title: title,
            kind: kind,
            content: content,
            entrypoint: entrypoint ?? if kind = macro: "render" else: "main",
            status: draft,
            enabled: true,
            version: 1,
            file_path: ""
        )
        new_script.status = draft
}

rule UpdateScript {
    when: UpdateScriptRequested(script, changes)
    ensures: ScriptFieldsUpdated(script, changes)
    if changes.slug != null:
        ensures: script.slug = unique_slug(slugify(changes.slug), excluding: script)
        if script.file_path != "":
            ensures: script.file_path = format("scripts/{slug}.lua", slug: script.slug)
            ensures: ScriptFileWritten(script)
            ensures: PreviousScriptFileDeleted(script)
    if script.status = published and not script_changes_affect_published_output(changes):
        ensures: script.status = published
        ensures: ScriptFileWritten(script)
            -- Keep published frontmatter synchronized for metadata-only and no-op content updates.
    ensures: script.updated_at = now
    ensures: script.version = script.version + 1
}

rule ReopenPublishedScript {
    when: UpdateScriptRequested(script, changes)
    requires: script.status = published
    requires: changes.content != null
    requires: changes.content != effective_script_content(script)
    ensures: script.status = draft
}

rule CreateAndPublishScript {
    -- Alternative creation path: create + immediately publish (file written)
    -- Some implementations may expose this as a single user action
    when: CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
    let slug = slugify(title)
    requires: ValidateScript(content) = valid
    ensures:
        let new_script = Script.created(
            project_id: project.id,
            slug: slug,
            title: title,
            kind: kind,
            content: null,
            entrypoint: entrypoint ?? if kind = macro: "render" else: "main",
            status: published,
            enabled: true,
            version: 1,
            file_path: format("scripts/{slug}.lua", slug: slug)
        )
        ScriptFileWritten(new_script)
}

rule PublishScript {
    when: PublishScriptRequested(script)
    requires: script.status = draft
    requires: ValidateScript(script.content) = valid
        -- Lua parsing must succeed before a script can be published
    ensures: script.status = published
    ensures: ScriptFileWritten(script)
    ensures: script.content = null
}

rule DeleteScript {
    when: DeleteScriptRequested(script)
    ensures: not exists script
    ensures: ScriptFileDeleted(script)
}

-- Script execution contracts by kind

rule ExecuteMacro {
    when: MacroExpansionRequested(script, template_context)
    requires: script.kind = macro
    requires: script.enabled = true
    requires: script.entrypoint != ""
    -- Macro scripts are invoked during template rendering
    -- via [[slug param1=value1 param2=value2]] syntax in post content
    -- Unknown macro names are resolved against enabled macro scripts by slug.
    -- They receive named parameters plus template_context.env fields that
    -- include isPreview, mainLanguage, languagePrefix, hook, source.kind,
    -- and translations.
    -- They return HTML and run sequentially with config.macro_timeout per
    -- invocation.
    -- Macro failures degrade to empty output for that invocation and do not
    -- abort rendering of the surrounding page.
    ensures: MacroOutputProduced(script, html_output)
}

rule ExecuteUtility {
    when: RunUtilityRequested(script)
    requires: script.kind = utility
    requires: script.enabled = true
    requires: script.entrypoint != ""
    -- Utility scripts commonly perform long-running data manipulation work.
    -- They are manually started by an operator action, run as managed jobs,
    -- may issue host-backed API calls, may emit progress during execution,
    -- and may be cancelled by the operator.
    ensures: UtilityOutputProduced(script, stdout)
}

rule ExecuteTransform {
    when: BlogmarkReceived(data)
    -- Transform scripts run sequentially on blogmark deep link data
    -- Input: title, content, tags, categories, source url
    -- Each transform can modify the data before post creation.
    -- Execution uses the same managed job host API contract as other batch
    -- scripts and may report progress while mass-processing remote or local
    -- content.
    let transforms = Scripts where project_id = data.project_id and kind = transform and enabled = true
    for t in ordered_by(transforms, s => s.updated_at, s => s.slug, s => s.id):
        requires: t.entrypoint != ""
        ensures: TransformApplied(t, data)

    @guarantee BlogmarkInputHardening
        -- The deep link is sanitized before transforms run. Title and url have
        -- control characters stripped and are trimmed. The url is kept only
        -- when it is http(s) with a host, after removing any credentials and
        -- fragment, and at most config.blogmark_max_url_length characters;
        -- otherwise it is dropped so it can never reach the post body. The
        -- title is capped at config.blogmark_max_title_length and falls back to
        -- the url host when blank.

    @guarantee BlogmarkDefaultBody
        -- When the deep link supplies no content, the post body defaults to a
        -- markdown link [title](url) to the bookmarked page, with the title
        -- escaped, so transforms operate on the same candidate whether or not
        -- an explicit body was provided. Explicit content always wins.

    @guarantee BlogmarkProjectTargeting
        -- A link's own project_id determines the target project. When it names a
        -- project that exists and differs from the active one, the shell
        -- switches to it before importing so the new post is shown in context.
        -- When it names a project that does not exist in this install (e.g. a
        -- bookmarklet copied from another instance) the import is refused with
        -- an error — the post is never redirected into a different project.
        -- With no link project_id, the active project is used; with no link
        -- project_id and no active project the import is refused with a warning.

    @guarantee BlogmarkColdStartDelivery
        -- A deep link that arrives before the shell is ready (e.g. the
        -- bookmarklet launching the app) is queued and replayed only after the
        -- client has finished initialising and rehydrating its stored
        -- workbench session, so the post is created and the editor it opens is
        -- not overwritten by the session restore. A session restore arriving
        -- after a deep link (e.g. when the link switched projects) preserves
        -- the newly opened editor tab as the active tab.

    @guarantee BlogmarkEditorActivation
        -- Once the transformed draft and its text-search entry exist, the shell
        -- opens that draft in the post editor immediately. Post-created side
        -- effects that may take longer, including lazy model loading and semantic
        -- indexing, continue without delaying or replacing the opened editor.

    @guarantee TransformTrigger
        -- Transform scripts are triggered automatically by blogmark import.
        -- Each script receives the current post candidate plus a context with
        -- source='blogmark' and the originating URL.

    @guarantee TransformPipelineContinuation
        -- Transform errors are captured per script and do not roll back the
        -- last valid post state produced by earlier transforms.
        -- The pipeline continues with subsequent enabled transforms.

    @guarantee TransformToastBudget
        -- Transform scripts may emit toast feedback.
        -- At most config.transform_max_toasts_per_script toasts are accepted
        -- from any one transform, with a total budget of
        -- config.transform_max_toasts_total across the pipeline.
        -- Individual toast messages are truncated to
        -- config.transform_max_toast_length characters.

    @guidance
        -- RuDS accepts only ruds://new-post deep links from browser bookmarks.
        -- bds2:// remains assigned to bDS2 so both applications can be installed.
        -- Ordering is deterministic: updated_at, then slug, then id
}

rule RebuildScriptsFromFiles {
    when: RebuildScriptsFromFilesRequested(project)
    for file in scan_directory(project.public_dir + "/scripts", "*.lua"):
        let parsed = parse_script_file(file)
        ensures: Script.created(parsed)
}
