//! AI / inference orchestration on `App` — kicks off chat
//! streams (regular, lexicon-RAG, grammar-check, help-RAG,
//! diagnostic-explain, timeline-critique), turn-selection
//! navigation, chat history search, and the apply / diff-review
//! flow that lands an inference back in the editor buffer.
//! The streaming protocol itself lives in `crate::ai::stream`;
//! this is the App-side state machinery around it. Extracted
//! from `tui::app` in the 1.2.7 refactor, Phase 3 batch 7.

use crossterm::event::{KeyCode, KeyEvent};
use ratatui::style::{Modifier, Style};
use tui_textarea::TextArea;
use uuid::Uuid;

use super::{
    critique_changes_default_prompt, critique_edit_default_prompt, current_word_or_selection,
    explain_diagnostic_default_prompt, extract_corrected_text, extract_translation_text,
    facts_scope_system_prompt, grammar_check_default_prompt, select_apply_text,
    sentence_rhythm_rewrite_default_prompt, FULL_SYSTEM_PROMPT, GRAMMAR_CHECK_SYSTEM_PROMPT,
    HELP_SYSTEM_PROMPT, LOCAL_SYSTEM_PROMPT,
};

use crate::ai::stream::{spawn_chat_stream, ChatTurn};
use crate::store::node::NodeKind;

use super::super::focus::Focus;
use super::super::inference::{
    AiMode, Inference, InferenceAction, InferenceMode, InferenceStatus,
};
use super::super::lexicon_build::LexiconKind;
use super::super::modal::{Modal, PromptBody, PromptCandidate, PromptSource};
use super::super::search_results::SearchHit;

/// 1.2.12+ — system prompt for the Ctrl+R review
/// inside the Ctrl+B 0 HJSON editor.  Same shape as
/// the prompts-editor TUI's ANALYSIS_SYSTEM_PROMPT
/// (the LLM is a reviewer, not an executor) but
/// tuned for project-config critique: the reviewer
/// knows the field shapes via inkhaven's documented
/// schema and can point at concrete improvements.
const HJSON_REVIEW_SYSTEM_PROMPT: &str = "\
You are reviewing an inkhaven project's `inkhaven.hjson` \
configuration.  inkhaven is a TUI literary editor for Typst \
books; the HJSON document governs every runtime knob — language, \
embeddings model, LLM provider settings, theme colours, style-\
warning detectors, autosave, backup behaviour, hooks, and more.

Your job: critique this config as a piece of work.  Identify \
fields that look misnamed; combinations that contradict each \
other; defaults that are dangerous given the rest of the config; \
fields that should probably be set but aren't (e.g. an `llm.\
default` pointing at a provider that isn't configured below).

Be specific.  Quote the dotted field path when you call something \
out — `editor.style_warnings.show_dont_tell.enabled`, not \
'the show-don't-tell field'.  When the user asks a direct \
question about the config, answer it first, then justify.  \
Do NOT rewrite the whole file — point at concrete fixes the user \
can apply line-by-line.";

impl super::App {

    pub(super) fn inference_done_with_text(&self) -> bool {
        matches!(
            self.inference.as_ref().map(|i| (&i.status, i.response.is_empty())),
            Some((InferenceStatus::Done, false))
        )
    }

    pub(super) fn apply_inference(&mut self, action: InferenceAction) {
        let Some(inf) = self.inference.as_ref() else {
            return;
        };
        let raw = inf.response.clone();
        if matches!(action, InferenceAction::CopyOnly) {
            // Copy keeps the original markdown — the user might paste it
            // somewhere that expects markdown, not Typst.
            if let Some(cb) = self.clipboard.as_mut() {
                let _ = cb.set_text(raw.clone());
            }
            self.status = "copied AI result to clipboard".into();
            return;
        }
        // 1.2.6+ — diff review gate. Only intercepts the
        // buffer-replacing actions (Replace / ReplaceCorrected);
        // additive actions (Insert / Top / Bottom) fall
        // through to the original direct path. The Modal::AiDiffReview
        // dispatcher calls `apply_inference_direct` after
        // the user accepts.
        if self.cfg.ai.diff_review_on_apply
            && matches!(action, InferenceAction::Replace | InferenceAction::ReplaceCorrected)
        {
            self.open_ai_diff_review(action, &raw);
            return;
        }
        self.apply_inference_direct(action, raw);
    }

    pub(super) fn apply_inference_direct(&mut self, action: InferenceAction, raw: String) {
        let Some(doc) = self.opened.as_mut() else {
            self.status = "no paragraph open — apply needs a focused paragraph".into();
            return;
        };

        // ReplaceCorrected has its own pipeline: pull just the corrected
        // paragraph (no commentary), skip the markdown→typst conversion
        // because the grammar prompt instructs the model to keep Typst
        // markup verbatim, and overwrite the buffer wholesale. Before
        // overwriting, snapshot the pre-correction buffer into
        // `correction_baseline` so the renderer can highlight what
        // changed in `theme.grammar_change_fg`. The highlight survives
        // saves (autosave or manual) and is dismissed only by switching
        // paragraphs or by Ctrl+B C — saving an accepted correction
        // shouldn't yank the visual diff out from under the user.
        if matches!(action, InferenceAction::ReplaceCorrected) {
            let Some(corrected) = extract_corrected_text(&raw) else {
                self.status =
                    "couldn't find corrected text in the response \
                     (expected `<<<CORRECTED>>>` block or fenced code)"
                        .into();
                return;
            };
            let baseline = doc.textarea.lines().to_vec();
            // Build a fresh TextArea from the corrected text rather than
            // shuffling cursor + selection inside the existing one — the
            // cut/select dance was leaving stray characters in the buffer
            // (the "In _The H" duplication seen in user reports).
            let corrected_lines: Vec<String> = if corrected.is_empty() {
                vec![String::new()]
            } else {
                corrected.split('\n').map(String::from).collect()
            };
            let mut new_ta = TextArea::new(corrected_lines);
            new_ta.set_cursor_line_style(
                Style::default().add_modifier(Modifier::REVERSED),
            );
            new_ta.set_line_number_style(
                Style::default().fg(self.theme.line_number_fg),
            );
            doc.textarea = new_ta;
            doc.correction_baseline = Some(baseline);
            doc.dirty = true;
            // Bump activity so idle autosave doesn't fire on the very
            // next tick (which would otherwise lose the freshness of
            // the diff before the user has had a chance to read it).
            doc.last_activity = std::time::Instant::now();
            self.status = format!(
                "applied AI result ({}) — changes highlighted; Ctrl+B C dismisses",
                action.label()
            );
            self.change_focus(Focus::Editor);
            return;
        }

        // 1.2.6+ — Replace runs through `select_apply_text`
        // so a grammar-style response with markers / fence /
        // "Corrected" heading lands ONLY the discrete block,
        // even when the user pressed `r` (which used to paste
        // the whole reply, commentary included). Insert / Top /
        // Bottom still take the full markdown→typst converted
        // body because additive applies are usually meant to
        // surface commentary too.
        let replace_payload: Option<String> =
            if matches!(action, InferenceAction::Replace) {
                match select_apply_text(&raw, false) {
                    Ok((s, _)) => Some(s),
                    Err(msg) => {
                        self.status = msg.into();
                        return;
                    }
                }
            } else {
                None
            };
        let text = super::super::markdown::markdown_to_typst(&raw);
        match action {
            InferenceAction::Replace => {
                if doc.textarea.selection_range().is_some() {
                    doc.textarea.cut();
                } else {
                    // No selection: replace the whole document.
                    use tui_textarea::CursorMove;
                    doc.textarea.move_cursor(CursorMove::Top);
                    doc.textarea.start_selection();
                    doc.textarea.move_cursor(CursorMove::Bottom);
                    doc.textarea.cut();
                }
                doc.textarea
                    .set_yank_text(replace_payload.unwrap_or_else(|| text.clone()));
                doc.textarea.paste();
            }
            InferenceAction::Insert => {
                // 1.2.13+ Phase C.2 — translation-aware
                // Insert.  When `pending_translation` is
                // set the response was composed by the
                // Ctrl+B Q envelope which instructs the
                // LLM to wrap the translation block in
                // `<<<TRANSLATION>>>` / `<<<END>>>`
                // markers.  Lift ONLY that block so the
                // gloss table + applied-rules list + any
                // commentary stay in the AI pane for the
                // author's reference but don't pollute
                // the manuscript.  Clear the flag after
                // extraction so a SECOND Insert press
                // falls back to the full-body insert
                // (escape hatch when the LLM forgot the
                // markers).
                let payload = if self.pending_translation {
                    self.pending_translation = false;
                    match extract_translation_text(&raw) {
                        Some(t) => t,
                        None => {
                            self.status = "translation markers missing — inserted \
                                           full response; press I again to redo"
                                .into();
                            text.clone()
                        }
                    }
                } else if self.pending_continuation_draft {
                    // 1.2.14+ Phase Q.3a — extract
                    // <<<DRAFT>>> block.  Same
                    // escape-hatch pattern as
                    // translation.
                    self.pending_continuation_draft = false;
                    match super::q3_q4_impl::extract_continuation_text(&raw) {
                        Some(t) => t,
                        None => {
                            self.status =
                                "continuation markers missing — inserted full response"
                                    .into();
                            text.clone()
                        }
                    }
                } else if self.pending_style_transfer {
                    // 1.2.14+ Phase Q.4b — extract
                    // <<<REWRITE>>> block.
                    self.pending_style_transfer = false;
                    match super::q3_q4_impl::extract_rewrite_text(&raw) {
                        Some(t) => t,
                        None => {
                            self.status =
                                "rewrite markers missing — inserted full response"
                                    .into();
                            text.clone()
                        }
                    }
                } else {
                    text
                };
                doc.textarea.set_yank_text(payload);
                doc.textarea.paste();
            }
            InferenceAction::Top => {
                use tui_textarea::CursorMove;
                doc.textarea.move_cursor(CursorMove::Top);
                doc.textarea.move_cursor(CursorMove::Head);
                doc.textarea.set_yank_text(format!("{text}\n\n"));
                doc.textarea.paste();
            }
            InferenceAction::Bottom => {
                use tui_textarea::CursorMove;
                doc.textarea.move_cursor(CursorMove::Bottom);
                doc.textarea.move_cursor(CursorMove::End);
                doc.textarea.set_yank_text(format!("\n\n{text}"));
                doc.textarea.paste();
            }
            // 1.2.15+ Phase S.1 — internal-invariant
            // guard.  `CopyOnly` short-circuits in the
            // caller (`apply_inference`) before
            // reaching this fn; `ReplaceCorrected`
            // routes through `replace_buffer_with_correction`
            // instead.  If a refactor ever lands one
            // of them here, we log + no-op rather
            // than crashing the TUI — the user just
            // sees nothing happen and can re-invoke.
            InferenceAction::CopyOnly | InferenceAction::ReplaceCorrected => {
                debug_assert!(false, "apply_inference_to_buffer reached with non-pasting action");
                tracing::error!(
                    target: "inkhaven::ai",
                    "apply_inference_to_buffer reached with non-pasting action {action:?} — \
                     caller should have short-circuited; ignoring",
                );
                return;
            }
        }
        doc.dirty = true;
        // 1.2.12+ Phase D — text-insertion / paste of AI
        // output can land enough new content to change the
        // dominant-language signal; let the delta-guard
        // decide whether re-detection is warranted.  No-op
        // when the effective mode is `book_defined`.
        self.maybe_redetect_paragraph_language();
        self.status = format!("applied AI result ({})", action.label());
        self.change_focus(Focus::Editor);
    }

    /// Union of system-level prompts (from prompts.hjson) and paragraphs
    /// nested under the "Prompts" system book. System prompts come first so
    /// the user's mental model — "well-known commands at the top, project-
    /// specific scratch prompts below" — is preserved. Filtered by the
    /// substring after `/` in `ai_input`.
    pub(super) fn prompt_picker_matches(&self) -> Vec<PromptCandidate> {
        let q = self.ai_input.as_str();
        let filter = q.strip_prefix('/').unwrap_or("").trim().to_lowercase();

        // 1.2.4+: rank candidates so prefix matches beat
        // mid-word substring matches. Empty filter → keep
        // insertion order (system before book). Match scores:
        //   3 = name starts with filter
        //   2 = description starts with filter (after splitting
        //       on whitespace — so "summarize selection" matches
        //       a /sel prefix on the second word)
        //   1 = name or description contains filter
        //   0 = no match (excluded)
        let score = |name: &str, desc: &str| -> i32 {
            if filter.is_empty() {
                return 1;
            }
            let nl = name.to_lowercase();
            let dl = desc.to_lowercase();
            if nl.starts_with(&filter) {
                return 3;
            }
            if dl.split_whitespace().any(|w| w.starts_with(&filter)) {
                return 2;
            }
            if nl.contains(&filter) || dl.contains(&filter) {
                return 1;
            }
            0
        };

        let mut scored: Vec<(i32, PromptCandidate)> = Vec::new();
        // 1) prompts.hjson (system)
        for p in &self.prompts.prompts {
            let s = score(&p.name, &p.description);
            if s > 0 {
                scored.push((s, PromptCandidate {
                    name: p.name.clone(),
                    description: p.description.clone(),
                    body: PromptBody::Static(p.template.clone()),
                    source: PromptSource::System,
                    // 1.2.12+ Phase C — propagate the
                    // language tag so the picker can
                    // section + chip.
                    language: p.language.clone(),
                }));
            }
        }
        // 2) Paragraphs under the Prompts system book
        if let Some(book_id) = self.system_book_id(crate::store::SYSTEM_TAG_PROMPTS) {
            for id in self.hierarchy.collect_subtree(book_id) {
                if id == book_id {
                    continue;
                }
                let Some(node) = self.hierarchy.get(id) else {
                    continue;
                };
                if node.kind != NodeKind::Paragraph {
                    continue;
                }
                let name = node.slug.clone();
                let title = node.title.clone();
                let s = score(&name, &title);
                if s > 0 {
                    // Pull `lang:<code>` tag if present.
                    let language = node.tags.iter().find_map(|t| {
                        let lc = t.to_lowercase();
                        let rest = lc.strip_prefix("lang:")?;
                        let code = rest.trim();
                        if code.is_empty() {
                            None
                        } else {
                            Some(code.to_string())
                        }
                    });
                    scored.push((s, PromptCandidate {
                        name,
                        description: title,
                        body: PromptBody::BookParagraph(node.id),
                        source: PromptSource::Book,
                        language,
                    }));
                }
            }
        }
        // 1.2.12+ Phase C — three-tier sort:
        //   1. language-priority bucket (active → untagged → other)
        //   2. score (existing prefix > word-prefix > substring)
        //   3. stable insertion order (system before book)
        //
        // The user sees in-language prompts first, untagged
        // (back-compat) below them, other-language matches
        // at the bottom.  Section headers in the renderer
        // make the split visible.
        let active = self.active_prompt_language();
        let bucket = |lang: &Option<String>| -> u8 {
            match lang.as_deref() {
                Some(l) if l.eq_ignore_ascii_case(&active) => 0,
                None => 1,
                Some(_) => 2,
            }
        };
        scored.sort_by(|a, b| {
            bucket(&a.1.language)
                .cmp(&bucket(&b.1.language))
                .then(b.0.cmp(&a.0))
        });
        let out: Vec<PromptCandidate> = scored.into_iter().map(|(_, c)| c).collect();
        out
    }

    pub(super) fn start_inference(&mut self) {
        let raw = self.ai_input.as_str().trim().to_string();
        if raw.is_empty() {
            self.status = "empty prompt".into();
            return;
        }
        // 1.2.4+: stash the raw prompt in the history ring for
        // Up/Down recall. Avoids dupes-against-most-recent so
        // the list stays useful when the user re-sends the same
        // prompt repeatedly.
        if self.ai_prompt_history.last() != Some(&raw) {
            self.ai_prompt_history.push(raw.clone());
            // Cap the history so a long session doesn't grow
            // unbounded. 500 entries is past any reasonable
            // recall horizon.
            if self.ai_prompt_history.len() > 500 {
                let drop_n = self.ai_prompt_history.len() - 500;
                self.ai_prompt_history.drain(..drop_n);
            }
        }
        self.ai_prompt_history_cursor = None;
        // "Help!" prefix (case-sensitive) reroutes through the F1 Help-book
        // RAG flow. The rest of the line becomes the question; the AI pane
        // shows the same grounded answer the F1 modal produces.
        if let Some(rest) = raw.strip_prefix("Help!") {
            let question = rest.trim().to_string();
            self.ai_input.clear();
            if question.is_empty() {
                self.status = "Help: type a question after `Help!`".into();
                return;
            }
            self.start_help_inference(&question);
            return;
        }
        let user_query = if raw.starts_with('/') {
            // 1.2.12+ — `/name [extra args]` form routes through
            // the language-aware resolver so a user with
            // `lang:ru` tagged prompts gets the Russian variant
            // first when working on Russian prose.  Embedded-
            // fallback floor doesn't apply here: arbitrary user
            // names have no embedded counterpart, so a miss is
            // surfaced as a status message instead.
            let after = raw.trim_start_matches('/').trim();
            let want_lang = self.active_prompt_language();
            match self.resolve_prompt_optional(after, &want_lang) {
                Some(found) => self.render_template(&found.template),
                None => {
                    self.status = format!(
                        "no prompt `{after}` — type `/` to see the list"
                    );
                    return;
                }
            }
        } else {
            raw
        };

        // BOOK_RAG-1 — the chat transcript records the author's *question*
        // (clean), not the multi-thousand-token retrieval prefix we send to
        // the model. So the Book conversation reads naturally and the replayed
        // history stays lean; the grounding is re-attached to each new prompt.
        let recorded_user_msg = user_query.clone();

        // Prepend the AI scope context if one is set. Failures (no
        // selection, etc.) abort the submission with a status message; the
        // scope sticks around so the user can fix the cause and re-submit.
        // BOOK_RAG-1 — Book scope retrieves passages from the prompt and
        // grounds the answer; every other scope keeps its existing per-query
        // context prefix.
        let prompt_text = if self.ai_mode == AiMode::Book {
            match self.book_rag_context(&user_query) {
                Ok(prefix) => {
                    // Remember the retrieval's valid citation ids so the
                    // finalised response can flag any the LLM invents.
                    self.pending_book_rag_cited = self
                        .book_rag_last_retrieval
                        .as_ref()
                        .map(|p| crate::book_rag::cited_ids(p));
                    format!("{prefix}\n\n{user_query}")
                }
                Err(reason) => {
                    self.status = reason;
                    return;
                }
            }
        } else {
            match self.build_ai_mode_context() {
                Ok(Some(prefix)) => format!("{prefix}\n\n{user_query}"),
                Ok(None) => user_query,
                Err(reason) => {
                    self.status = reason;
                    return;
                }
            }
        };
        // Lift any pending Place/Character RAG prefix (set by Ctrl+B P / C
        // when the AI prompt was empty). Consumes it — one-shot.
        let prompt_text = match self.pending_rag_prefix.take() {
            Some(rag) => format!("{rag}\n\n{prompt_text}"),
            None => prompt_text,
        };
        let mode_used = self.ai_mode;

        let (model, _env_var) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = e.to_string();
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();

        // Replay the accumulated chat history before this new user message
        // so the model has continuous context across turns.
        let mut history = self.chat_history.clone();

        // 1.2.6+ — per-paragraph AI memory. When this is a
        // Paragraph-scoped prompt AND the feature is on AND
        // there's an open paragraph, prepend the paragraph's
        // stored memory turns to the chat history so the
        // model sees the prior paragraph-specific context.
        // Also stash the target id so `pump_inference` can
        // stamp the new turns onto it after the stream
        // completes.
        let memory_target: Option<Uuid> = if self.cfg.ai.per_paragraph_memory
            && self.cfg.ai.per_paragraph_memory_max_turns > 0
            && mode_used == AiMode::Paragraph
        {
            self.opened.as_ref().map(|d| d.id)
        } else {
            None
        };
        if let Some(target_id) = memory_target {
            if let Some(node) = self.hierarchy.get(target_id) {
                let mut memory_history: Vec<ChatTurn> =
                    Vec::with_capacity(node.ai_memory.len());
                for turn in &node.ai_memory {
                    match turn.role.as_str() {
                        "user" => memory_history
                            .push(ChatTurn::User(turn.text.clone())),
                        "assistant" => memory_history
                            .push(ChatTurn::Assistant(turn.text.clone())),
                        _ => {}
                    }
                }
                // Memory comes BEFORE the visible chat
                // history — these are older turns from prior
                // sessions, so they're the prologue.
                memory_history.append(&mut history);
                history = memory_history;
            }
        }
        self.pending_paragraph_memory_target = memory_target;
        // System prompt depends on the inference mode. Local clamps the
        // model to supplied context only; Full lets it augment with
        // general knowledge while still treating context as ground truth.
        // `ink.ai.set_system_prompt` overrides both via a Bund script.
        // 1.2.21+ — the Facts scope frames the whole session as
        // fact-analysis / fact-checking (the facts themselves are the
        // seeded history prologue), unless a Bund override is in force.
        let system_prompt = self.system_prompt_override.clone().or_else(|| {
            if mode_used == AiMode::Facts {
                let lang = crate::ai::prompts::iso_from_long(&self.cfg.language);
                Some(facts_scope_system_prompt(lang).to_string())
            } else if mode_used == AiMode::Book {
                // BOOK_RAG-1 — the citation-grounded contract, resolved
                // through the standard chain (Prompts book `book-rag-system`
                // → prompts.hjson → the bundled default) so it's customisable.
                let want_lang = self.active_prompt_language();
                Some(
                    self.resolve_prompt("book-rag-system", &want_lang, || {
                        crate::book_rag::system_prompt(&want_lang).to_string()
                    })
                    .template,
                )
            } else {
                match self.inference_mode {
                    InferenceMode::Local => Some(LOCAL_SYSTEM_PROMPT.to_string()),
                    InferenceMode::Full => Some(FULL_SYSTEM_PROMPT.to_string()),
                }
            }
        });
        // BOOK_RAG-1 / INNER_EDITOR-1 — tag scope-specific inferences under
        // their own usage category so they show in `inkhaven cost` separately
        // from plain chat.
        let usage_category = match mode_used {
            AiMode::Book => "book_rag",
            AiMode::EditorConversation => "inner_editor",
            _ => "chat",
        };
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            system_prompt,
            history,
            prompt_text.clone(),
            usage_category,
        );

        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        // Remember the user message so we can pair it with the assistant
        // turn once the stream finishes. Book scope records the clean question
        // (the prefix is sent to the model but not shown in the transcript);
        // every other scope records what it sent, unchanged.
        self.pending_chat_user_msg = Some(if mode_used == AiMode::Book {
            recorded_user_msg
        } else {
            prompt_text
        });
        // Reset chat-history scroll so the user always sees the
        // streaming reply (if they'd PageUp'd to look at earlier turns
        // before sending, the new turn would otherwise land off-screen).
        self.chat_history_scroll = 0;
        // Stay on the AI prompt pane so follow-up questions are one keystroke
        // away. Esc bounces to the AI pane to read/scroll the answer.
        self.change_focus(Focus::AiPrompt);
        let depth = self.chat_history.len() / 2 + 1;
        let scope_note = if mode_used == AiMode::None {
            String::new()
        } else {
            format!(" · scope={}", mode_used.label())
        };
        self.status = format!(
            "streaming from {provider} (chat turn #{depth}{scope_note})…"
        );
        // Auto-reset the scope so the next prompt isn't surprised by stale
        // context. The user re-cycles with F9 to pick a new scope.
        // 1.2.21+ — except Facts: it's a sticky *session* scope, so the
        // fact-analysis framing + seeded facts persist across follow-up
        // questions until the author cycles F9 away.
        if mode_used != AiMode::Facts {
            self.ai_mode = AiMode::None;
        }
        // Clear the prompt so the next inference starts fresh.
        self.ai_input.clear();
    }

    /// Path used by the chat-history persistence hooks. Lives next
    /// to `.inkhaven-backup.json` and `.session.json` inside the
    /// project root.
    pub(super) fn chat_history_path(&self) -> std::path::PathBuf {
        self.layout.root.join(".inkhaven-chat.json")
    }

    /// Editor meta `Ctrl+B P` (Places) / `Ctrl+B C` (Characters). Treats
    /// the editor's selection (or the word under the cursor) as a lookup
    /// term, sweeps matching paragraphs in the named system book, builds
    /// a RAG context block, and either fires the inference immediately
    /// (if the AI prompt already has a query) or stashes the context as
    /// `pending_rag_prefix` and refocuses the AI prompt for the user to
    /// type a query (item 4 in the spec).
    pub(super) fn start_lexicon_inference(&mut self, kind: LexiconKind) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = format!("{} RAG needs an open paragraph", kind.label());
            return;
        };
        let lookup = current_word_or_selection(doc);
        if lookup.trim().is_empty() {
            self.status = format!(
                "{} RAG: select a name or place the cursor on one first",
                kind.label()
            );
            return;
        }

        let Some(book_id) = self.system_book_id(kind.system_tag()) else {
            self.status = format!(
                "{} book is missing — re-open the project to seed it",
                kind.label()
            );
            return;
        };

        // Case-insensitive substring match against paragraph titles. A
        // selection of "Москва" finds both "Москва" and "Москва-Сити",
        // which is usually the user's intent.
        let needle = lookup.to_lowercase();
        let mut chunks: Vec<String> = Vec::new();
        for id in self.hierarchy.collect_subtree(book_id) {
            if id == book_id {
                continue;
            }
            let Some(node) = self.hierarchy.get(id) else {
                continue;
            };
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            if !node.title.to_lowercase().contains(&needle) {
                continue;
            }
            let body = match self.store.get_content(node.id) {
                Ok(Some(b)) => String::from_utf8_lossy(&b).to_string(),
                _ => continue,
            };
            chunks.push(format!(
                "── {}: {} ──\n{}\n── end {} ──",
                kind.label(),
                node.title,
                body,
                kind.label().to_lowercase()
            ));
        }
        if chunks.is_empty() {
            self.status = format!(
                "{} RAG: no entry titled like `{lookup}` in the {} book",
                kind.label(),
                kind.label()
            );
            return;
        }
        let prefix = format!(
            "── {} context for `{lookup}` ({} match(es)) ──\n\n{}",
            kind.label(),
            chunks.len(),
            chunks.join("\n\n")
        );

        // Item 4: if the AI prompt is empty, arm the prefix and let the
        // user type their question. Otherwise send immediately with the
        // current prompt as the question.
        let prompt_present = !self.ai_input.as_str().trim().is_empty();
        if prompt_present {
            self.pending_rag_prefix = Some(prefix);
            self.start_inference();
            // start_inference moves focus to AiPrompt; bounce to AI pane
            // so the user can watch the streamed answer per spec.
            self.change_focus(Focus::Ai);
        } else {
            self.pending_rag_prefix = Some(prefix);
            self.change_focus(Focus::AiPrompt);
            self.status = format!(
                "{} RAG armed for `{lookup}` — type your question and Enter",
                kind.label()
            );
        }
    }

    /// Run a grammar check on the currently-open paragraph. Resolves a
    /// "Grammar check" prompt template by precedence:
    ///   1. Paragraph titled / slugged `grammar-check` (or `Grammar check`)
    ///      under the Prompts system book.
    ///   2. Same-named entry in `prompts.hjson` (`name: "grammar-check"`).
    ///   3. Built-in fallback that constrains the LLM to checking syntax
    ///      and punctuation in `cfg.language` while preserving any Typst
    ///      formatting.
    ///
    /// In all three cases the paragraph body is appended verbatim. After
    /// streaming starts focus jumps to the AI pane so the user can watch
    /// the result render in real time.
    pub(super) fn start_grammar_check(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "grammar check needs an open paragraph".into();
            return;
        };
        let body = doc.textarea.lines().join("\n");
        if body.trim().is_empty() {
            self.status = "grammar check: paragraph is empty".into();
            return;
        }

        // 1.2.12+ — Phase A: route through the three-pass
        // language-aware resolver.  Same observable behaviour as
        // the legacy 4-step pattern for projects without any
        // `lang:*` tags or `language: <code>` HJSON entries
        // (Pass 2 picks up every untagged prompt); projects that
        // *do* add per-language prompts get them preferred.
        const NAME: &str = "grammar-check";
        let want_lang = self.active_prompt_language();
        let template = self
            .resolve_prompt(NAME, &want_lang, || {
                // 1.2.12+ Phase B — embedded floor is now
                // language-aware; pass the ISO code from
                // `active_prompt_language`.
                grammar_check_default_prompt(&want_lang).to_string()
            })
            .template;

        // Render placeholders ({{selection}} / {{context}}) and then
        // append the paragraph body so the model has a single trailing
        // block to work on regardless of whether the template already
        // referenced it.
        let rendered = self.render_template(&template);
        let prompt_text = format!(
            "{rendered}\n\n── Paragraph: {title} ──\n{body}\n── end paragraph ──",
            title = doc.title
        );

        let (model, _env_var) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("grammar check: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        // Grammar check is a one-shot: don't replay chat history, don't
        // append the turn to history. Behaviour matches Help in that
        // sense.
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            Some(GRAMMAR_CHECK_SYSTEM_PROMPT.to_string()),
            Vec::new(),
            prompt_text,
            "grammar",
        );
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        // Per spec: focus moves to the AI pane so the user can watch the
        // streamed result. Esc bounces back to AiPrompt for follow-ups.
        self.change_focus(Focus::Ai);
        self.status = format!(
            "Grammar check: streaming from {provider} ({})…",
            self.cfg.language
        );
    }

    pub(super) fn start_help_inference(&mut self, query: &str) {
        let query = query.trim();
        if query.is_empty() {
            self.status = "Help: empty question".into();
            return;
        }

        // Locate the Help book; required as the RAG source.
        let Some(help_id) = self.system_book_id(crate::store::SYSTEM_TAG_HELP) else {
            self.status = "Help book not present — re-open the project to seed it".into();
            return;
        };
        let help_subtree: std::collections::HashSet<Uuid> =
            self.hierarchy.collect_subtree(help_id).into_iter().collect();

        // Search broadly, then filter to nodes inside the Help subtree. We
        // ask for more than we'll actually feed to the LLM so the post-filter
        // doesn't starve us if many hits are outside Help.
        let raw_hits = match self.store.search_text(query, 40) {
            Ok(hits) => hits,
            Err(e) => {
                self.status = format!("Help: search failed: {e}");
                return;
            }
        };
        let mut chosen: Vec<SearchHit> = raw_hits
            .iter()
            .filter_map(SearchHit::parse)
            .filter(|h| help_subtree.contains(&h.id))
            .collect();
        // Keep only paragraphs — branches don't have prose to ground on.
        chosen.retain(|h| h.kind == NodeKind::Paragraph);
        // Cap context size to avoid blowing the model's window.
        const MAX_CONTEXT_PARAGRAPHS: usize = 8;
        const MAX_CHARS_PER_PARAGRAPH: usize = 2000;
        chosen.truncate(MAX_CONTEXT_PARAGRAPHS);

        // Fetch full content for the chosen paragraphs and assemble the
        // grounded context block.
        let mut context = String::new();
        let mut included = 0usize;
        for hit in &chosen {
            let body = match self.store.get_content(hit.id) {
                Ok(Some(bytes)) => String::from_utf8_lossy(&bytes).to_string(),
                _ => continue,
            };
            let trimmed = if body.chars().count() > MAX_CHARS_PER_PARAGRAPH {
                let mut t: String = body.chars().take(MAX_CHARS_PER_PARAGRAPH).collect();
                t.push('…');
                t
            } else {
                body
            };
            let breadcrumb = self.title_breadcrumb(hit.id);
            context.push_str(&format!(
                "── Help excerpt: {} (path: {}) ──\n{}\n\n",
                hit.title, breadcrumb, trimmed
            ));
            included += 1;
        }

        if included == 0 {
            self.status = format!(
                "Help: no entries found for `{}`. Try a different question.",
                query
            );
            return;
        }

        let system_prompt = HELP_SYSTEM_PROMPT.to_string();
        let user_prompt = format!(
            "Question: {query}\n\nContext (Inkhaven Help excerpts — your ONLY allowed source):\n\n{context}\nAnswer using only the context above. If it does not contain the answer, say so plainly and suggest which part of the Help book might be relevant."
        );

        let (model, _env_var) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("Help: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();

        // Help is a one-shot RAG inference — no chat history is replayed
        // (so the strict grounding system prompt isn't diluted), and the
        // turn does not accumulate into `chat_history`.
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            Some(system_prompt),
            Vec::new(),
            user_prompt,
            "help",
        );
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        // Land on the AI prompt pane so the user can immediately ask a
        // follow-up Help question; Esc flips to the AI pane to read.
        self.change_focus(Focus::AiPrompt);
        self.status = format!(
            "Help: streaming answer from {provider} (grounded on {included} excerpt(s))…"
        );
    }

    /// 1.2.12+ — Ctrl+R inside the `Ctrl+B 0` HJSON
    /// editor modal: kick off an LLM review of the
    /// current buffer.  Mirrors the prompts-editor
    /// TUI's "reviewer LLM" pattern — the model
    /// critiques the HJSON config as a piece of work,
    /// not by executing it.  Streams into
    /// `App.inference` so the response is visible in
    /// the main TUI's AI pane once the modal closes.
    /// Status echoes progress while the modal is up.
    pub(super) fn start_hjson_review(&mut self) {
        let buffer = match &self.modal {
            crate::tui::modal::Modal::HjsonEditor { textarea, path, .. } => {
                let body = textarea.lines().join("\n");
                let path_label = path
                    .file_name()
                    .map(|s| s.to_string_lossy().into_owned())
                    .unwrap_or_else(|| "inkhaven.hjson".to_string());
                (body, path_label)
            }
            _ => {
                self.status = "hjson review: not in the hjson editor".into();
                return;
            }
        };
        let (body, label) = buffer;
        if body.trim().is_empty() {
            self.status = "hjson review: buffer is empty".into();
            return;
        }
        let prompt_text = format!(
            "Review this `{label}` configuration for an inkhaven project.  \
             Identify potential issues: invalid combinations of values, \
             dangerous defaults, fields that look misnamed, fields that \
             should probably be set but aren't, places where the existing \
             value contradicts a comment.  Be specific — quote the field \
             path when you critique it.  When the user asks a question \
             about the config, answer it directly first, then justify.\n\n\
             ```hjson\n{body}\n```"
        );
        let (model, _env_var) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("hjson review: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            Some(HJSON_REVIEW_SYSTEM_PROMPT.to_string()),
            Vec::new(),
            prompt_text,
            "hjson-review",
        );
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        self.status = format!(
            "hjson review: streaming from {provider} — close (Esc) to read in AI pane",
        );
    }

    /// Ctrl+F12 (1.2.6+) — send the typst diagnostic at the
    /// cursor (or the closest one) to the AI pane with the
    /// configured explain-or-fix prompt. Surrounds the
    /// diagnostic with ±5 context lines so the model sees the
    /// problem and what's around it without the whole file.
    /// Used to live on bare F11; macOS grabs F11 (Mission
    /// Control / Show Desktop) so the chord never made it
    /// into the TUI.
    pub(super) fn start_explain_diagnostic(&mut self) {
        // Force a refresh so we explain the live state, not the
        // cached one.
        self.refresh_typst_diagnostics_for_opened();
        let (diag, body, title) = match self.opened.as_ref() {
            Some(doc) => {
                if doc.typst_diagnostics.is_empty() {
                    self.status =
                        "Ctrl+F12 explain: no typst diagnostics in this buffer".into();
                    return;
                }
                let (cur_row, _) = doc.textarea.cursor();
                let cur1 = cur_row + 1;
                // Pick the diagnostic closest to the cursor row.
                let picked = doc
                    .typst_diagnostics
                    .iter()
                    .min_by_key(|d| {
                        ((d.line as i64) - (cur1 as i64)).abs()
                    })
                    .cloned();
                let Some(d) = picked else {
                    self.status =
                        "Ctrl+F12 explain: no diagnostic to anchor on".into();
                    return;
                };
                let body = doc.textarea.lines().join("\n");
                (d, body, doc.title.clone())
            }
            None => {
                self.status = "Ctrl+F12 explain: no paragraph open".into();
                return;
            }
        };

        // ±5 lines of context around the diagnostic.
        let lines: Vec<&str> = body.lines().collect();
        let lo = diag.line.saturating_sub(6); // 0-based
        let hi = (diag.line + 4).min(lines.len()); // exclusive
        let mut context = String::new();
        for (idx_zero, line) in lines.iter().enumerate().take(hi).skip(lo) {
            let lineno = idx_zero + 1;
            let mark = if lineno == diag.line { ">> " } else { "   " };
            context.push_str(&format!("{mark}{lineno:>4}  {line}\n"));
        }

        // 1.2.12+ Phase B — capture the want-lang code so the
        // language-aware embedded floor can pick the right
        // variant.  `resolve_prompt_template` itself reads
        // `active_prompt_language` internally for its
        // language target.
        let want_lang = self.active_prompt_language();
        let template = self.resolve_prompt_template("explain-diagnostic", || {
            explain_diagnostic_default_prompt(&want_lang).to_string()
        });
        let rendered = self.render_template(&template);
        let prompt_text = format!(
            "{rendered}\n\n── Diagnostic ──\nline {line}:{col} — {msg}\n── end ──\n\n── Context (paragraph: {title}) ──\n{context}── end context ──",
            line = diag.line,
            col = diag.col,
            msg = diag.message,
        );

        let (model, _env_var) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("Ctrl+F12 explain: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            None,
            Vec::new(),
            prompt_text,
            "explain",
        );
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        self.change_focus(Focus::Ai);
        self.status = format!(
            "Explaining typst diagnostic at line {}:{} via {provider}…",
            diag.line, diag.col,
        );
    }

    /// F12 (1.2.6+) — AI critique of the open paragraph. Mode-
    /// aware: when split-edit (F4) is active, sends the
    /// "evaluate-changes" prompt with both the snapshot and
    /// the live buffer; otherwise sends the "critique-edit"
    /// prompt with just the live buffer. Both prompt names
    /// resolve via the standard Prompts-book → prompts.hjson
    /// → embedded precedence.
    pub(super) fn start_critique(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "F12 critique: no paragraph open".into();
            return;
        };
        let body = doc.textarea.lines().join("\n");
        if body.trim().is_empty() {
            self.status = "F12 critique: paragraph is empty".into();
            return;
        }
        let title = doc.title.clone();
        let split_baseline = doc
            .split
            .as_ref()
            .map(|s| s.snapshot_lines.join("\n"));

        // 1.2.12+ Phase D — split-view with two distinct
        // paragraphs picks the `critique-compare` flow:
        // bundle both bodies and ask the LLM for a
        // comparative critique (translation faithfulness,
        // draft-vs-draft strength).  Same precedence
        // rules as the other flows — Prompts book →
        // prompts.hjson → embedded.  Skips the comparison
        // when secondary is empty or holds the same body
        // as primary (avoids self-compare); falls back to
        // single-paragraph critique-edit in those cases.
        let primary_id = doc.id;
        let split_compare: Option<(String, String, String)> = if self.split_view {
            self.secondary.as_ref().and_then(|sec| {
                if sec.id == primary_id {
                    return None;
                }
                let sec_body = sec.textarea.lines().join("\n");
                if sec_body.trim().is_empty() {
                    return None;
                }
                Some((sec.title.clone(), sec_body, sec.id.to_string()))
            })
        } else {
            None
        };

        // 1.2.12+ Phase B — embedded floors are language-
        // aware; capture the want-lang ISO and pass it into
        // whichever variant fires.  Function pointers can't
        // close over local state, so the dispatch is the
        // local `match` below.
        let want_lang = self.active_prompt_language();
        let prompt_name = if split_compare.is_some() {
            "critique-compare"
        } else if split_baseline.is_some() {
            "critique-changes"
        } else {
            "critique-edit"
        };
        let template = self
            .resolve_prompt_template(prompt_name, || match prompt_name {
                "critique-changes" => {
                    critique_changes_default_prompt(&want_lang).to_string()
                }
                "critique-compare" => {
                    super::super::app::critique_compare_default_prompt(&want_lang)
                        .to_string()
                }
                _ => critique_edit_default_prompt(&want_lang).to_string(),
            });
        let rendered = self.render_template(&template);

        let prompt_text = match (&split_compare, split_baseline.as_ref()) {
            (Some((sec_title, sec_body, _)), _) => format!(
                "{rendered}\n\n── Left (`{title}`) ──\n{body}\n── end left ──\n\n── Right (`{sec_title}`) ──\n{sec_body}\n── end right ──",
            ),
            (None, Some(baseline)) => format!(
                "{rendered}\n\n── Before (snapshot) ──\n{baseline}\n── end before ──\n\n── After (current buffer of `{title}`) ──\n{body}\n── end after ──",
            ),
            (None, None) => format!(
                "{rendered}\n\n── Paragraph: {title} ──\n{body}\n── end paragraph ──",
            ),
        };

        let (model, _env_var) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("F12 critique: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            None,
            Vec::new(),
            prompt_text,
            "critique",
        );
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        self.change_focus(Focus::Ai);
        self.status = format!(
            "F12 critique (`{prompt_name}`): streaming from {provider}…",
        );
    }

    /// 1.2.9+ — Ctrl+B Shift+T action: AI-driven
    /// show-don't-tell scan of the open paragraph.
    /// Mirrors `start_critique` exactly — builds a
    /// system prompt, spawns the streaming AI client,
    /// routes the response into the AI pane.  Uses
    /// the `show-dont-tell` prompt name; falls back
    /// to the embedded template
    /// (`show_dont_tell_default_prompt`) when the
    /// project / global prompts library doesn't
    /// provide one.
    pub(super) fn start_show_dont_tell_scan(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "show↛tell scan: no paragraph open".into();
            return;
        };
        let body = doc.textarea.lines().join("\n");
        if body.trim().is_empty() {
            self.status = "show↛tell scan: paragraph is empty".into();
            return;
        }
        let title = doc.title.clone();
        let template = self.resolve_prompt_template(
            "show-dont-tell",
            || {
                let want_lang = self.active_prompt_language();
                super::super::app::show_dont_tell_default_prompt(&want_lang)
                    .to_string()
            },
        );
        let rendered = self.render_template(&template);
        let prompt_text = format!(
            "{rendered}\n\n── Paragraph: {title} ──\n{body}\n── end paragraph ──",
        );
        let (model, _env_var) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("show↛tell scan: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            None,
            Vec::new(),
            prompt_text,
            "show-dont-tell",
        );
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        self.change_focus(Focus::Ai);
        self.status =
            format!("show↛tell scan: streaming from {provider}…");
    }

    /// 1.2.21+ — Ctrl+B Shift+X.  AI fact-check of the open
    /// paragraph against the project's Facts book.
    ///
    /// Per the Facts design (Decision 1) this **locks the AI scope
    /// to the local paragraph** — the check is about *this* prose,
    /// never a whole-book send — and **grounds against the Facts
    /// book**: every established fact is included so the model can
    /// flag a claim that contradicts the world (snow in a tropical
    /// region, a three-day ride that should be three weeks).  An
    /// empty Facts book degrades to a plain local fact-check.
    /// One-shot — streams the verdict into the AI pane, like the
    /// show↛tell scan, under the multilingual fact-analysis system
    /// prompt.
    ///
    /// Prompt resolution is the standard pattern: a Prompts-book
    /// `fact-check` paragraph → a `prompts.hjson` entry → the
    /// embedded multilingual `fact_check_default_prompt`.
    pub(super) fn start_fact_check(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "fact check: no paragraph open".into();
            return;
        };
        let body = doc.textarea.lines().join("\n");
        if body.trim().is_empty() {
            self.status = "fact check: paragraph is empty".into();
            return;
        }
        let title = doc.title.clone();
        let para_id = doc.id;
        // Decision 1 — lock the AI scope to the local paragraph.
        self.ai_mode = AiMode::Paragraph;
        // 1.2.21+ FF.4d — arm navigable findings: pump_inference parses
        // this run's verdict on completion; clear any stale findings.
        self.fact_check_pending = Some((para_id, title.clone()));
        self.fact_check_nav = super::FactCheckNav::default();
        let template = self.resolve_prompt_template("fact-check", || {
            let want_lang = self.active_prompt_language();
            super::super::app::fact_check_default_prompt(&want_lang).to_string()
        });
        let rendered = self.render_template(&template);
        // Ground against the Facts book.  FF.5 — when the open paragraph
        // is explicitly wiki-linked to particular facts (Ctrl+V A), those
        // dependencies lead in a labelled block so the model weights
        // them; the rest follow.  Additive: every fact is still sent, so
        // coverage never drops.
        let all_ids = self.facts_paragraph_ids();
        let linked: std::collections::HashSet<Uuid> = self
            .hierarchy
            .get(para_id)
            .map(|n| n.linked_paragraphs.iter().copied().collect())
            .unwrap_or_default();
        let linked_ids: Vec<Uuid> =
            all_ids.iter().copied().filter(|id| linked.contains(id)).collect();
        let n_linked = linked_ids.len();
        let facts_block = if all_ids.is_empty() {
            String::new()
        } else if linked_ids.is_empty() {
            let facts = self.facts_chunks_for(&all_ids);
            format!(
                "── Established facts ({} entr{}) ──\n{}\n── end facts ──\n\n",
                facts.len(),
                if facts.len() == 1 { "y" } else { "ies" },
                facts.join("\n\n"),
            )
        } else {
            let rest_ids: Vec<Uuid> =
                all_ids.iter().copied().filter(|id| !linked.contains(id)).collect();
            let linked_chunks = self.facts_chunks_for(&linked_ids);
            let rest_chunks = self.facts_chunks_for(&rest_ids);
            let rest_block = if rest_chunks.is_empty() {
                String::new()
            } else {
                format!(
                    "── Other established facts ({}) ──\n{}\n── end facts ──\n\n",
                    rest_chunks.len(),
                    rest_chunks.join("\n\n"),
                )
            };
            format!(
                "── Facts this scene is linked to ({}) — weight contradictions with these ──\n{}\n── end linked facts ──\n\n{}",
                linked_chunks.len(),
                linked_chunks.join("\n\n"),
                rest_block,
            )
        };
        let prompt_text = format!(
            "{rendered}\n\n{facts_block}── Paragraph: {title} ──\n{body}\n── end paragraph ──",
        );
        let (model, _env_var) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("fact check: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        let lang = crate::ai::prompts::iso_from_long(&self.cfg.language);
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            Some(facts_scope_system_prompt(lang).to_string()),
            Vec::new(),
            prompt_text,
            "fact-check",
        );
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        self.change_focus(Focus::Ai);
        let grounded = if all_ids.is_empty() {
            " (no facts — generic check)".to_string()
        } else if n_linked > 0 {
            format!(" (grounded in Facts; {n_linked} linked to this scene)")
        } else {
            " (grounded in Facts)".to_string()
        };
        self.status = format!("fact check{grounded}: streaming from {provider}…");
    }

    /// 1.2.11+ — Ctrl+B Shift+M.  AI-driven sentence-
    /// rhythm rewrite of the open paragraph.
    ///
    /// Prompt resolution follows the standard
    /// pattern (`resolve_prompt_template`):
    ///   1. Paragraph in the project's Prompts
    ///      system book with slug or title
    ///      `sentence-rhythm-rewrite` /
    ///      `sentence rhythm rewrite`.
    ///   2. Entry in the project's
    ///      `prompts.hjson`.
    ///   3. Embedded multilingual fallback —
    ///      `sentence_rhythm_rewrite_default_prompt`,
    ///      language-aware via `cfg.language`.
    ///
    /// Unlike show-don't-tell (which leaves the
    /// response in the AI pane for the user to
    /// read), the rewrite flow auto-opens an AI
    /// diff modal when streaming completes.  The
    /// modal carries `post_accept_snapshot =
    /// Some("Sentence rhythm rewrite")` so the
    /// apply step creates an annotated F6-
    /// discoverable snapshot of the pre-rewrite
    /// state BEFORE the buffer is replaced.
    pub(super) fn start_sentence_rhythm_rewrite(&mut self) {
        // 1.2.11+ — chord can fire from inside the
        // `Ctrl+B Shift+H` rhythm-gauge modal
        // (the natural diagnose-then-rewrite
        // workflow).  Dismiss the gauge before
        // spawning the inference so the user
        // sees the AI pane streaming the rewrite,
        // not a stale gauge frozen on a verdict.
        if matches!(self.modal, Modal::SentenceRhythm { .. }) {
            self.modal = Modal::None;
        }
        let Some(doc) = self.opened.as_ref() else {
            self.status = "rhythm rewrite: no paragraph open".into();
            return;
        };
        let body = doc.textarea.lines().join("\n");
        if body.trim().is_empty() {
            self.status = "rhythm rewrite: paragraph is empty".into();
            return;
        }
        let title = doc.title.clone();
        let language = self.cfg.language.clone();
        // 1.2.12+ — Phase A: route through the language-aware
        // resolver.  Same precedence the F7 grammar-check flow
        // uses; both slug and title forms are tried inside each
        // pass by the resolver.
        const NAME: &str = "sentence-rhythm-rewrite";
        let want_lang = self.active_prompt_language();
        let template = self
            .resolve_prompt(NAME, &want_lang, || {
                // 1.2.12+ Phase B — embedded floor is now
                // a 5-language match keyed by ISO code.
                sentence_rhythm_rewrite_default_prompt(&want_lang).to_string()
            })
            .template;
        let rendered = self.render_template(&template);
        let prompt_text = format!(
            "{rendered}\n\n── Paragraph: {title} ──\n{body}\n── end paragraph ──",
        );
        let (model, _env_var) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("rhythm rewrite: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            None,
            Vec::new(),
            prompt_text,
            "rhythm",
        );
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        // Flag this inference as "auto-apply via
        // diff review" — pump_inference watches
        // for the transition to Done and opens
        // the diff modal automatically. The annotation is the snapshot
        // label the apply step preserves the pre-rewrite state under.
        self.pending_rewrite_diff = Some("Sentence rhythm rewrite".into());
        self.pending_rewrite_span = None; // whole-paragraph rewrite
        self.change_focus(Focus::Ai);
        self.status = format!(
            "rhythm rewrite ({language}): streaming from {provider} · diff review on completion…"
        );
    }

    /// 1.3.7 EDITORIAL-2 P0 — stream a category-specific AI rewrite of the
    /// **open** paragraph (the cockpit's `f` opens the finding's paragraph
    /// first), routed through the same diff→snapshot review as the rhythm
    /// rewrite. `category` selects the recipe ([`crate::editorial::fix_spec`]).
    ///
    /// 1.3.9 EDITORIAL-3 P0 — `span` is the finding's paragraph-relative char
    /// range. For a [`FixScope::Span`] category with a span, only that phrase
    /// is rewritten (marked in context, the reply spliced back); otherwise the
    /// whole paragraph is rewritten as before.
    pub(super) fn start_editorial_rewrite(&mut self, category: &str, span: Option<(usize, usize)>) {
        use crate::editorial::FixScope;
        let Some(spec) = crate::editorial::fix_spec(category) else {
            self.status = "editorial fix: this finding isn't AI-rewritable".into();
            return;
        };
        let Some(doc) = self.opened.as_ref() else {
            self.status = "editorial fix: no paragraph open".into();
            return;
        };
        let body = doc.textarea.lines().join("\n");
        if body.trim().is_empty() {
            self.status = "editorial fix: paragraph is empty".into();
            return;
        }
        let title = doc.title.clone();
        let want_lang = self.active_prompt_language();
        let template = self
            .resolve_prompt(spec.slug, &want_lang, || spec.builtin.to_string())
            .template;
        let rendered = self.render_template(&template);
        // A span-scoped fix marks the flagged phrase in context with « » and
        // asks for just its replacement; everything else stays a whole-
        // paragraph rewrite.
        let use_span = matches!(spec.scope, FixScope::Span) && span.is_some();
        let prompt_text = if use_span {
            let (a, b) = span.unwrap();
            let chars: Vec<char> = body.chars().collect();
            let n = chars.len();
            let a = a.min(n);
            let b = b.clamp(a, n);
            let before: String = chars[..a].iter().collect();
            let phrase: String = chars[a..b].iter().collect();
            let after: String = chars[b..].iter().collect();
            format!(
                "{rendered}\n\nThe paragraph below has ONE phrase marked between « and ». \
Rewrite ONLY that phrase. Output just the replacement text for it — no « » markers, none of the \
surrounding paragraph, no preamble.\n\n── Paragraph: {title} (for context) ──\n{before}«{phrase}»{after}\n── end paragraph ──"
            )
        } else {
            format!("{rendered}\n\n── Paragraph: {title} ──\n{body}\n── end paragraph ──")
        };
        let (model, _env) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("editorial fix: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        let rx = spawn_chat_stream(self.ai.client.clone(), model.clone(), None, Vec::new(), prompt_text, "editorial-rewrite");
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        self.pending_rewrite_diff = Some(format!("Editorial: {}", spec.label));
        self.pending_rewrite_span = if use_span { span } else { None };
        self.change_focus(Focus::Ai);
        let scope_chip = if use_span { " · phrase" } else { "" };
        self.status = format!(
            "editorial fix ({}{scope_chip}): streaming from {provider} · diff review on completion…",
            spec.label
        );
    }

    /// 1.2.13+ Phase C — Ctrl+B Q.  Translate the open
    /// paragraph from the project's working language INTO an
    /// invented language defined under the `Language` system
    /// book.
    ///
    /// Scope decisions for the MVP:
    ///   * Zero Language sub-books → status error.
    ///   * Exactly one → translate directly.
    ///   * Two or more → translate into the first by canonical
    ///     order and warn the user; per-language sub-letter
    ///     chord (`Ctrl+B Q Q` for Quenya etc.) lands in
    ///     Phase C.2.
    ///
    /// The prompt envelope is composed at fire time from the
    /// language sub-book's chapters: every Grammar + every
    /// Phonology rule for context; a RAG-filtered slice of
    /// Dictionary entries (lemma OR any paradigm form appears
    /// in the source); up to three Sample-text paragraphs as
    /// few-shot anchors.  Streams into the AI pane via the
    /// shared `Inference` machinery.
    pub(super) fn start_translate_to_invented(&mut self) {
        self.start_translate_in_direction(
            super::super::modal::TranslationDirection::ToInvented,
        );
    }

    /// 1.2.13+ Phase C.2 — Ctrl+B Shift+Q.  Reverse direction:
    /// translate the open paragraph FROM an invented language
    /// back to the project's working language.  Useful for
    /// roundtrip testing — feeding an output from `Ctrl+B Q`
    /// through this verifies the LLM can read back its own
    /// grammar.
    pub(super) fn start_translate_from_invented(&mut self) {
        self.start_translate_in_direction(
            super::super::modal::TranslationDirection::FromInvented,
        );
    }

    fn start_translate_in_direction(
        &mut self,
        direction: super::super::modal::TranslationDirection,
    ) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "translate: no paragraph open".into();
            return;
        };
        let body = doc.textarea.lines().join("\n");
        if body.trim().is_empty() {
            self.status = "translate: paragraph is empty".into();
            return;
        }
        let title = doc.title.clone();

        // Find Language sub-books.
        let lang_root = self.hierarchy.iter().find(|n| {
            n.system_tag.as_deref()
                == Some(crate::store::SYSTEM_TAG_LANGUAGES)
        });
        let Some(lang_root) = lang_root else {
            self.status = "translate: Language system book missing — \
                           re-open the project to seed it"
                .into();
            return;
        };
        let lang_books: Vec<_> =
            self.hierarchy.children_of(Some(lang_root.id));
        if lang_books.is_empty() {
            self.status = "translate: no invented languages defined — \
                           run `inkhaven language init <name>` first"
                .into();
            return;
        }

        // 0 → error (handled above), 1 → direct, 2+ → picker.
        //
        // 1.2.15+ Phase S.1 — pattern-match `[only]`
        // instead of `len() == 1` + `.first().unwrap()`.
        // Same behaviour, no panic surface even if a
        // future refactor changes how `lang_books` is
        // built; the `else { /* 2+ → picker */ }` falls
        // through to the picker path naturally.
        if let [only] = lang_books.as_slice() {
            let target_book = (*only).clone();
            self.spawn_translation_inference(
                &target_book,
                direction,
                title,
                body,
            );
            return;
        }

        // Multi-language project: pop the picker.  The picker
        // commit handler dispatches back into
        // `spawn_translation_inference` with the user's choice.
        let entries: Vec<(uuid::Uuid, String)> = lang_books
            .iter()
            .map(|n| (n.id, n.title.clone()))
            .collect();
        let n = entries.len();
        let dir_word = match direction {
            super::super::modal::TranslationDirection::ToInvented => "into",
            super::super::modal::TranslationDirection::FromInvented => "from",
        };
        self.modal = super::super::modal::Modal::TranslationLanguagePicker {
            entries,
            cursor: 0,
            direction,
            source_title: title,
            source_body: body,
        };
        self.status = format!(
            "translate {dir_word} which of {n} languages? · ↑↓ / Enter / first-letter · Esc to cancel"
        );
    }

    /// 1.2.13+ Phase C.2 — picker key handler.  Esc cancels;
    /// Enter commits with the highlighted language.  Plain
    /// letter keys also commit when the language title starts
    /// with that letter (the per-language sub-letter shortcut
    /// the proposal calls out as `Ctrl+B Q Q` for Quenya etc.,
    /// rolled into the picker rather than a separate sub-chord).
    pub(super) fn translation_picker_handle_key(
        &mut self,
        key: crossterm::event::KeyEvent,
    ) -> bool {
        use crossterm::event::KeyCode;
        let super::super::modal::Modal::TranslationLanguagePicker {
            entries,
            cursor,
            ..
        } = &mut self.modal
        else {
            return false;
        };
        let total = entries.len();
        match key.code {
            KeyCode::Up => {
                if *cursor > 0 {
                    *cursor -= 1;
                }
                true
            }
            KeyCode::Down => {
                if *cursor + 1 < total {
                    *cursor += 1;
                }
                true
            }
            KeyCode::Home => {
                *cursor = 0;
                true
            }
            KeyCode::End => {
                *cursor = total.saturating_sub(1);
                true
            }
            KeyCode::Esc => {
                self.modal = super::super::modal::Modal::None;
                self.status = "translation cancelled".into();
                true
            }
            KeyCode::Enter => {
                self.commit_translation_picker();
                true
            }
            KeyCode::Char(c) => {
                // First-letter shortcut: jump to (and commit on)
                // the first language whose name starts with the
                // typed letter, case-insensitive.  Unambiguous
                // single-letter ↦ language is the proposal's
                // `Ctrl+B Q Q` for Quenya pattern, just
                // unbundled.
                if let Some(idx) = entries.iter().position(|(_, name)| {
                    name.chars()
                        .next()
                        .map(|first| {
                            first.to_ascii_lowercase()
                                == c.to_ascii_lowercase()
                        })
                        .unwrap_or(false)
                }) {
                    *cursor = idx;
                    self.commit_translation_picker();
                    true
                } else {
                    false
                }
            }
            _ => false,
        }
    }

    fn commit_translation_picker(&mut self) {
        let (target_id, direction, source_title, source_body) =
            match std::mem::replace(
                &mut self.modal,
                super::super::modal::Modal::None,
            ) {
                super::super::modal::Modal::TranslationLanguagePicker {
                    entries,
                    cursor,
                    direction,
                    source_title,
                    source_body,
                } => {
                    let Some((id, _)) = entries.get(cursor).cloned() else {
                        return;
                    };
                    (id, direction, source_title, source_body)
                }
                other => {
                    self.modal = other;
                    return;
                }
            };
        let Some(target_book) = self.hierarchy.get(target_id).cloned() else {
            self.status = "translate: target language vanished — try again".into();
            return;
        };
        self.spawn_translation_inference(
            &target_book,
            direction,
            source_title,
            source_body,
        );
    }

    /// Compose the envelope, spawn the chat stream, and park
    /// the resulting `Inference` into `self.inference` so the
    /// AI pane picks it up.  Shared by the single-language
    /// fast path and the picker-commit path.
    fn spawn_translation_inference(
        &mut self,
        target_book: &crate::store::node::Node,
        direction: super::super::modal::TranslationDirection,
        source_title: String,
        source_body: String,
    ) {
        let target_name = target_book.title.clone();
        let source_lang = self.cfg.language.clone();
        let envelope = match self.compose_translation_prompt(
            target_book,
            &target_name,
            &source_lang,
            &source_title,
            &source_body,
            direction,
        ) {
            Ok(text) => text,
            Err(e) => {
                self.status = format!("translate: {e}");
                return;
            }
        };
        let (model, _env_var) = match self
            .ai
            .resolve_provider(&self.cfg.llm, None)
        {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("translate: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            None,
            Vec::new(),
            envelope,
            "translation",
        );
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        self.pending_translation = true;
        self.change_focus(Focus::Ai);
        let arrow = match direction {
            super::super::modal::TranslationDirection::ToInvented =>
                format!("→ {target_name}"),
            super::super::modal::TranslationDirection::FromInvented =>
                format!("{target_name} →"),
        };
        self.status = format!(
            "translate {arrow}: streaming from {provider}…"
        );
    }

    /// Build the translation prompt envelope.
    ///
    /// Structure:
    ///   1. System prompt: explains the LLM's role.
    ///   2. Target-language overview (Meta/overview HJSON).
    ///   3. Grammar rules — all of them, capped by §13's
    ///      typical 4-8K token budget.
    ///   4. Phonology rules — all of them.
    ///   5. Dictionary entries — RAG-filtered to words present
    ///      in the source text (case-insensitive lookup against
    ///      the LanguageEntryIndex's surface forms).  Falls
    ///      back to all entries when the index is empty (no
    ///      parseable HJSON bodies yet — early in the language's
    ///      life).
    ///   6. Sample texts — up to 3 most recent paragraphs from
    ///      the Sample-texts chapter, as few-shot register
    ///      anchors.
    ///   7. User prompt: the source text + direction.
    fn compose_translation_prompt(
        &self,
        target_book: &crate::store::node::Node,
        target_name: &str,
        source_lang: &str,
        source_title: &str,
        source_body: &str,
        direction: super::super::modal::TranslationDirection,
    ) -> std::result::Result<String, String> {
        use crate::store::node::NodeKind;
        use super::super::modal::TranslationDirection;

        // Section 2 — overview.
        let overview = self
            .read_chapter_body(target_book.id, "Meta", "overview")
            .unwrap_or_default();

        // Section 3 + 4 — Grammar + Phonology.
        let grammar = self.collect_chapter_bodies(target_book.id, "Grammar");
        let phonology = self.collect_chapter_bodies(target_book.id, "Phonology");

        // Section 5 — Dictionary entries, RAG-filtered.
        use unicode_segmentation::UnicodeSegmentation;
        let source_words_lc: std::collections::HashSet<String> =
            UnicodeSegmentation::unicode_words(source_body)
                .map(|w: &str| w.to_lowercase())
                .collect();
        // Collect every Dictionary entry once, owned.  Then
        // RAG-filter — single-word translations hit when the
        // translation appears as a tokenised word in the
        // source; multi-word translations need a substring
        // check on the lowercased body.
        let body_lc = source_body.to_lowercase();
        let mut all_entries: Vec<crate::language_entry::DictionaryEntry> =
            Vec::new();
        for id in self.hierarchy.collect_subtree(target_book.id) {
            let Some(node) = self.hierarchy.get(id) else { continue; };
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            let path = self.hierarchy.slug_path(node);
            if !path.contains("/dictionary/") {
                continue;
            }
            let bytes = match self.store.get_content(id) {
                Ok(Some(b)) => b,
                _ => continue,
            };
            let body_str = match std::str::from_utf8(&bytes) {
                Ok(s) => s,
                Err(_) => continue,
            };
            if let Ok(Some(entry)) = crate::language_entry::parse(body_str) {
                all_entries.push(entry);
            }
        }
        let mut seen: std::collections::HashSet<String> =
            std::collections::HashSet::new();
        let relevant: Vec<&crate::language_entry::DictionaryEntry> = all_entries
            .iter()
            .filter(|entry| {
                let translation = entry.translation.trim().to_lowercase();
                if translation.is_empty() {
                    return false;
                }
                let hit = if translation.contains(' ') {
                    body_lc.contains(&translation)
                } else {
                    source_words_lc.contains(&translation)
                };
                hit && seen.insert(entry.word.clone())
            })
            .collect();
        let dictionary_section = if relevant.is_empty() {
            "(no matching dictionary entries — translate by analogy + ask if uncertain)".to_string()
        } else {
            let mut s = String::new();
            for entry in &relevant {
                s.push_str(&format!(
                    "* `{}` ({}): {}",
                    entry.word, entry.pos, entry.translation
                ));
                if !entry.example.trim().is_empty() {
                    s.push_str(&format!(" — _{}_", entry.example));
                }
                if !entry.inflection.is_empty() {
                    let forms: Vec<String> = entry
                        .inflection
                        .iter()
                        .map(|(k, v)| format!("{k}={v}"))
                        .collect();
                    s.push_str(&format!(" [forms: {}]", forms.join(", ")));
                }
                s.push('\n');
            }
            s
        };

        // Section 6 — Sample texts.
        let sample_texts = self
            .collect_chapter_bodies(target_book.id, "Sample texts")
            .into_iter()
            .take(3)
            .collect::<Vec<_>>()
            .join("\n\n---\n\n");

        // Section 1 + 7 — system + user prompt.
        let working_lang_label = if source_lang.trim().is_empty() {
            "the project's working language"
        } else {
            source_lang
        };
        let (from_label, to_label) = match direction {
            TranslationDirection::ToInvented =>
                (working_lang_label, target_name),
            TranslationDirection::FromInvented =>
                (target_name, working_lang_label),
        };
        let envelope = format!(
            "You are a translator between {working_lang_label} and {target_name}, an \
             invented language defined by the author of this manuscript.  Translate \
             the source paragraph FROM {from_label} INTO {to_label}, using the \
             authoritative grammar, phonology, dictionary, and sample texts below.\n\
             \n\
             Respond in this exact order:\n\
             1. The translation, wrapped between `<<<TRANSLATION>>>` and `<<<END>>>` \
                markers on lines by themselves.  This block is what the editor's \
                insert chord lifts into the buffer, so it must contain ONLY the \
                target-language prose — no commentary, no quotation marks, no \
                trailing notes.\n\
             2. A per-token gloss table (source · gloss · target).\n\
             3. A short list of which grammar rules fired and which dictionary entries \
                you applied.\n\
             4. Confidence flags — call out any word for which the dictionary was \
                missing or ambiguous, with a suggested entry the author can add.\n\
             \n\
             ── Language overview ──\n\
             {overview}\n\
             \n\
             ── Grammar rules ──\n\
             {grammar}\n\
             \n\
             ── Phonology rules ──\n\
             {phonology}\n\
             \n\
             ── Dictionary (filtered to source-relevant entries) ──\n\
             {dictionary_section}\n\
             \n\
             ── Sample texts (register anchors) ──\n\
             {sample_texts}\n\
             \n\
             ── Source paragraph: {source_title} (in {from_label}) ──\n\
             {source_body}\n\
             ── end source ──",
            grammar = grammar.join("\n\n---\n\n"),
            phonology = phonology.join("\n\n---\n\n"),
        );
        Ok(envelope)
    }

    /// Read one paragraph by title under one chapter title
    /// under a language sub-book.  Used for `Meta/overview`.
    fn read_chapter_body(
        &self,
        book_id: uuid::Uuid,
        chapter_title: &str,
        paragraph_title: &str,
    ) -> Option<String> {
        use crate::store::node::NodeKind;
        let chapter = self
            .hierarchy
            .children_of(Some(book_id))
            .into_iter()
            .find(|n| {
                n.kind == NodeKind::Chapter
                    && n.title.eq_ignore_ascii_case(chapter_title)
            })?
            .clone();
        let paragraph = self
            .hierarchy
            .collect_subtree(chapter.id)
            .into_iter()
            .filter_map(|id| self.hierarchy.get(id))
            .find(|n| {
                n.kind == NodeKind::Paragraph
                    && n.title.eq_ignore_ascii_case(paragraph_title)
            })?
            .clone();
        let bytes = self.store.get_content(paragraph.id).ok().flatten()?;
        String::from_utf8(bytes).ok()
    }

    /// Read every paragraph body under one chapter title
    /// of a language sub-book.  Used for Grammar / Phonology /
    /// Sample texts.
    fn collect_chapter_bodies(
        &self,
        book_id: uuid::Uuid,
        chapter_title: &str,
    ) -> Vec<String> {
        use crate::store::node::NodeKind;
        let Some(chapter) = self
            .hierarchy
            .children_of(Some(book_id))
            .into_iter()
            .find(|n| {
                n.kind == NodeKind::Chapter
                    && n.title.eq_ignore_ascii_case(chapter_title)
            })
            .cloned()
        else {
            return Vec::new();
        };
        let mut out = Vec::new();
        for id in self.hierarchy.collect_subtree(chapter.id) {
            let Some(node) = self.hierarchy.get(id) else { continue; };
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            let Ok(Some(bytes)) = self.store.get_content(id) else { continue; };
            if let Ok(s) = String::from_utf8(bytes) {
                out.push(s);
            }
        }
        out
    }

    pub(super) fn ai_diff_review_handle_key(&mut self, key: KeyEvent) {
        match key.code {
            KeyCode::Up => {
                if let Modal::AiDiffReview { scroll, .. } = &mut self.modal {
                    if *scroll > 0 {
                        *scroll -= 1;
                    }
                }
            }
            KeyCode::Down => {
                if let Modal::AiDiffReview {
                    before_lines,
                    after_lines,
                    scroll,
                    wrapped_total,
                    ..
                } = &mut self.modal
                {
                    // 1.2.11+ — bound by wrapped row count
                    // when the renderer has populated it
                    // (every frame after first render).
                    // Fall back to source-line count on
                    // the very first key press before any
                    // render — it's a safe lower bound
                    // since wrapping only adds rows.
                    let max = if *wrapped_total > 0 {
                        *wrapped_total
                    } else {
                        before_lines.len().max(after_lines.len())
                    };
                    if *scroll + 1 < max {
                        *scroll += 1;
                    }
                }
            }
            KeyCode::PageUp => {
                if let Modal::AiDiffReview { scroll, .. } = &mut self.modal {
                    *scroll = scroll.saturating_sub(10);
                }
            }
            KeyCode::PageDown => {
                if let Modal::AiDiffReview {
                    before_lines,
                    after_lines,
                    scroll,
                    wrapped_total,
                    ..
                } = &mut self.modal
                {
                    let max = if *wrapped_total > 0 {
                        *wrapped_total
                    } else {
                        before_lines.len().max(after_lines.len())
                    };
                    *scroll = (*scroll + 10).min(max.saturating_sub(1));
                }
            }
            KeyCode::Home => {
                if let Modal::AiDiffReview { scroll, .. } = &mut self.modal {
                    *scroll = 0;
                }
            }
            KeyCode::End => {
                if let Modal::AiDiffReview {
                    before_lines,
                    after_lines,
                    scroll,
                    wrapped_total,
                    ..
                } = &mut self.modal
                {
                    let max = if *wrapped_total > 0 {
                        *wrapped_total
                    } else {
                        before_lines.len().max(after_lines.len())
                    };
                    *scroll = max.saturating_sub(1);
                }
            }
            // Accept — commit via the original direct path AND
            // refocus the editor pane so the user lands on the
            // freshly-edited buffer ready to type. (`e` is kept
            // as an alias for muscle memory; both behave the
            // same since 1.2.6 batch 7.)
            KeyCode::Char('a')
            | KeyCode::Char('A')
            | KeyCode::Char('e')
            | KeyCode::Char('E')
            | KeyCode::Enter => {
                let taken = std::mem::replace(&mut self.modal, Modal::None);
                if let Modal::AiDiffReview {
                    after_lines,
                    action,
                    post_accept_snapshot,
                    ..
                } = taken
                {
                    // 1.2.11+ — when the rewrite flow
                    // requested it, snapshot the
                    // pre-rewrite buffer with the
                    // supplied annotation BEFORE
                    // replacing.  This is how
                    // Ctrl+B Shift+M (sentence-
                    // rhythm rewrite) preserves the
                    // user's old prose with a
                    // labelled F6-discoverable
                    // entry.
                    if let Some(annotation) = post_accept_snapshot {
                        self.snapshot_open_paragraph_with_annotation(
                            &annotation,
                        );
                    }
                    let after = after_lines.join("\n");
                    self.apply_ai_diff_accepted(action, after, true);
                }
                // 1.3.9 — advance an active batch fix-all to the next finding.
                if self.editorial_batch.is_some() {
                    if let Some(b) = self.editorial_batch.as_mut() {
                        b.applied += 1;
                    }
                    self.advance_editorial_batch();
                }
            }
            // Reject — close and leave the buffer alone.
            KeyCode::Char('r') | KeyCode::Char('R') => {
                self.modal = Modal::None;
                if self.editorial_batch.is_some() {
                    if let Some(b) = self.editorial_batch.as_mut() {
                        b.skipped += 1;
                    }
                    self.advance_editorial_batch();
                } else {
                    self.status = "AI diff: rejected — buffer unchanged".into();
                }
            }
            _ => {}
        }
    }

    pub(super) fn chat_selection_step(&mut self, delta: isize) {
        let Some(sel) = self.chat_selection else { return };
        let total = self.chat_history.len();
        if total == 0 {
            self.chat_selection = None;
            return;
        }
        let new_turn = if delta < 0 {
            sel.turn.saturating_sub(delta.unsigned_abs())
        } else {
            (sel.turn + delta as usize).min(total - 1)
        };
        if let Some(s) = self.chat_selection.as_mut() {
            s.turn = new_turn;
        }
        let label = self.chat_turn_label(new_turn);
        self.status = format!("chat selection: {} {}/{total}", label, new_turn + 1);
    }

    pub(super) fn chat_selection_jump(&mut self, target: usize) {
        let Some(_sel) = self.chat_selection else { return };
        let total = self.chat_history.len();
        if total == 0 {
            self.chat_selection = None;
            return;
        }
        let new_turn = target.min(total - 1);
        if let Some(s) = self.chat_selection.as_mut() {
            s.turn = new_turn;
        }
        let label = self.chat_turn_label(new_turn);
        self.status = format!("chat selection: {} {}/{total}", label, new_turn + 1);
    }

    pub(super) fn chat_turn_label(&self, idx: usize) -> &'static str {
        match self.chat_history.get(idx) {
            Some(ChatTurn::User(_)) => "User",
            Some(ChatTurn::Assistant(_)) => "Assistant",
            None => "?",
        }
    }

    /// `c` / `C` action: copy the selected turn's text to the system
    /// clipboard. Silently no-op when no clipboard is available
    /// (headless host); status bar reports the outcome either way.
    pub(super) fn chat_selection_copy(&mut self) {
        let Some(sel) = self.chat_selection else { return };
        let Some(turn) = self.chat_history.get(sel.turn) else { return };
        let text = match turn {
            ChatTurn::User(s) | ChatTurn::Assistant(s) => s.clone(),
        };
        match self.clipboard.as_mut() {
            Some(cb) => match cb.set_text(text.clone()) {
                Ok(()) => {
                    self.status = format!(
                        "copied {} turn ({} chars)",
                        self.chat_turn_label(sel.turn),
                        text.chars().count()
                    );
                }
                Err(e) => {
                    self.status = format!("clipboard copy failed: {e}");
                }
            },
            None => {
                self.status =
                    "no system clipboard available — copy unavailable on this host".into();
            }
        }
    }

    /// `t` / `T` action: insert the selected turn's text at the
    /// editor cursor. Useful when an Assistant reply is the right
    /// next paragraph or when a User question becomes the new
    /// prompt body. Requires an open paragraph in the editor.
    pub(super) fn chat_selection_into_editor(&mut self) {
        let Some(sel) = self.chat_selection else { return };
        let Some(turn) = self.chat_history.get(sel.turn) else { return };
        let text = match turn {
            ChatTurn::User(s) | ChatTurn::Assistant(s) => s.clone(),
        };
        let label = self.chat_turn_label(sel.turn);
        if self.opened.is_none() {
            self.status =
                "no paragraph open — switch off AI fullscreen (Ctrl+B K) and pick one".into();
            return;
        }
        if let Some(doc) = self.opened.as_mut() {
            doc.textarea.insert_str(&text);
            doc.dirty = true;
        }
        self.status = format!(
            "inserted {label} turn into editor ({} chars)",
            text.chars().count()
        );
    }

    /// Find every line index in the rendered chat-history pane
    /// whose text contains `query` (case-insensitive). Render runs
    /// against the same shape `draw_chat_history` produces so the
    /// indices map 1-1 to rendered rows.
    pub(super) fn chat_search_matches(&self, query: &str) -> Vec<usize> {
        if query.is_empty() {
            return Vec::new();
        }
        let needle = query.to_lowercase();
        let (lines, _) = self.build_chat_history_lines();
        let mut out: Vec<usize> = Vec::new();
        for (i, line) in lines.iter().enumerate() {
            let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
            if text.to_lowercase().contains(&needle) {
                out.push(i);
            }
        }
        out
    }

}
