-- allium: 1
-- Terminal UI (issue #26, phase 4)
-- Scope: extension (second renderer over the shared UI core)
-- Distilled from: lib/bds/tui.ex, lib/bds/ui/sidebar.ex, lib/bds/ui/post_editor/*.ex

entity TuiState {
    view: posts | media | templates | scripts | tags | settings | git
    focus: sidebar | editor
    selected_index: Integer
    editing_post: Boolean
    editor_preview: Boolean
        -- true while the read-only rendered-Markdown preview is shown
    -- The editor drives the same BDS.UI.PostEditor workflow as the GUI:
    -- canonical-language edits update the post, other languages update
    -- translations; publish routes through the same publishing pipeline.
}

config {
    -- Same preference sections as the settings backends; matches the
    -- GUI settings nav minus the GUI-only Style tab.
    tui_settings_sections: Set<String> = {
        "project", "editor", "content", "ai",
        "technology", "publishing", "data", "mcp"
    }
}

surface TuiSurface {
    facing _: TuiRuntime

    provides:
        TuiKeyPressed(code, modifiers)
        TuiEventReceived(entity, entity_id, action)
        TuiSettingsChanged(key)
        ShellTaskCompleted(route)
        ProjectsOverlaySelectionConfirmed(project)
        FolderPathPromptTabPressed(input)
        FolderPathPromptSubmitted(path)
        SearchPromptChanged(query)
        SearchPromptConfirmed(query)
        SearchPromptCancelled()
        CommandListSelectionConfirmed(command)
        GitCommitPromptSubmitted(message)

    @guarantee OverlayPrecedence
        -- Open overlays and status-line prompts capture key input
        -- before the sidebar/editor key rules below apply. Overlays
        -- clear the terminal cells beneath them.

    @guarantee EditorRendering
        -- The post editor renders the buffer with syntax highlighting
        -- (markdown with [[macro]] accents for posts, HTML+Liquid for
        -- templates, Lua for scripts), soft-wraps long lines to the
        -- viewport width, keeps the cursor's visual row visible while
        -- scrolling, and highlights the cursor's source line. There is
        -- no line-number gutter.

    @guarantee SidebarSelectionVisibility
        -- The sidebar viewport follows the selected entry.
}

-- ─── Sidebar navigation ─────────────────────────────────────

rule SidebarNavigation {
    when: TuiKeyPressed(code, _)
    requires: code = "up" or code = "down" or code = "j" or code = "k"
    requires: TuiState.focus = sidebar
    let items = flattened_sidebar_items()
        -- BDS.UI.Sidebar.view data, identical to the GUI sidebar
    ensures:
        if code = "up" or code = "k":
            TuiState.selected_index = previous_selectable_index(items, TuiState.selected_index)
        else:
            TuiState.selected_index = next_selectable_index(items, TuiState.selected_index)
        -- section headers are not selectable; movement skips them
}

rule OpenEntry {
    when: TuiKeyPressed(code, _)
    requires: code = "enter"
    requires: TuiState.focus = sidebar
    let entry = selected_sidebar_entry(TuiState.selected_index)
    ensures:
        if entry.kind = "post":
            TuiState.editing_post = true
            TuiState.focus = editor
            -- editor seeded from the persisted form (title + textarea);
            -- syntax highlighting and word wrap on. New empty posts
            -- created with "n" open in the editor the same way.
        if entry.kind = "image":
            TerminalImagePreviewOpened(entry)
        if entry.kind != "post" and entry.kind != "image":
            StatusMessageShown(message_key: "tui.editing_not_available")
}

-- ─── Post editor keys ───────────────────────────────────────

rule SaveAndPublish {
    when: TuiKeyPressed(code, modifiers)
    requires: code = "s" or code = "p"
    requires: modifiers = "ctrl"
    requires: TuiState.editing_post
    ensures:
        if code = "s":
            PostDraftPersisted()
        else:
            PostPersistedAndPublished()
    -- Both persist through BDS.UI.PostEditor.Persistence, so files,
    -- embeddings, search index, and the event bus behave exactly as a
    -- GUI save.
}

rule EditorMode {
    when: TuiKeyPressed(code, modifiers)
    requires: code = "e"
    requires: modifiers = "ctrl"
    requires: TuiState.editing_post
    ensures: TuiState.editor_preview = not TuiState.editor_preview
        -- toggles between the syntax-highlighted editor (the default
        -- when opening a post) and the read-only rendered-Markdown
        -- preview (tui-markdown: headings/emphasis/lists styled);
        -- keys in preview never edit content
}

rule EditorEscape {
    when: TuiKeyPressed(code, _)
    requires: code = "esc"
    requires: TuiState.focus = editor
    ensures: TuiState.focus = sidebar
        -- esc returns to the sidebar from either editor mode
}

rule AiQuickAction {
    when: TuiKeyPressed(code, modifiers)
    requires: code = "g"
    requires: modifiers = "ctrl"
    requires: TuiState.editing_post
    ensures:
        if active_endpoint_configured:
            AISuggestionRequested(entity_type: "post", entity_id: edited_post_id())
            -- one-shot AI post analysis (title/excerpt suggestions)
        else:
            StatusMessageShown(message_key: "tui.ai_unavailable_offline")
            -- airplane gating: without a local endpoint the TUI shows
            -- a status message instead of calling out
}

-- ─── Project switcher ───────────────────────────────────────

rule ProjectSwitcher {
    when: TuiKeyPressed(code, modifiers)
    requires: code = "p"
    requires: modifiers != "ctrl"
    requires: TuiState.focus = sidebar
    ensures: ProjectsOverlayOpened(projects: all_projects(), active: active_project())
        -- all projects from the caching database, active one marked
}

rule ProjectOverlayActivate {
    when: ProjectsOverlaySelectionConfirmed(project)
    ensures: ActiveProjectChanged(project)
        -- BDS.Projects.set_active_project
    ensures: SidebarReloaded()
}

rule ProjectOverlayFolderPrompt {
    when: TuiKeyPressed(code, _)
    requires: code = "o"
    requires: projects_overlay_open()
    ensures: FolderPathPromptOpened()
        -- switches the overlay to a folder-path prompt with bash-style
        -- tab completion
}

rule FolderPromptTabCompletion {
    when: FolderPathPromptTabPressed(input)
    let expanded = expand_home(input)
        -- a leading "~" expands to the home directory
    let candidates = completion_candidates(expanded)
        -- only directories are offered; dotfolders only when the last
        -- path segment starts with "."
    ensures:
        if candidates.count = 1:
            FolderPromptInputReplaced(candidates.first + "/")
            -- a unique directory completes with a trailing slash
        if candidates.count > 1:
            FolderPromptInputReplaced(longest_common_prefix(candidates))
            FolderPromptCandidatesListed(candidates)
            -- ambiguous prefix: longest common prefix + candidate list
        if candidates.count = 0:
            FolderPromptUnchanged()
}

rule FolderPromptSubmit {
    when: FolderPathPromptSubmitted(path)
    let expanded = expand_home(path)
    ensures:
        if directory_exists(expanded):
            ProjectOpenedFromFolder(expanded)
            -- BDS.Projects.create_project with data_path, named after
            -- the folder, then activated
            DatabaseRebuildQueued(expanded)
            -- queued automatically through the rebuild_database shell
            -- command so the folder's content is loaded into the
            -- caching database
        else:
            FolderPromptErrorShown(path)
            -- invalid paths keep the prompt open and report the error
}

-- ─── Sidebar search ─────────────────────────────────────────

enum TuiSearchTokenKind {
    text
        -- plain word: text search
    tag_filter
        -- "tag:x": filters like the GUI tag chip
    category_filter
        -- "category:x": filters like the GUI category chip
    day_filter
        -- ISO date (yyyy-mm-dd): narrows to that day; extends the
        -- shared sidebar queries
}

value TuiSearchToken {
    kind: TuiSearchTokenKind
    argument: String
        -- the search word, tag/category name, or ISO date
}

surface TuiSearchTokenSurface {
    context token: TuiSearchToken

    exposes:
        token.kind
        token.argument
}

rule SidebarSearch {
    when: TuiKeyPressed(code, _)
    requires: code = "/"
    requires: TuiState.focus = sidebar
    ensures: SearchPromptOpened()
        -- vi-style search prompt in the status line
}

rule SidebarSearchInput {
    when: SearchPromptChanged(query)
    let tokens = parse_search_tokens(query)
        -- black box: each whitespace-separated token classified as a
        -- TuiSearchToken; tokens combine with AND
    ensures: SidebarFiltered(filters: tokens)
        -- live-filters the current view through the same shared
        -- BDS.UI.Sidebar filter params the GUI sidebar uses
        -- (posts, pages, media)
}

rule SidebarSearchCommit {
    when: SearchPromptConfirmed(query)
    ensures: SidebarFilterKept(view: TuiState.view, query: query)
        -- enter keeps the filter, shown appended to the sidebar title;
        -- filters are per view
}

rule SidebarSearchCancel {
    when: SearchPromptCancelled()
    ensures: SidebarFilterCleared(view: TuiState.view)
        -- esc clears the filter
}

-- ─── Command prompt ─────────────────────────────────────────

rule CommandPrompt {
    when: TuiKeyPressed(code, _)
    requires: code = ":"
    requires: TuiState.focus = sidebar
    ensures: CommandListOpened(commands: parameterless_shell_commands())
        -- vi-style prompt: filterable list of the parameterless
        -- Blog-menu shell commands (metadata diff, validate site,
        -- force render, rebuilds, reindex, translations, duplicates,
        -- upload, browser preview URL) — the same
        -- BDS.Desktop.ShellCommands backend as the GUI menu. ":?"
        -- shows the full list as help. Commands whose follow-up UI is
        -- GUI-only (validate translations, find duplicates) are marked.
}

rule CommandPromptExecute {
    when: CommandListSelectionConfirmed(command)
    ensures: ShellCommandExecuted(command)
    ensures: TaskProgressShownInStatusLine()
        -- queued tasks report progress in the status line until all
        -- tasks finish
}

-- ─── Settings panel (issue #29) ─────────────────────────────

rule SettingsPanel {
    when: TuiKeyPressed(code, _)
    requires: code = "6"
    requires: TuiState.focus = sidebar
    -- "6" matches the GUI panel numbering
    ensures: TuiState.view = settings
    ensures: SettingsSectionsListed(sections: config.tui_settings_sections)
        -- enter on a section opens a section-specific editor in the
        -- main area: a generic typed-field form from BDS.UI.SettingsForm
}

rule SettingsFieldEdit {
    when: TuiKeyPressed(code, _)
    requires: code = "enter"
    requires: TuiState.view = settings
    let row = selected_settings_row()
    requires: not row.read_only
        -- read-only info rows are skipped by the selection
    ensures:
        if row.kind = "text":
            StatusLinePromptOpened(row)
            -- esc cancels the prompt, then closes the form
        if row.kind = "boolean":
            SettingToggled(row)
        if row.kind = "enum":
            SettingCycled(row)
            -- cycles the enum through its options
}

rule SettingsSave {
    when: TuiKeyPressed(code, modifiers)
    requires: code = "s"
    requires: modifiers = "ctrl"
    requires: TuiState.view = settings
    ensures: SettingsSectionSaved(selected_settings_section())
        -- saves through the same backends as the GUI editor
        -- (BDS.Metadata, BDS.Settings, BDS.AI, BDS.MCP.AgentConfig)
        -- and reloads the form. Category removal and AI model
        -- discovery remain GUI-only.
}

-- ─── Tags panel (issue #34) ─────────────────────────────────

rule TagsPanel {
    when: TuiKeyPressed(code, _)
    requires: code = "5"
    requires: TuiState.focus = sidebar
    -- "5" matches the GUI panel numbering
    ensures: TuiState.view = tags
    ensures: TagsSectionsListed()
        -- the same three sections as the GUI tags editor (Tag Cloud,
        -- Create / Edit, Merge Tags — from BDS.UI.Sidebar's tags nav
        -- view); enter opens the section in the main area, all backed
        -- by BDS.UI.TagsPanel over the same BDS.Tags operations as the
        -- GUI editor. Cloud lists tags with post usage counts ordered
        -- by usage; manage is alphabetical. Text prompts live in the
        -- status line; esc cancels the prompt, then closes the panel.
        -- Domain tag events reload an open panel, keeping it in sync
        -- with GUI and CLI writes.
}

rule TagsManageKeys {
    when: TuiKeyPressed(code, _)
    requires: TuiState.view = tags
    requires: tags_manage_section_active()
    ensures:
        if code = "n":
            TagCreatePromptOpened()
        if code = "enter":
            TagRenamePromptOpened(selected_tag())
        if code = "c":
            TagColourCycled(selected_tag())
            -- cycles through the shared colour presets
        if code = "t":
            TagTemplateCycled(selected_tag())
            -- cycles the tag's post template
        if code = "d":
            TagDeleteConfirmArmed(selected_tag())
            -- "d" then "y" deletes with post-count confirmation;
            -- any other key cancels
        if code = "s":
            TagsSyncedFromPosts()
}

rule TagsMergeKeys {
    when: TuiKeyPressed(code, _)
    requires: TuiState.view = tags
    requires: tags_merge_section_active()
    ensures:
        if code = "space":
            TagMarkToggled(selected_tag())
        if code = "m" and marked_tags().count >= 2:
            TagsMerged(source_tags: marked_tags(), target_tag: selected_tag())
            -- merges the marked tags into the selected one (target
            -- included, at least two marked)
}

-- ─── Git panel (issue #30) ──────────────────────────────────

rule GitPanel {
    when: TuiKeyPressed(code, _)
    requires: code = "7"
    requires: TuiState.focus = sidebar
    -- "7" matches the GUI panel numbering; content sync panel
    ensures: TuiState.view = git
    ensures: GitPanelOpened()
        -- sidebar: changed files from git status (code + path, branch
        -- with ahead/behind counts in the title), commit history in
        -- the lower half like the GUI (short hash + subject; ↑ marks
        -- commits that still need a push, ↓ remote-only ones).
        -- Main area: scrollable whole-folder diff (staged + unstaged,
        -- capped) paged with pgup/pgdn. Every action is BDS.Git, the
        -- same backend as the GUI git panel.
}

rule GitPanelKeys {
    when: TuiKeyPressed(code, _)
    requires: TuiState.view = git
    requires: is_git_repository(active_project())
    ensures:
        if code = "enter":
            GitDiffJumpedToFile(selected_changed_file())
        if code = "c":
            GitCommitPromptOpened()
            -- status-line commit prompt
        if code = "u":
            GitPullStarted()
            -- ff-only, runs off the UI process, reports back as a
            -- status toast
        if code = "s":
            GitPushStarted()
            -- runs off the UI process, reports back as a status toast
}

rule GitPanelNonRepo {
    when: TuiKeyPressed(code, _)
    requires: TuiState.view = git
    requires: not is_git_repository(active_project())
    requires: code = "c" or code = "u" or code = "s"
    ensures: StatusMessageShown(message_key: "tui.not_a_git_repository")
        -- a project folder that is not a git repository shows a
        -- message and refuses the actions
}

rule GitCommitSubmit {
    when: GitCommitPromptSubmitted(message)
    ensures:
        if message = "":
            GitCommitRejectedEmptyMessage()
            -- empty messages are rejected
        else:
            GitCommitExecuted(message)
            -- git add -A + commit
}

-- ─── Report panels ──────────────────────────────────────────

rule ReportPanels {
    when: ShellTaskCompleted(route)
    requires: route = "metadata_diff" or route = "site_validation"
    requires: task_completed_in_current_session(route)
        -- reports completed before this session started never pop up
    ensures: ReportPanelOpened(route)
        -- scrollable report panel showing the differences
}

rule ReportPanelApply {
    when: TuiKeyPressed(code, _)
    requires: code = "enter"
    requires: report_panel_open()
    ensures: ReportApplied(open_report())
        -- enter applies the whole report: metadata diff repairs all
        -- items from the filesystem (file -> db) and imports orphan
        -- files; site validation applies the full report incrementally
        -- (render missing, remove extra, re-render updated) — matching
        -- the GUI defaults
    ensures: ReportPanelClosed()
}

rule ReportPanelDismiss {
    when: TuiKeyPressed(code, _)
    requires: code = "esc"
    requires: report_panel_open()
    ensures: ReportPanelClosed()
        -- esc closes the report without applying
}

-- ─── Multi-client sync ──────────────────────────────────────

rule MultiClientSync {
    when: TuiEventReceived(entity, entity_id, action)
    -- Keeps TUI sessions synchronized with GUI clients and pipeline
    -- ingestion.
    ensures: SidebarReloaded()
    ensures:
        if entity = "post" and action = "deleted" and edited_post_id() = entity_id:
            TuiState.editing_post = false
            -- a post deleted elsewhere closes the open editor
}

rule ServerSideLocale {
    when: TuiSettingsChanged(key)
    requires: key = "ui.language"
    ensures: TuiRelocalized()
        -- the TUI re-reads the server-side UI language and re-renders;
        -- all clients always share one language
}
