//! Chord-action binding table.
//!
//! Stage 1 of the rebindable-keys roadmap: extract every meta- and
//! bund-sub-chord from the hardcoded `match` arms in `app.rs` into
//! a data-driven `KeyBindings` struct. App dispatch becomes a
//! single table lookup followed by a `run_action` switch.
//!
//! ## What's here (Stage 1)
//!
//! * `Action` — one variant per reachable handler. Names are
//!   `snake_case` so they serialise to dotted strings in HJSON
//!   (`tree.add_chapter`, `bund.run_buffer`, …).
//! * `Scope` — pane filter on each binding entry.
//! * `BindingEntry` — `(chord, action, scope)` triple.
//! * `KeyBindings::defaults()` — produces the canonical table
//!   matching today's hardcoded chord layout exactly.
//! * `KeyBindings::resolve_*` — table lookups consulted by
//!   `handle_meta_action` / `handle_bund_action`.
//!
//! ## What's not here yet (Stage 2)
//!
//! * `ink.key.*` Bund stdlib for runtime rebinding.
//! * Auto-generated status-bar hint strings.
//! * Migration of F-keys (F1/F3/F4/F5/F6/F7) into the table.

use crossterm::event::KeyEvent;
use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, LazyLock};

use super::focus::Focus;
use super::keymap::KeyChord;

/// Which pane(s) a binding applies in. The first binding whose
/// scope matches the current focus wins, so narrow-scoped entries
/// (`Editor`) MUST come before broad ones (`Any`) in
/// `KeyBindings::defaults()`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
    /// Active in any pane.
    Any,
    /// Editor pane only.
    Editor,
    /// Tree pane + the search bar (which lives above the tree).
    Tree,
    /// AI pane + the AI prompt input line.
    Ai,
}

impl Scope {
    pub fn matches(self, focus: Focus) -> bool {
        match self {
            Scope::Any => true,
            Scope::Editor => focus == Focus::Editor,
            Scope::Tree => matches!(focus, Focus::Tree | Focus::SearchBar),
            Scope::Ai => matches!(focus, Focus::Ai | Focus::AiPrompt),
        }
    }
}

/// Every user-reachable chord-action. New chord features add a
/// variant here + an arm in `App::run_action`. Variant names
/// serialise (via serde) to the canonical dotted form used in
/// HJSON `keys.bindings`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
    // ── Tree pane ─────────────────────────────────────────────
    #[serde(rename = "tree.add_book")]
    AddBook,
    #[serde(rename = "tree.add_chapter")]
    AddChapter,
    #[serde(rename = "tree.add_subchapter")]
    AddSubchapter,
    #[serde(rename = "tree.add_paragraph")]
    AddParagraph,
    #[serde(rename = "tree.delete_node")]
    DeleteNode,
    #[serde(rename = "tree.morph_type")]
    MorphType,
    #[serde(rename = "tree.reorder_up")]
    ReorderUp,
    #[serde(rename = "tree.reorder_down")]
    ReorderDown,

    // ── Editor pane ───────────────────────────────────────────
    #[serde(rename = "editor.save")]
    Save,
    #[serde(rename = "editor.create_snapshot")]
    CreateSnapshot,
    #[serde(rename = "editor.cycle_status")]
    CycleStatus,
    #[serde(rename = "editor.open_function_picker")]
    OpenFunctionPicker,
    #[serde(rename = "editor.rename_to_first_sentence")]
    RenameToFirstSentence,
    /// `P` in the editor — context-sensitive: image-picker when
    /// the cursor sits inside `#image(...)`, otherwise Places
    /// lexicon lookup.
    #[serde(rename = "editor.lookup_places_or_image")]
    LookupPlacesOrImage,
    #[serde(rename = "editor.lookup_characters")]
    LookupCharacters,
    #[serde(rename = "editor.lookup_notes")]
    LookupNotes,
    #[serde(rename = "editor.lookup_artefacts")]
    LookupArtefacts,
    #[serde(rename = "editor.open_quickref")]
    OpenQuickref,

    // ── Global meta ───────────────────────────────────────────
    #[serde(rename = "global.open_credits")]
    OpenCredits,
    #[serde(rename = "global.open_book_info")]
    OpenBookInfo,
    #[serde(rename = "global.open_llm_picker")]
    OpenLlmPicker,
    #[serde(rename = "global.toggle_sound")]
    ToggleSound,
    #[serde(rename = "global.schedule_assemble")]
    ScheduleAssemble,
    #[serde(rename = "global.schedule_build")]
    ScheduleBuild,
    #[serde(rename = "global.schedule_take")]
    ScheduleTake,
    #[serde(rename = "global.toggle_typewriter")]
    ToggleTypewriter,
    #[serde(rename = "global.toggle_ai_fullscreen")]
    ToggleAiFullscreen,
    #[serde(rename = "global.status_filter_ready")]
    StatusFilterReady,
    #[serde(rename = "global.status_filter_final")]
    StatusFilterFinal,
    #[serde(rename = "global.status_filter_third")]
    StatusFilterThird,
    #[serde(rename = "global.status_filter_second")]
    StatusFilterSecond,
    #[serde(rename = "global.status_filter_first")]
    StatusFilterFirst,
    #[serde(rename = "global.status_filter_napkin")]
    StatusFilterNapkin,
    #[serde(rename = "global.status_filter_none")]
    StatusFilterNone,

    // ── AI pane ───────────────────────────────────────────────
    #[serde(rename = "ai.clear_chat")]
    ClearChat,

    // ── Bund prefix ───────────────────────────────────────────
    #[serde(rename = "bund.run_buffer")]
    BundRunBuffer,
    #[serde(rename = "bund.new_script")]
    BundNewScript,
    #[serde(rename = "bund.open_eval_modal")]
    BundOpenEvalModal,
    /// Ctrl+Z ? — open the script picker. Lists scripts in the
    /// cursor's branch; `A` toggles to the `Scripts` system book.
    #[serde(rename = "bund.open_script_picker")]
    BundOpenScriptPicker,

    // ── Top-level (1.2.4+ F-key migration) ────────────────────
    /// F1 anywhere — open the Help-book query modal.
    #[serde(rename = "help.query")]
    HelpQuery,
    /// F2 in Tree — rename the cursor's node.
    #[serde(rename = "tree.rename")]
    RenameNode,
    /// F3 in Tree — file picker, import context.
    #[serde(rename = "tree.file_picker_import")]
    FilePickerTreeImport,
    /// F3 in Editor — file picker, "load into buffer" context.
    #[serde(rename = "editor.file_picker_load")]
    FilePickerEditorLoad,
    /// F4 in Editor — toggle split-edit mode.
    #[serde(rename = "editor.toggle_split")]
    ToggleSplit,
    /// Ctrl+F4 in Editor — accept the snapshot pane into the
    /// live buffer.
    #[serde(rename = "editor.accept_split_snapshot")]
    AcceptSplitSnapshot,
    /// F6 in Editor — open the snapshot picker.
    #[serde(rename = "editor.snapshot_picker")]
    OpenSnapshotPicker,
    /// F7 in Editor — grammar check the open paragraph.
    #[serde(rename = "editor.grammar_check")]
    GrammarCheck,
    /// F9 anywhere — cycle AI scope mode.
    #[serde(rename = "ai.cycle_mode")]
    CycleAiMode,
    /// F10 anywhere — toggle inference mode (Local ↔ Full).
    #[serde(rename = "ai.toggle_inference_mode")]
    ToggleInferenceMode,

    // ── View prefix (1.2.4+, default Ctrl+V) ──────────────────
    /// Ctrl+V 1 (Editor) — write the open paragraph's live buffer
    /// as markdown to cwd.
    #[serde(rename = "view.export_markdown_buffer")]
    ViewExportMarkdownBuffer,
    /// Ctrl+V 2 (Editor) — write the containing subchapter's
    /// subtree as markdown to cwd.
    #[serde(rename = "view.export_markdown_subchapter")]
    ViewExportMarkdownSubchapter,
    /// Ctrl+V 1 (Tree) — write the tree-cursor's node + descendants
    /// as markdown to cwd.
    #[serde(rename = "view.export_markdown_subtree")]
    ViewExportMarkdownSubtree,
    /// Ctrl+V S — toggle similar-paragraph mode (vector-similarity
    /// picker, side-by-side editor).
    #[serde(rename = "view.toggle_similar_mode")]
    ViewToggleSimilarMode,
    /// Ctrl+V G — open the writing-progress modal.
    #[serde(rename = "view.open_progress")]
    ViewOpenProgress,
    /// Ctrl+V T — open the per-paragraph target-words input modal.
    #[serde(rename = "view.open_paragraph_target")]
    ViewOpenParagraphTarget,
    /// Ctrl+V A — switch the tree pane into "select paragraph
    /// to link" mode. Enter on a paragraph adds it to the open
    /// paragraph's `linked_paragraphs`.
    #[serde(rename = "view.add_link")]
    ViewAddLink,
    /// Ctrl+V I — reverse of `view.add_link`. Tree pane picker;
    /// Enter on a paragraph adds the OPEN paragraph to THAT
    /// paragraph's outgoing links (creates an incoming link
    /// for current).
    #[serde(rename = "view.add_incoming_link")]
    ViewAddIncomingLink,
    /// Ctrl+V L — open the linked-paragraphs floating modal
    /// (`D` removes a link).
    #[serde(rename = "view.list_links")]
    ViewListLinks,
    /// Ctrl+V K — open the backlinks floating modal. Reverse of
    /// `view.list_links`: shows paragraphs whose
    /// `linked_paragraphs` contains the open paragraph.
    #[serde(rename = "view.list_backlinks")]
    ViewListBacklinks,
    /// Ctrl+V B — toggle bookmark on the open paragraph.
    #[serde(rename = "view.toggle_bookmark")]
    ViewToggleBookmark,
    /// Ctrl+V M — open the bookmark picker.
    #[serde(rename = "view.list_bookmarks")]
    ViewListBookmarks,
    /// Ctrl+V P — fuzzy paragraph picker (1.2.4+).
    #[serde(rename = "view.fuzzy_paragraph_picker")]
    ViewFuzzyParagraphPicker,

    /// Explicit "this chord does nothing" — overlay entries can
    /// set `action: "none"` to disable a default chord.
    #[serde(rename = "none")]
    None,

    /// Runtime-only: a Bund lambda registered under the given
    /// name via `ink.key.bind_lambda`. Dispatch routes to
    /// `scripting::hooks::fire(name, vec![])`. `#[serde(skip)]` —
    /// these can't appear in HJSON; they live only in memory and
    /// vanish on process exit.
    #[serde(skip)]
    BundLambda(Arc<str>),
}

impl Action {
    /// Short label used in the auto-generated status-bar meta
    /// hint ("add chapter", "morph-type", …). Returns `""` for
    /// `None` and the lambda name for `BundLambda`.
    pub fn label(&self) -> String {
        match self {
            Action::AddBook => "add book".into(),
            Action::AddChapter => "add chapter".into(),
            Action::AddSubchapter => "add subchapter".into(),
            Action::AddParagraph => "add paragraph".into(),
            Action::DeleteNode => "delete".into(),
            Action::MorphType => "morph-type".into(),
            Action::ReorderUp => "↑ reorder".into(),
            Action::ReorderDown => "↓ reorder".into(),

            Action::Save => "save".into(),
            Action::CreateSnapshot => "snapshot".into(),
            Action::CycleStatus => "status".into(),
            Action::OpenFunctionPicker => "func".into(),
            Action::RenameToFirstSentence => "retitle".into(),
            Action::LookupPlacesOrImage => "place/pic".into(),
            Action::LookupCharacters => "character".into(),
            Action::LookupNotes => "notes".into(),
            Action::LookupArtefacts => "artefacts".into(),
            Action::OpenQuickref => "help".into(),

            Action::OpenCredits => "credits".into(),
            Action::OpenBookInfo => "info".into(),
            Action::OpenLlmPicker => "LLM".into(),
            Action::ToggleSound => "sound".into(),
            Action::ScheduleAssemble => "assemble".into(),
            Action::ScheduleBuild => "build".into(),
            Action::ScheduleTake => "take".into(),
            Action::ToggleTypewriter => "typewriter".into(),
            Action::ToggleAiFullscreen => "AI-full".into(),
            Action::StatusFilterReady => "Ready".into(),
            Action::StatusFilterFinal => "Final".into(),
            Action::StatusFilterThird => "Third".into(),
            Action::StatusFilterSecond => "Second".into(),
            Action::StatusFilterFirst => "First".into(),
            Action::StatusFilterNapkin => "Napkin".into(),
            Action::StatusFilterNone => "None".into(),

            Action::ClearChat => "clear chat".into(),

            Action::BundRunBuffer => "run buffer".into(),
            Action::BundNewScript => "new script".into(),
            Action::BundOpenEvalModal => "eval".into(),
            Action::BundOpenScriptPicker => "pick script".into(),

            Action::HelpQuery => "help".into(),
            Action::RenameNode => "rename".into(),
            Action::FilePickerTreeImport => "file picker".into(),
            Action::FilePickerEditorLoad => "load file".into(),
            Action::ToggleSplit => "split".into(),
            Action::AcceptSplitSnapshot => "accept snap".into(),
            Action::OpenSnapshotPicker => "snapshots".into(),
            Action::GrammarCheck => "grammar".into(),
            Action::CycleAiMode => "AI mode".into(),
            Action::ToggleInferenceMode => "infer mode".into(),

            Action::ViewExportMarkdownBuffer => "md buffer".into(),
            Action::ViewExportMarkdownSubchapter => "md subchap".into(),
            Action::ViewExportMarkdownSubtree => "md subtree".into(),
            Action::ViewToggleSimilarMode => "similar".into(),
            Action::ViewOpenProgress => "progress".into(),
            Action::ViewOpenParagraphTarget => "para target".into(),
            Action::ViewAddLink => "add link".into(),
            Action::ViewAddIncomingLink => "add ← link".into(),
            Action::ViewListLinks => "list links".into(),
            Action::ViewListBacklinks => "backlinks".into(),
            Action::ViewToggleBookmark => "bookmark".into(),
            Action::ViewListBookmarks => "bookmarks".into(),
            Action::ViewFuzzyParagraphPicker => "find ¶".into(),

            Action::None => String::new(),
            Action::BundLambda(name) => format!("λ {name}"),
        }
    }

    /// Long, user-friendly description used by Ctrl+B H (the
    /// quick-reference panel). Where `label()` is squeezed into
    /// the status-bar hint and is therefore terse to the point of
    /// cryptic, this is a full sentence aimed at someone reading
    /// the panel for the first time. Returns `""` for `None` and
    /// a generic "user-bound Bund lambda" line for `BundLambda`.
    pub fn description(&self) -> String {
        match self {
            // ── Tree ──────────────────────────────────────────
            Action::AddBook => "Add a new top-level Book to the project.".into(),
            Action::AddChapter => "Add a Chapter under the current branch.".into(),
            Action::AddSubchapter =>
                "Add a Subchapter under the current chapter / subchapter.".into(),
            Action::AddParagraph =>
                "Add a Paragraph leaf under the current branch (typst content).".into(),
            Action::DeleteNode =>
                "Delete the node under the tree cursor (asks for confirmation).".into(),
            Action::MorphType =>
                "Cycle the selected leaf's flavour: Paragraph(typst) → Paragraph(hjson) → Script(bund).".into(),
            Action::ReorderUp =>
                "Move the current node up among its siblings.".into(),
            Action::ReorderDown =>
                "Move the current node down among its siblings.".into(),

            // ── Editor / save / snapshots ─────────────────────
            Action::Save =>
                "Save the open paragraph to disk (autosave also fires on idle).".into(),
            Action::CreateSnapshot =>
                "Snapshot the open paragraph (history kept under F6 picker).".into(),
            Action::CycleStatus =>
                "Cycle the open paragraph's status: None → Napkin → First → Second → Third → Final → Ready.".into(),
            Action::OpenFunctionPicker =>
                "Open the Typst function picker — type to filter, Enter inserts #name(…).".into(),
            Action::RenameToFirstSentence =>
                "Rename the open paragraph using its first sentence as the new title.".into(),
            Action::LookupPlacesOrImage =>
                "Inside #image(\"…\"): pick a sibling image. Otherwise run a Places RAG over the selection.".into(),
            Action::LookupCharacters =>
                "Character RAG — selection is queried against the Characters book, answer streams in AI pane.".into(),
            Action::LookupNotes =>
                "Notes RAG — selection is queried against the Notes book, answer streams in AI pane.".into(),
            Action::LookupArtefacts =>
                "Artefacts RAG — selection is queried against the Artefacts book, answer streams in AI pane.".into(),
            Action::OpenQuickref =>
                "Open this Quick reference panel (live keymap + static cheatsheet).".into(),

            // ── Global / panels ───────────────────────────────
            Action::OpenCredits =>
                "Show inkhaven version, author, and bundled-component credits.".into(),
            Action::OpenBookInfo =>
                "Open the current book's info panel: paths, stats, PDF status.".into(),
            Action::OpenLlmPicker =>
                "Switch the active LLM provider — choice is persisted to inkhaven.hjson.".into(),
            Action::ToggleSound =>
                "Toggle typewriter SFX (Enter / focus-out clicks). Choice is persisted to inkhaven.hjson.".into(),
            Action::ScheduleAssemble =>
                "Book assembly — emit a typst-compilable tree under the artefacts dir.".into(),
            Action::ScheduleBuild =>
                "Build the book — assemble + run `typst compile` (PDF lands in artefacts dir).".into(),
            Action::ScheduleTake =>
                "Take the book — build then copy the PDF (and any configured extras) into the launch cwd.".into(),
            Action::ToggleTypewriter =>
                "Toggle full-screen typewriter mode — hides every other pane for focused writing.".into(),
            Action::ToggleAiFullscreen =>
                "Toggle full-screen AI mode — AI pane | chat history + AI prompt.".into(),
            Action::StatusFilterReady =>
                "Filter the tree to paragraphs marked Ready under the cursor.".into(),
            Action::StatusFilterFinal =>
                "Filter the tree to paragraphs marked Final under the cursor.".into(),
            Action::StatusFilterThird =>
                "Filter the tree to paragraphs marked Third under the cursor.".into(),
            Action::StatusFilterSecond =>
                "Filter the tree to paragraphs marked Second under the cursor.".into(),
            Action::StatusFilterFirst =>
                "Filter the tree to paragraphs marked First under the cursor.".into(),
            Action::StatusFilterNapkin =>
                "Filter the tree to paragraphs marked Napkin under the cursor.".into(),
            Action::StatusFilterNone =>
                "Filter the tree to paragraphs with no status under the cursor.".into(),

            // ── AI ────────────────────────────────────────────
            Action::ClearChat =>
                "Clear the chat history and any in-flight inference for a fresh AI session.".into(),

            // ── Bund prefix ───────────────────────────────────
            Action::BundRunBuffer =>
                "Evaluate the currently-open .bund script against Adam (Bund VM).".into(),
            Action::BundNewScript =>
                "Add a new Bund script under the Scripts system book.".into(),
            Action::BundOpenEvalModal =>
                "Open the one-shot Bund eval modal — type an expression, see its result in the status bar.".into(),
            Action::BundOpenScriptPicker =>
                "Open the script picker — list scripts in the current branch (A toggles to Scripts book), Enter runs.".into(),

            // ── Top-level F-keys (1.2.4+) ──────────────────────
            Action::HelpQuery =>
                "Open the Help-book RAG query modal — natural-language question against the Help book.".into(),
            Action::RenameNode =>
                "Rename the tree-cursor's node (paragraphs also rename their .typ on disk).".into(),
            Action::FilePickerTreeImport =>
                "Open the file picker in import mode — a file becomes a new paragraph, a directory recursively imports as branches.".into(),
            Action::FilePickerEditorLoad =>
                "Open the file picker in load mode — replaces the open paragraph's buffer with the picked file's content.".into(),
            Action::ToggleSplit =>
                "Toggle split-edit mode — captures the current buffer as a read-only lower pane.".into(),
            Action::AcceptSplitSnapshot =>
                "Replace the live buffer with the split's captured snapshot, exit split, mark dirty.".into(),
            Action::OpenSnapshotPicker =>
                "Open the snapshot picker for the current paragraph (↑↓ navigate · Enter loads · V diff · D delete).".into(),
            Action::GrammarCheck =>
                "Grammar-check the open paragraph — runs the configured F7 prompt against the AI, applies via `g` in the AI pane.".into(),
            Action::CycleAiMode =>
                "Cycle AI scope: None → Selection → Paragraph → Subchapter → Chapter → Book → None.".into(),
            Action::ToggleInferenceMode =>
                "Toggle inference mode: Local-only RAG ↔ Full general knowledge (Help is pinned to Local regardless).".into(),

            // ── View prefix ────────────────────────────────────
            Action::ViewExportMarkdownBuffer =>
                "Export the open paragraph's live buffer (including unsaved edits) as markdown to the launch cwd.".into(),
            Action::ViewExportMarkdownSubchapter =>
                "Export the containing subchapter's subtree as markdown to the launch cwd.".into(),
            Action::ViewExportMarkdownSubtree =>
                "Export the tree-cursor's node and all descendants as markdown to the launch cwd.".into(),
            Action::ViewToggleSimilarMode =>
                "Toggle similar-paragraph mode — vector-similarity picker; selecting a hit opens a second editor side-by-side. Re-press to save both and exit.".into(),
            Action::ViewOpenProgress =>
                "Open the writing-progress modal (today / streak / per-book pace / 30-day sparkline / status-ladder counts).".into(),
            Action::ViewOpenParagraphTarget =>
                "Set or clear the open paragraph's word-count goal. Saves that cross the target auto-promote status one ladder step.".into(),
            Action::ViewAddLink =>
                "Add a linked paragraph — tree pane switches to `select paragraph to link` mode; Enter links, Esc cancels. Stored as metadata, never embedded in typst source.".into(),
            Action::ViewAddIncomingLink =>
                "Add an incoming link — tree pane picker; Enter on a paragraph adds the OPEN paragraph to THAT paragraph's outgoing links (reverse of Ctrl+V A).".into(),
            Action::ViewListLinks =>
                "Open the linked-paragraphs modal — list outgoing wiki-links for the open paragraph; press D on a row to remove.".into(),
            Action::ViewListBacklinks =>
                "Open the backlinks modal — list paragraphs that link to the open paragraph (reverse of Ctrl+V L). Enter opens; D removes the source's outgoing link to current.".into(),
            Action::ViewToggleBookmark =>
                "Toggle bookmark on the open paragraph. Bookmarks are surfaced by the Ctrl+V M picker; survive restart via metadata.".into(),
            Action::ViewListBookmarks =>
                "Open the bookmark picker — every bookmarked paragraph in the project. Enter opens; D removes the bookmark.".into(),
            Action::ViewFuzzyParagraphPicker =>
                "Fuzzy paragraph picker — type any substring of the title or slug path, Enter opens the highlighted hit.".into(),

            Action::None => String::new(),
            Action::BundLambda(name) =>
                format!("User-bound Bund lambda `{name}` (registered via ink.key.bind_lambda)."),
        }
    }
}

#[derive(Debug, Clone)]
pub struct BindingEntry {
    pub chord: KeyChord,
    pub action: Action,
    pub scope: Scope,
}

/// Live binding table. Held in the process-wide `ACTIVE` slot
/// and consulted on every meta- / bund-sub-chord dispatch.
/// `ink.key.*` stdlib words mutate the same struct under the
/// shared RwLock.
#[derive(Debug, Clone)]
pub struct KeyBindings {
    /// Prefix chord that gates the meta sub-chord table (default
    /// `Ctrl+B`). Stored here so `ink.key.*` stdlib words can
    /// parse `"Ctrl+b m"` shorthand without taking a separate
    /// dependency on the App.
    pub meta_prefix: KeyChord,
    /// Same for the Bund sub-chord table (default `Ctrl+Z`).
    /// `None` when the user disabled it via empty config.
    pub bund_prefix: Option<KeyChord>,
    /// View-prefix chord (1.2.4+, default `Ctrl+V`). Gates the
    /// markdown-export / similar-mode / progress / paragraph-target
    /// sub-chords. `None` disables the layer entirely.
    pub view_prefix: Option<KeyChord>,
    pub meta_sub: Vec<BindingEntry>,
    pub bund_sub: Vec<BindingEntry>,
    pub view_sub: Vec<BindingEntry>,
    /// Top-level (no-prefix) chords. 1.2.4+ home for the F-keys
    /// that used to be hardcoded in `handle_key`. Single-token
    /// chord strings in HJSON `keys.bindings` (e.g. `"F1"`,
    /// `"Shift+F4"`) route here.
    pub top_level: Vec<BindingEntry>,
}

impl Default for KeyBindings {
    fn default() -> Self {
        Self::defaults()
    }
}

impl KeyBindings {
    /// The canonical chord layout — must reproduce the behaviour
    /// of the hardcoded match arms `app.rs` had before Stage 1.
    /// Narrow-scoped entries come BEFORE broad ones (`Any`) so
    /// pane-specific bindings beat global ones when both match.
    pub fn defaults() -> Self {
        Self {
            meta_prefix: KeyChord::parse("Ctrl+b").expect("default meta_prefix"),
            bund_prefix: Some(KeyChord::parse("Ctrl+z").expect("default bund_prefix")),
            view_prefix: Some(KeyChord::parse("Ctrl+v").expect("default view_prefix")),
            meta_sub: vec![
                // ── Tree pane ─────────────────────────────────
                entry("c", Action::AddChapter, Scope::Tree),
                entry("s", Action::AddSubchapter, Scope::Tree),
                entry("p", Action::AddParagraph, Scope::Tree),
                entry("d", Action::DeleteNode, Scope::Tree),
                entry("m", Action::MorphType, Scope::Tree),
                entry("Up", Action::ReorderUp, Scope::Tree),
                entry("Down", Action::ReorderDown, Scope::Tree),
                // Reorder aliases used in the old keymap.
                entry("u", Action::ReorderUp, Scope::Tree),
                entry("j", Action::ReorderDown, Scope::Tree),

                // ── Editor pane ───────────────────────────────
                entry("s", Action::Save, Scope::Editor),
                entry("n", Action::CreateSnapshot, Scope::Editor),
                entry("r", Action::CycleStatus, Scope::Editor),
                entry("f", Action::OpenFunctionPicker, Scope::Editor),
                entry("t", Action::RenameToFirstSentence, Scope::Editor),
                entry("m", Action::MorphType, Scope::Editor),
                entry("p", Action::LookupPlacesOrImage, Scope::Editor),
                entry("c", Action::LookupCharacters, Scope::Editor),
                entry("g", Action::LookupNotes, Scope::Editor),
                entry("y", Action::LookupArtefacts, Scope::Editor),

                // ── AI pane ───────────────────────────────────
                entry("c", Action::ClearChat, Scope::Ai),

                // ── Global (Any) ──────────────────────────────
                // H is pane-aware-content but pane-agnostic-binding —
                // every pane gets a "quickref" overlay tailored to
                // the focused area.
                entry("h", Action::OpenQuickref, Scope::Any),
                entry("v", Action::OpenCredits, Scope::Any),
                entry("i", Action::OpenBookInfo, Scope::Any),
                entry("l", Action::OpenLlmPicker, Scope::Any),
                entry("e", Action::ToggleSound, Scope::Any),
                entry("a", Action::ScheduleAssemble, Scope::Any),
                entry("b", Action::ScheduleBuild, Scope::Any),
                entry("o", Action::ScheduleTake, Scope::Any),
                entry("w", Action::ToggleTypewriter, Scope::Any),
                entry("k", Action::ToggleAiFullscreen, Scope::Any),
                entry("1", Action::StatusFilterReady, Scope::Any),
                entry("2", Action::StatusFilterFinal, Scope::Any),
                entry("3", Action::StatusFilterThird, Scope::Any),
                entry("4", Action::StatusFilterSecond, Scope::Any),
                entry("5", Action::StatusFilterFirst, Scope::Any),
                entry("6", Action::StatusFilterNapkin, Scope::Any),
                entry("7", Action::StatusFilterNone, Scope::Any),
            ],
            bund_sub: vec![
                entry("r", Action::BundRunBuffer, Scope::Any),
                entry("n", Action::BundNewScript, Scope::Any),
                entry("e", Action::BundOpenEvalModal, Scope::Any),
                entry("?", Action::BundOpenScriptPicker, Scope::Any),
            ],
            view_sub: vec![
                // Editor / AI-prompt: 1 = buffer markdown, 2 =
                // containing-subchapter subtree markdown.
                entry("1", Action::ViewExportMarkdownBuffer, Scope::Editor),
                entry("2", Action::ViewExportMarkdownSubchapter, Scope::Editor),
                entry("1", Action::ViewExportMarkdownBuffer, Scope::Ai),
                entry("2", Action::ViewExportMarkdownSubchapter, Scope::Ai),
                // Tree: 1 = subtree markdown.
                entry("1", Action::ViewExportMarkdownSubtree, Scope::Tree),
                // Global suffixes.
                entry("s", Action::ViewToggleSimilarMode, Scope::Any),
                entry("g", Action::ViewOpenProgress, Scope::Any),
                entry("t", Action::ViewOpenParagraphTarget, Scope::Any),
                entry("a", Action::ViewAddLink, Scope::Any),
                entry("i", Action::ViewAddIncomingLink, Scope::Any),
                entry("l", Action::ViewListLinks, Scope::Any),
                entry("k", Action::ViewListBacklinks, Scope::Any),
                entry("b", Action::ViewToggleBookmark, Scope::Any),
                entry("m", Action::ViewListBookmarks, Scope::Any),
                entry("p", Action::ViewFuzzyParagraphPicker, Scope::Any),
            ],
            top_level: vec![
                // F1 anywhere: Help-book RAG modal.
                entry("F1", Action::HelpQuery, Scope::Any),
                // F2: rename — pane-aware-content but bound in Tree
                // (where the cursor lives) + Editor (where rename
                // can still be triggered for the open paragraph).
                entry("F2", Action::RenameNode, Scope::Tree),
                entry("F2", Action::RenameNode, Scope::Editor),
                // F3: pane-specific file picker. Tree → import,
                // Editor → load.
                entry("F3", Action::FilePickerTreeImport, Scope::Tree),
                entry("F3", Action::FilePickerEditorLoad, Scope::Editor),
                // F4 / Ctrl+F4 — split-edit and "accept split".
                entry("F4", Action::ToggleSplit, Scope::Editor),
                entry("Ctrl+F4", Action::AcceptSplitSnapshot, Scope::Editor),
                // F5 — snapshot the open paragraph (same as
                // Ctrl+B N inside meta_sub).
                entry("F5", Action::CreateSnapshot, Scope::Editor),
                // F6 — snapshot picker.
                entry("F6", Action::OpenSnapshotPicker, Scope::Editor),
                // F7 — grammar check.
                entry("F7", Action::GrammarCheck, Scope::Editor),
                // F9 / F10 — global AI mode + inference toggle.
                entry("F9", Action::CycleAiMode, Scope::Any),
                entry("F10", Action::ToggleInferenceMode, Scope::Any),
            ],
        }
    }

    /// Resolve a single (top-level) keystroke against the
    /// `top_level` table — the home for F-keys after 1.2.4's
    /// migration.
    pub fn resolve_top_level(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
        resolve_in(&self.top_level, ev, focus)
    }

    /// Resolve a meta sub-chord against the current focus. Returns
    /// `None` when no binding matches, `Some(Action::None)` when a
    /// binding was explicitly disabled by the user overlay.
    pub fn resolve_meta_sub(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
        resolve_in(&self.meta_sub, ev, focus)
    }

    /// Same as `resolve_meta_sub` for chords after the bund_prefix.
    pub fn resolve_bund_sub(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
        resolve_in(&self.bund_sub, ev, focus)
    }

    /// Same as `resolve_meta_sub` for chords after the view_prefix
    /// (1.2.4+, default Ctrl+V).
    pub fn resolve_view_sub(&self, ev: &KeyEvent, focus: Focus) -> Option<Action> {
        resolve_in(&self.view_sub, ev, focus)
    }

    /// Apply a list of `(layer, entry)` overlay pairs on top of
    /// the existing table. Each new entry replaces any existing
    /// `(chord, scope)` match in the same layer and gets
    /// prepended so it wins resolution against the defaults.
    pub fn apply_overlay(&mut self, overlay: Vec<(Layer, BindingEntry)>) {
        for (layer, new) in overlay {
            let table = self.layer_table_mut(layer);
            table.retain(|b| !(b.chord == new.chord && b.scope == new.scope));
            table.insert(0, new);
        }
    }

    fn layer_table_mut(&mut self, layer: Layer) -> &mut Vec<BindingEntry> {
        match layer {
            Layer::MetaSub => &mut self.meta_sub,
            Layer::BundSub => &mut self.bund_sub,
            Layer::ViewSub => &mut self.view_sub,
            Layer::TopLevel => &mut self.top_level,
        }
    }

    /// Build a `KeyBindings` from `defaults()` overlaid with the
    /// parsed HJSON `keys.bindings` entries. Caller supplies the
    /// already-parsed meta + bund + view prefixes so the overlay
    /// parser can route `"Ctrl+b m"` → meta_sub table by prefix
    /// match.
    pub fn from_overrides(
        meta_prefix: KeyChord,
        bund_prefix: Option<KeyChord>,
        view_prefix: Option<KeyChord>,
        overrides: &[(String, String, Option<String>)],
    ) -> Result<Self, String> {
        let mut bindings = Self::defaults();
        bindings.meta_prefix = meta_prefix;
        bindings.bund_prefix = bund_prefix;
        bindings.view_prefix = view_prefix;
        let mut overlay: Vec<(Layer, BindingEntry)> = Vec::new();
        for (chord_str, action_str, scope_str) in overrides {
            let entry = parse_overlay(
                meta_prefix,
                bund_prefix.unwrap_or_else(disabled_chord_placeholder),
                view_prefix.unwrap_or_else(disabled_chord_placeholder),
                chord_str,
                action_str,
                scope_str,
            )?;
            overlay.push(entry);
        }
        bindings.apply_overlay(overlay);
        Ok(bindings)
    }

    /// Add or replace a single binding. Used by `ink.key.bind` /
    /// `ink.key.bind_lambda`. Same `(chord, scope)` uniqueness
    /// semantics as the HJSON overlay: a new entry shadows any
    /// existing one with matching key.
    pub fn add(&mut self, layer: Layer, entry: BindingEntry) {
        let table = self.layer_table_mut(layer);
        table.retain(|b| !(b.chord == entry.chord && b.scope == entry.scope));
        table.insert(0, entry);
    }

    /// Remove every entry whose `(chord, scope)` matches. Returns
    /// the number of entries removed (zero when nothing matched).
    pub fn remove(&mut self, layer: Layer, chord: &KeyChord, scope: Scope) -> usize {
        let table = self.layer_table_mut(layer);
        let before = table.len();
        table.retain(|b| !(b.chord == *chord && b.scope == scope));
        before - table.len()
    }

    /// Parse a `"<prefix> <suffix>"` shorthand and return
    /// `(layer, suffix_chord)`. Used by `ink.key.*` stdlib words
    /// AND the HJSON overlay parser via `parse_overlay`.
    pub fn parse_sub_chord(&self, s: &str) -> Result<(Layer, KeyChord), String> {
        let parts: Vec<&str> = s.split_whitespace().collect();
        let (prefix_str, suffix_str) = match parts.as_slice() {
            [single] => {
                return Err(format!(
                    "chord `{single}`: top-level (no-prefix) binding not yet supported \
                     — use `<meta_prefix> <key>` or `<bund_prefix> <key>`"
                ));
            }
            [prefix, suffix] => (*prefix, *suffix),
            _ => return Err(format!("chord `{s}`: expected `<prefix> <suffix>`")),
        };
        let prefix = KeyChord::parse(prefix_str)
            .map_err(|e| format!("chord `{s}` prefix: {e}"))?;
        let suffix = KeyChord::parse(suffix_str)
            .map_err(|e| format!("chord `{s}` suffix: {e}"))?;
        let layer = if prefix == self.meta_prefix {
            Layer::MetaSub
        } else if Some(prefix) == self.bund_prefix {
            Layer::BundSub
        } else if Some(prefix) == self.view_prefix {
            Layer::ViewSub
        } else {
            return Err(format!(
                "chord `{s}`: prefix `{prefix_str}` is not meta_prefix / bund_prefix / view_prefix"
            ));
        };
        if suffix == self.meta_prefix
            || Some(suffix) == self.bund_prefix
            || Some(suffix) == self.view_prefix
        {
            return Err(format!(
                "chord `{s}`: suffix collides with a prefix chord"
            ));
        }
        Ok((layer, suffix))
    }
}

impl KeyBindings {
    /// Build the status-bar hint string for the meta-prefix
    /// chord on the given focus. Iterates `meta_sub` in
    /// registration order, skipping disabled entries and
    /// deduplicating actions (so `Up` + `u` for ReorderUp
    /// surface as one entry).
    pub fn meta_hint(&self, focus: Focus) -> String {
        self.hint_for(&self.meta_sub, "META", focus)
    }

    /// Same for the bund-prefix chord.
    pub fn bund_hint(&self, focus: Focus) -> String {
        self.hint_for(&self.bund_sub, "BUND", focus)
    }

    /// Same for the view-prefix chord (1.2.4+, default Ctrl+V).
    pub fn view_hint(&self, focus: Focus) -> String {
        self.hint_for(&self.view_sub, "VIEW", focus)
    }

    fn hint_for(&self, table: &[BindingEntry], prefix: &str, focus: Focus) -> String {
        use std::collections::HashSet;
        let mut parts: Vec<String> = vec![prefix.to_string()];
        let mut seen: HashSet<String> = HashSet::new();
        for entry in table {
            if !entry.scope.matches(focus) {
                continue;
            }
            if matches!(entry.action, Action::None) {
                continue;
            }
            let label = entry.action.label();
            if label.is_empty() {
                continue;
            }
            // De-dupe by action label: a user who bound the same
            // action to two chords (e.g. ReorderUp on Up and u)
            // only sees the action once in the hint.
            if !seen.insert(label.clone()) {
                continue;
            }
            parts.push(format!("{} {}", entry.chord.to_display_string(), label));
        }
        parts.push("Esc cancel".into());
        parts.join(" · ")
    }
}

/// Placeholder chord matched by nothing real — used to satisfy
/// `parse_overlay`'s `bund_prefix` arg when the user disabled the
/// bund prefix via empty config.
fn disabled_chord_placeholder() -> KeyChord {
    KeyChord {
        code: crossterm::event::KeyCode::Null,
        modifiers: crossterm::event::KeyModifiers::NONE,
    }
}

/// Which sub-chord table the overlay entry targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Layer {
    MetaSub,
    BundSub,
    /// 1.2.4+: Ctrl+V family — markdown export / similar mode /
    /// progress / paragraph target.
    ViewSub,
    /// 1.2.4+: top-level (no-prefix) chords — home for the
    /// F-keys after the migration. HJSON `keys.bindings` chord
    /// strings that contain a single token (no prefix) land here.
    TopLevel,
}

fn parse_overlay(
    meta_prefix: KeyChord,
    bund_prefix: KeyChord,
    view_prefix: KeyChord,
    chord: &str,
    action: &str,
    scope: &Option<String>,
) -> Result<(Layer, BindingEntry), String> {
    // Shorthand split: "Ctrl+b y" → ["Ctrl+b", "y"]. Trim runs of
    // whitespace so "Ctrl+b   y" also parses cleanly.
    let parts: Vec<&str> = chord.split_whitespace().collect();
    // 1.2.4+: single-token chord strings (e.g. `"F1"`, `"Shift+F4"`)
    // bind into the `top_level` table — no prefix required.
    if parts.len() == 1 {
        let single = KeyChord::parse(parts[0])
            .map_err(|e| format!("binding chord `{chord}`: {e}"))?;
        let action_enum = parse_action(action)?;
        let scope_enum = parse_scope(scope.as_deref())?;
        return Ok((
            Layer::TopLevel,
            BindingEntry {
                chord: single,
                action: action_enum,
                scope: scope_enum,
            },
        ));
    }
    let (prefix_str, suffix_str) = match parts.as_slice() {
        [prefix, suffix] => (*prefix, *suffix),
        _ => {
            return Err(format!(
                "binding chord `{chord}`: expected `<prefix> <suffix>` (two tokens) or single top-level chord"
            ));
        }
    };
    let prefix = KeyChord::parse(prefix_str)
        .map_err(|e| format!("binding chord `{chord}` prefix: {e}"))?;
    let suffix = KeyChord::parse(suffix_str)
        .map_err(|e| format!("binding chord `{chord}` suffix: {e}"))?;
    let layer = if prefix == meta_prefix {
        Layer::MetaSub
    } else if prefix == bund_prefix {
        Layer::BundSub
    } else if prefix == view_prefix {
        Layer::ViewSub
    } else {
        return Err(format!(
            "binding chord `{chord}`: prefix `{prefix_str}` is not meta_prefix / bund_prefix / view_prefix"
        ));
    };
    // Reject rebinding the prefixes themselves and the hard-quit
    // chord — those are configured via top-level `keys.*` slots,
    // not the bindings overlay.
    if suffix == meta_prefix || suffix == bund_prefix || suffix == view_prefix {
        return Err(format!(
            "binding chord `{chord}`: suffix collides with a prefix chord"
        ));
    }
    let scope = parse_scope(scope.as_deref())?;
    let action = parse_action(action)?;
    Ok((
        layer,
        BindingEntry {
            chord: suffix,
            action,
            scope,
        },
    ))
}

fn parse_scope(s: Option<&str>) -> Result<Scope, String> {
    match s {
        None | Some("any") => Ok(Scope::Any),
        Some("editor") => Ok(Scope::Editor),
        Some("tree") => Ok(Scope::Tree),
        Some("ai") => Ok(Scope::Ai),
        Some(other) => Err(format!(
            "scope `{other}`: expected one of any / editor / tree / ai"
        )),
    }
}

fn parse_action(s: &str) -> Result<Action, String> {
    // Round-trip via serde: variant rename attributes give us the
    // canonical dotted form. `serde_json::from_str` reads a JSON
    // string literal and matches it against the rename map.
    serde_json::from_str::<Action>(&format!("\"{s}\""))
        .map_err(|e| format!("action `{s}`: {e}"))
}

fn resolve_in(table: &[BindingEntry], ev: &KeyEvent, focus: Focus) -> Option<Action> {
    table
        .iter()
        .find(|b| b.scope.matches(focus) && b.chord.matches(ev))
        .map(|b| b.action.clone())
}

fn entry(chord: &str, action: Action, scope: Scope) -> BindingEntry {
    BindingEntry {
        chord: KeyChord::parse(chord).expect("invalid default chord — programmer error"),
        action,
        scope,
    }
}

// ── Shared active KeyBindings ────────────────────────────────────────
//
// App reads from this on every chord dispatch; `ink.key.*` Bund
// stdlib writes to it. Lazily initialised with `KeyBindings::defaults()`
// on first access — so CLI subcommands (`inkhaven bund`) that don't
// build an `App` still see a functioning binding table.
//
// `install` replaces the contents under the write lock, so TUI
// startup (which parses the HJSON overlay) wins over the lazy
// defaults whenever it runs.

static ACTIVE: LazyLock<RwLock<KeyBindings>> =
    LazyLock::new(|| RwLock::new(KeyBindings::defaults()));

/// Replace the active KeyBindings. Called by `App::new` after
/// applying the HJSON overlay. Cheap because the new value is
/// move-swapped under the write lock.
pub fn install(bindings: KeyBindings) {
    *ACTIVE.write() = bindings;
}

/// Read access. Lazy default-init means this never blocks on
/// missing installation — CLI smoke usage gets defaults.
pub fn read() -> RwLockReadGuard<'static, KeyBindings> {
    ACTIVE.read()
}

/// Write access for `ink.key.*` Bund stdlib words.
pub fn write() -> RwLockWriteGuard<'static, KeyBindings> {
    ACTIVE.write()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    fn ev(c: char) -> KeyEvent {
        KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
    }

    #[test]
    fn defaults_resolve_known_chords() {
        let k = KeyBindings::defaults();
        // Tree pane: C → add chapter
        assert_eq!(
            k.resolve_meta_sub(&ev('c'), Focus::Tree),
            Some(Action::AddChapter)
        );
        // Editor pane: C → character lookup (different action,
        // same key — scope discriminates).
        assert_eq!(
            k.resolve_meta_sub(&ev('c'), Focus::Editor),
            Some(Action::LookupCharacters)
        );
        // AI pane: C → clear chat
        assert_eq!(
            k.resolve_meta_sub(&ev('c'), Focus::Ai),
            Some(Action::ClearChat)
        );
        // V is global → open credits regardless of pane
        assert_eq!(
            k.resolve_meta_sub(&ev('v'), Focus::Tree),
            Some(Action::OpenCredits)
        );
        assert_eq!(
            k.resolve_meta_sub(&ev('v'), Focus::Editor),
            Some(Action::OpenCredits)
        );
    }

    #[test]
    fn pane_scope_beats_any() {
        let k = KeyBindings::defaults();
        // In editor, P → places-or-image (Editor scope), NOT add
        // paragraph (Tree scope). Both are listed; narrow scope
        // wins.
        assert_eq!(
            k.resolve_meta_sub(&ev('p'), Focus::Editor),
            Some(Action::LookupPlacesOrImage)
        );
        // In tree, P → add paragraph.
        assert_eq!(
            k.resolve_meta_sub(&ev('p'), Focus::Tree),
            Some(Action::AddParagraph)
        );
    }

    #[test]
    fn status_filter_digits() {
        let k = KeyBindings::defaults();
        for (c, expected) in [
            ('1', Action::StatusFilterReady),
            ('2', Action::StatusFilterFinal),
            ('3', Action::StatusFilterThird),
            ('4', Action::StatusFilterSecond),
            ('5', Action::StatusFilterFirst),
            ('6', Action::StatusFilterNapkin),
            ('7', Action::StatusFilterNone),
        ] {
            assert_eq!(
                k.resolve_meta_sub(&ev(c), Focus::Editor),
                Some(expected),
                "digit {c}"
            );
        }
    }

    #[test]
    fn bund_sub_known_chords() {
        let k = KeyBindings::defaults();
        assert_eq!(
            k.resolve_bund_sub(&ev('r'), Focus::Tree),
            Some(Action::BundRunBuffer)
        );
        assert_eq!(
            k.resolve_bund_sub(&ev('n'), Focus::Editor),
            Some(Action::BundNewScript)
        );
        assert_eq!(
            k.resolve_bund_sub(&ev('e'), Focus::Ai),
            Some(Action::BundOpenEvalModal)
        );
    }

    #[test]
    fn unknown_chord_is_none() {
        let k = KeyBindings::defaults();
        assert_eq!(k.resolve_meta_sub(&ev('z'), Focus::Editor), None);
    }
}
