//! 1.2.14+ Phase A.2 — Threads picker + weave view
//! sub-module of `tui::app`.
//!
//! `Ctrl+V Shift+H` opens the picker; `w` from
//! inside the picker pushes the weave view as a
//! sub-modal (the picker stored in `return_to` so
//! `Esc` pops back).
//!
//! All HJSON parsing happens at picker-open time
//! and is cached on `ThreadsPickerEntry`; the
//! weave-view grid is also pre-computed at modal-
//! open time so navigation is pure cursor math.
//!
//! See `Documentation/PROPOSALS/1.2.14_PLAN.md`
//! §3.3 for the design.

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::Deserialize;
use uuid::Uuid;

use crate::store::node::NodeKind;
use crate::store::SYSTEM_TAG_THREADS;

use super::super::input::TextInput;
use super::super::modal::{Modal, ThreadsPickerEntry};
use super::App;

/// Subset of thread HJSON fields the picker / weave
/// view need.  Mirrors `cli::thread::ThreadSummary`
/// but lives here so the TUI module doesn't reach
/// into the CLI crate just for a parse target.
#[derive(Debug, Default, Clone, Deserialize)]
struct ThreadBody {
    #[serde(default)]
    title: String,
    #[serde(default)]
    status: String,
    #[serde(default)]
    weight: String,
    #[serde(default)]
    tension: i32,
    #[serde(default)]
    characters: Vec<String>,
    #[serde(default)]
    places: Vec<String>,
}

/// 1.2.14+ Phase A.3 — a paragraph inside the audit
/// scope, materialised once at audit time so the
/// envelope composer doesn't re-read bodies.
struct ScopedParagraph {
    #[allow(dead_code)] // future-proof for "jump to paragraph from audit"
    id: Uuid,
    title: String,
    body: String,
    linked: Vec<Uuid>,
}

/// 1.2.14+ Phase A.3 — a structural concern pre-
/// computed before the LLM ever runs.  Surfaced
/// in the prompt as a "blind-spots pre-pass" so
/// the model has hard evidence to confirm or
/// refute rather than guessing from the prose
/// alone.
struct BlindSpot {
    thread_title: String,
    kind: BlindSpotKind,
}

enum BlindSpotKind {
    /// Thread has links project-wide but zero in
    /// the current scope.
    DormantInScope,
    /// Status `payoff` but no paragraph links to
    /// the thread anywhere.
    PayoffUnfired,
    /// Status past `setup` but no project-wide
    /// links.
    ZeroLinks,
}

impl App {
    /// 1.2.14+ Phase A.2 — `Ctrl+V Shift+H` handler.
    /// Walk the Threads system book subtree,
    /// materialise one `ThreadsPickerEntry` per
    /// paragraph (HJSON parse + reverse-link
    /// count), open the picker modal.
    pub(super) fn open_threads_picker(&mut self) {
        let Some(threads_root_id) =
            self.system_book_id(SYSTEM_TAG_THREADS)
        else {
            self.status = "threads picker: Threads system book missing — \
                           re-open the project to seed it"
                .into();
            return;
        };
        let entries = self.collect_thread_picker_entries(threads_root_id);
        if entries.is_empty() {
            self.status = "threads picker: no threads defined — \
                           run `inkhaven thread add <name>`"
                .into();
            return;
        }
        let visible: Vec<usize> = (0..entries.len()).collect();
        self.modal = Modal::ThreadsPicker {
            entries,
            cursor: 0,
            filter: TextInput::new(),
            filter_active: false,
            visible,
        };
        self.status =
            "↑↓ Enter open · Shift+Enter pin to secondary · w weave · / filter · Esc".into();
    }

    fn collect_thread_picker_entries(
        &self,
        threads_root_id: Uuid,
    ) -> Vec<ThreadsPickerEntry> {
        let mut out: Vec<ThreadsPickerEntry> = Vec::new();
        // Build the reverse-link tally in one pass
        // over the hierarchy so we don't pay
        // O(threads * paragraphs) for the per-
        // thread `link_count` field.
        let mut link_tally: std::collections::HashMap<Uuid, usize> =
            std::collections::HashMap::new();
        for node in self.hierarchy.iter() {
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            for target in &node.linked_paragraphs {
                *link_tally.entry(*target).or_insert(0) += 1;
            }
        }
        for id in self.hierarchy.collect_subtree(threads_root_id) {
            if id == threads_root_id {
                continue;
            }
            let Some(node) = self.hierarchy.get(id) else { continue; };
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            let body = match self.store.get_content(id) {
                Ok(Some(bytes)) => bytes,
                _ => continue,
            };
            let body_str = std::str::from_utf8(&body).unwrap_or("");
            let parsed: ThreadBody =
                serde_hjson::from_str(body_str).unwrap_or_default();
            let title_field = if parsed.title.trim().is_empty() {
                node.title.clone()
            } else {
                parsed.title.clone()
            };
            out.push(ThreadsPickerEntry {
                id,
                name: node.title.clone(),
                title_field,
                status: parsed.status,
                weight: parsed.weight,
                tension: parsed.tension,
                character_count: parsed.characters.len(),
                place_count: parsed.places.len(),
                link_count: link_tally.get(&id).copied().unwrap_or(0),
            });
        }
        // Canonical order: by paragraph order index.
        // `collect_subtree` already returns pre-
        // order; that matches the tree pane.  Keep.
        out
    }

    /// 1.2.14+ Phase A.2 — recompute `visible`
    /// indices after a filter edit.  Case-
    /// insensitive substring against name + title
    /// + status + weight.
    fn threads_picker_refilter(&mut self) {
        let Modal::ThreadsPicker {
            entries,
            cursor,
            filter,
            visible,
            ..
        } = &mut self.modal
        else {
            return;
        };
        let f = filter.as_str().to_lowercase();
        let f = f.trim();
        if f.is_empty() {
            *visible = (0..entries.len()).collect();
        } else {
            *visible = entries
                .iter()
                .enumerate()
                .filter(|(_, e)| {
                    e.name.to_lowercase().contains(f)
                        || e.title_field.to_lowercase().contains(f)
                        || e.status.to_lowercase().contains(f)
                        || e.weight.to_lowercase().contains(f)
                })
                .map(|(i, _)| i)
                .collect();
        }
        if *cursor >= visible.len() {
            *cursor = visible.len().saturating_sub(1);
        }
    }

    /// 1.2.14+ Phase A.2 — picker key handler.
    pub(super) fn threads_picker_handle_key(
        &mut self,
        key: KeyEvent,
    ) -> bool {
        let Modal::ThreadsPicker {
            entries,
            cursor,
            filter,
            filter_active,
            visible,
        } = &mut self.modal
        else {
            return false;
        };
        // ── filter-input mode ──────────────────
        if *filter_active {
            match key.code {
                KeyCode::Esc | KeyCode::Enter => {
                    *filter_active = false;
                    return true;
                }
                KeyCode::Backspace => {
                    filter.backspace();
                    self.threads_picker_refilter();
                    return true;
                }
                KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
                    filter.insert_char(c);
                    self.threads_picker_refilter();
                    return true;
                }
                _ => return true,
            }
        }
        // ── navigation mode ───────────────────
        let visible_len = visible.len();
        match key.code {
            KeyCode::Up => {
                if *cursor > 0 {
                    *cursor -= 1;
                }
                true
            }
            KeyCode::Down => {
                if *cursor + 1 < visible_len {
                    *cursor += 1;
                }
                true
            }
            KeyCode::Home => {
                *cursor = 0;
                true
            }
            KeyCode::End => {
                *cursor = visible_len.saturating_sub(1);
                true
            }
            KeyCode::Char('/') => {
                *filter_active = true;
                true
            }
            KeyCode::Esc => {
                self.modal = Modal::None;
                true
            }
            KeyCode::Enter => {
                let target_id = visible
                    .get(*cursor)
                    .and_then(|i| entries.get(*i))
                    .map(|e| e.id);
                let pin_to_secondary =
                    key.modifiers.contains(KeyModifiers::SHIFT);
                self.modal = Modal::None;
                if let Some(id) = target_id {
                    if pin_to_secondary {
                        let _ = self.pin_secondary_by_uuid(id);
                    } else if let Some(node) = self.hierarchy.get(id).cloned() {
                        let _ = self.load_paragraph(&node);
                    }
                }
                true
            }
            KeyCode::Char('w') => {
                self.open_thread_weave_view();
                true
            }
            _ => false,
        }
    }

    /// 1.2.14+ Phase A.2 — `w` from picker.
    /// Snapshot the current picker state into the
    /// new sub-modal's `return_to`; compute the
    /// chapter list + per-cell paragraph grid.
    fn open_thread_weave_view(&mut self) {
        let threads: Vec<ThreadsPickerEntry> = match &self.modal {
            Modal::ThreadsPicker { entries, .. } => entries.clone(),
            _ => return,
        };
        if threads.is_empty() {
            return;
        }
        // Every Chapter under every user book in
        // canonical order.  System books (Notes /
        // Threads / Language / etc.) excluded so
        // the weave shows only manuscript chapters.
        let mut chapters: Vec<(Uuid, String, String)> = Vec::new();
        for book in self.hierarchy.children_of(None) {
            if book.kind != NodeKind::Book {
                continue;
            }
            if book.system_tag.is_some() {
                continue;
            }
            for chapter in self.hierarchy.children_of(Some(book.id)) {
                if chapter.kind != NodeKind::Chapter {
                    continue;
                }
                chapters.push((
                    chapter.id,
                    book.title.clone(),
                    chapter.title.clone(),
                ));
            }
        }

        // Pre-compute grid.  For each chapter,
        // collect its subtree's paragraphs; for
        // each thread, count which of those
        // paragraphs links to it.
        let mut chapter_paragraphs: Vec<Vec<Uuid>> =
            Vec::with_capacity(chapters.len());
        for (chapter_id, _, _) in &chapters {
            let mut ids: Vec<Uuid> = 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 {
                    ids.push(id);
                }
            }
            chapter_paragraphs.push(ids);
        }
        let mut grid: Vec<Vec<Vec<Uuid>>> =
            Vec::with_capacity(threads.len());
        for thread in &threads {
            let mut row: Vec<Vec<Uuid>> = Vec::with_capacity(chapters.len());
            for ids in &chapter_paragraphs {
                let mut cell: Vec<Uuid> = Vec::new();
                for pid in ids {
                    if let Some(n) = self.hierarchy.get(*pid) {
                        if n.linked_paragraphs.contains(&thread.id) {
                            cell.push(*pid);
                        }
                    }
                }
                row.push(cell);
            }
            grid.push(row);
        }

        let return_to = Box::new(std::mem::replace(&mut self.modal, Modal::None));
        self.modal = Modal::ThreadWeaveView {
            threads,
            chapters,
            grid,
            cursor_row: 0,
            cursor_col: 0,
            scroll_row: 0,
            scroll_col: 0,
            return_to,
        };
        self.status =
            "weave: ↑↓ thread · ←→ chapter · Enter jump to ¶ · Esc back to picker".into();
    }

    /// 1.2.14+ Phase A.3 — `Ctrl+V Shift+A` handler.
    /// Resolves the audit scope from the F9 AiMode,
    /// composes the prompt envelope from every
    /// thread's HJSON + a blind-spots pre-pass +
    /// the scope's paragraph contents, then
    /// streams the response into the AI pane.
    ///
    /// Scope resolution:
    ///   * `AiMode::Book` / `Chapter` / `Subchapter`
    ///     → walk up from the cursor to find that
    ///     scope.
    ///   * `AiMode::Paragraph` / `Selection` /
    ///     `None` → fall back to the cursor's
    ///     containing Chapter (audits over a
    ///     single paragraph are too narrow to be
    ///     useful).
    pub(super) fn start_thread_audit(&mut self) {
        let Some(threads_root_id) = self.system_book_id(SYSTEM_TAG_THREADS)
        else {
            self.status = "thread audit: Threads system book missing".into();
            return;
        };
        let entries = self.collect_thread_picker_entries(threads_root_id);
        if entries.is_empty() {
            self.status =
                "thread audit: no threads defined — run `inkhaven thread add <name>`"
                    .into();
            return;
        }

        // Resolve scope.
        let scope_node = match self.resolve_thread_audit_scope() {
            Some(n) => n,
            None => {
                self.status = "thread audit: no scope under cursor (open a paragraph or place the tree cursor on a book/chapter)".into();
                return;
            }
        };

        // Collect scope content + blind-spots.
        let scope_paragraphs = self.collect_scope_paragraphs(scope_node.id);
        let blind_spots = self.compute_blind_spots(&entries, &scope_paragraphs);
        let thread_bodies = self.collect_thread_bodies_full(threads_root_id);

        let envelope = self.compose_thread_audit_prompt(
            &scope_node,
            &thread_bodies,
            &blind_spots,
            &scope_paragraphs,
        );

        let (model, _env_var) = match self
            .ai
            .resolve_provider(&self.cfg.llm, None)
        {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("thread audit: {e}");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        let rx = super::super::super::ai::stream::spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            None,
            Vec::new(),
            envelope,
            "thread-audit",
        );
        self.inference = Some(super::super::inference::Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: super::super::inference::InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        self.pending_chat_user_msg = None;
        self.change_focus(super::super::focus::Focus::Ai);
        self.status = format!(
            "thread audit ({}): streaming from {provider}...",
            scope_node.title
        );
    }

    /// Walk up from the cursor (open paragraph
    /// when there is one, otherwise the tree
    /// cursor's node) until we find a Book /
    /// Chapter / Subchapter matching the F9 AiMode.
    /// Falls back to the cursor's containing
    /// Chapter when AiMode is too narrow.
    fn resolve_thread_audit_scope(&self) -> Option<crate::store::node::Node> {
        use super::super::inference::AiMode;
        let starting_id = self
            .opened
            .as_ref()
            .map(|d| d.id)
            .or_else(|| self.rows.get(self.tree_cursor).map(|(id, _)| *id))?;
        let target = match self.ai_mode {
            AiMode::Book => Some(NodeKind::Book),
            AiMode::Chapter => Some(NodeKind::Chapter),
            AiMode::Subchapter => Some(NodeKind::Subchapter),
            // Paragraph / Selection / None — audit
            // over a single paragraph is too narrow;
            // walk up to the containing Chapter
            // instead.
            _ => Some(NodeKind::Chapter),
        };
        let mut cur = Some(starting_id);
        while let Some(id) = cur {
            let node = self.hierarchy.get(id)?;
            if Some(node.kind) == target {
                // Skip system-tagged books — auditing
                // Threads against the Threads book is
                // a noop.
                if node.system_tag.is_some() {
                    cur = node.parent_id;
                    continue;
                }
                return Some(node.clone());
            }
            cur = node.parent_id;
        }
        None
    }

    /// Collect every paragraph under `root` in
    /// pre-order, with body bytes + the existing
    /// outgoing-link UUIDs.  Filters out
    /// non-paragraph nodes (chapters, subchapters,
    /// images) so the audit only sees prose.
    fn collect_scope_paragraphs(&self, root: Uuid) -> Vec<ScopedParagraph> {
        let mut out: Vec<ScopedParagraph> = Vec::new();
        for id in self.hierarchy.collect_subtree(root) {
            let Some(node) = self.hierarchy.get(id) else { continue; };
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            let body = match self.store.get_content(id) {
                Ok(Some(b)) => b,
                _ => continue,
            };
            let body_str = std::str::from_utf8(&body)
                .unwrap_or("")
                .to_string();
            out.push(ScopedParagraph {
                id,
                title: node.title.clone(),
                body: body_str,
                linked: node.linked_paragraphs.clone(),
            });
        }
        out
    }

    /// Pre-compute the structural concerns the
    /// audit prompt feeds the LLM up front:
    ///   * Link counts (so the LLM doesn't have
    ///     to count manually).
    ///   * Threads marked `payoff` whose payoff
    ///     paragraph hasn't been linked.
    ///   * Threads with zero links in the scope
    ///     (dormant for this section of the
    ///     manuscript).
    fn compute_blind_spots(
        &self,
        threads: &[super::super::modal::ThreadsPickerEntry],
        scope_paragraphs: &[ScopedParagraph],
    ) -> Vec<BlindSpot> {
        let mut out: Vec<BlindSpot> = Vec::new();
        for t in threads {
            // Project-wide link count (cached on
            // the entry).
            let total_links = t.link_count;
            // In-scope link count.
            let in_scope: usize = scope_paragraphs
                .iter()
                .filter(|p| p.linked.contains(&t.id))
                .count();
            // Stale-in-scope check.
            if total_links > 0 && in_scope == 0 {
                out.push(BlindSpot {
                    thread_title: t.title_field.clone(),
                    kind: BlindSpotKind::DormantInScope,
                });
            }
            // Payoff-marked but unfired.
            if t.status.eq_ignore_ascii_case("payoff") && total_links == 0 {
                out.push(BlindSpot {
                    thread_title: t.title_field.clone(),
                    kind: BlindSpotKind::PayoffUnfired,
                });
            }
            // Brand-new thread with zero links
            // anywhere.
            if !t.status.eq_ignore_ascii_case("setup") && total_links == 0 {
                out.push(BlindSpot {
                    thread_title: t.title_field.clone(),
                    kind: BlindSpotKind::ZeroLinks,
                });
            }
        }
        out
    }

    /// Read every Thread paragraph's full HJSON
    /// body for inclusion in the prompt
    /// envelope.  Returns `(thread_title, body)`
    /// tuples in canonical order.
    fn collect_thread_bodies_full(
        &self,
        threads_root_id: Uuid,
    ) -> Vec<(String, String)> {
        let mut out: Vec<(String, String)> = Vec::new();
        for id in self.hierarchy.collect_subtree(threads_root_id) {
            if id == threads_root_id {
                continue;
            }
            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; };
            let Ok(body) = std::str::from_utf8(&bytes) else { continue; };
            out.push((node.title.clone(), body.to_string()));
        }
        out
    }

    fn compose_thread_audit_prompt(
        &self,
        scope_node: &crate::store::node::Node,
        thread_bodies: &[(String, String)],
        blind_spots: &[BlindSpot],
        scope_paragraphs: &[ScopedParagraph],
    ) -> String {
        let scope_kind = scope_node.kind.as_str();
        let scope_title = &scope_node.title;
        let blind_spots_text = if blind_spots.is_empty() {
            "(no structural blind spots detected)".to_string()
        } else {
            let mut s = String::new();
            for bs in blind_spots {
                let label = match bs.kind {
                    BlindSpotKind::DormantInScope =>
                        "DORMANT IN SCOPE — has links elsewhere but not in this scope",
                    BlindSpotKind::PayoffUnfired =>
                        "PAYOFF UNFIRED — status `payoff` but no paragraph links to it yet",
                    BlindSpotKind::ZeroLinks =>
                        "ZERO LINKS — status is past `setup` but no paragraph links to it anywhere",
                };
                s.push_str(&format!("  · {} — {}\n", bs.thread_title, label));
            }
            s
        };
        let threads_text = thread_bodies
            .iter()
            .map(|(title, body)| format!("── Thread: {title} ──\n{body}"))
            .collect::<Vec<_>>()
            .join("\n");
        let scope_paragraphs_text = scope_paragraphs
            .iter()
            .map(|p| {
                let linked = if p.linked.is_empty() {
                    "(no outgoing links)".to_string()
                } else {
                    format!("(links to: {})", p.linked.len())
                };
                format!(
                    "── Paragraph: {} {} ──\n{}",
                    p.title, linked, p.body
                )
            })
            .collect::<Vec<_>>()
            .join("\n\n");

        format!(
            "You are auditing a manuscript against a set of named plot threads.  \
             Each thread is an HJSON-fronted paragraph capturing one named narrative \
             arc with status (setup / develop / payoff / resolved / abandoned), weight \
             (major / subplot / runner / bridge), arc shape (opening / midpoint / \
             payoff), character + place connections, and tension.\n\
             \n\
             Your task: read the scope (a {scope_kind} titled `{scope_title}`) and \
             score each scope paragraph against the thread inventory.  Specifically:\n\
             \n\
             1. For each scope paragraph, list which threads it ADVANCES (moves toward \
                the next arc beat), which it TOUCHES INCIDENTALLY (mentions or echoes \
                without advancing), and which it SHOULD advance but doesn't (given the \
                thread's current status + connections).\n\
             2. Call out the structural blind spots pre-computed below.  Confirm or \
                refute each.\n\
             3. Flag any thread whose declared status looks wrong given the manuscript \
                evidence (e.g. status `payoff` but the in-scope payoff hasn't landed; \
                status `setup` but the thread has many incidental touches already).\n\
             4. End with a one-paragraph summary of the scope's narrative health: which \
                threads dominate, which are dormant, what's working, what's drifting.\n\
             \n\
             Be specific.  Reference paragraph titles when you make claims.  Don't \
             rewrite prose; analyse it.\n\
             \n\
             ── Thread inventory ──\n\
             {threads_text}\n\
             \n\
             ── Blind-spots pre-pass ──\n\
             {blind_spots_text}\n\
             \n\
             ── Scope: {scope_kind} `{scope_title}` ──\n\
             {scope_paragraphs_text}\n\
             ── end scope ──",
        )
    }

    /// 1.2.14+ Phase A.2 — weave view key handler.
    pub(super) fn thread_weave_handle_key(&mut self, key: KeyEvent) -> bool {
        let Modal::ThreadWeaveView {
            threads,
            chapters,
            grid,
            cursor_row,
            cursor_col,
            return_to,
            ..
        } = &mut self.modal
        else {
            return false;
        };
        let n_rows = threads.len();
        let n_cols = chapters.len();
        match key.code {
            KeyCode::Up => {
                if *cursor_row > 0 {
                    *cursor_row -= 1;
                }
                true
            }
            KeyCode::Down => {
                if *cursor_row + 1 < n_rows {
                    *cursor_row += 1;
                }
                true
            }
            KeyCode::Left => {
                if *cursor_col > 0 {
                    *cursor_col -= 1;
                }
                true
            }
            KeyCode::Right => {
                if *cursor_col + 1 < n_cols {
                    *cursor_col += 1;
                }
                true
            }
            KeyCode::Home => {
                *cursor_col = 0;
                true
            }
            KeyCode::End => {
                *cursor_col = n_cols.saturating_sub(1);
                true
            }
            KeyCode::Enter => {
                let target = grid
                    .get(*cursor_row)
                    .and_then(|row| row.get(*cursor_col))
                    .and_then(|cell| cell.first())
                    .copied();
                if let Some(id) = target {
                    if let Some(node) = self.hierarchy.get(id).cloned() {
                        self.modal = Modal::None;
                        let _ = self.load_paragraph(&node);
                    } else {
                        self.status = "weave: target paragraph vanished".into();
                    }
                } else {
                    self.status = "weave: this cell has no linking paragraph".into();
                }
                true
            }
            KeyCode::Esc => {
                let restored = std::mem::replace(return_to.as_mut(), Modal::None);
                self.modal = restored;
                true
            }
            _ => false,
        }
    }

    /// 1.2.14+ Phase D.4 — `Ctrl+V Shift+D` handler.
    /// Walk every thread + its reverse-link count;
    /// compute the same distributions + blind-
    /// spot lists `inkhaven thread doctor` prints,
    /// pop the modal.
    pub(super) fn open_thread_doctor(&mut self) {
        let Some(threads_root_id) =
            self.system_book_id(SYSTEM_TAG_THREADS)
        else {
            self.status = "thread doctor: Threads system book missing".into();
            return;
        };
        let data = self.compute_thread_doctor_data(threads_root_id);
        if data.thread_count == 0 {
            self.status =
                "thread doctor: no threads defined — `inkhaven thread add <name>` to start"
                    .into();
            return;
        }
        self.modal = Modal::ThreadDoctor { data };
        self.status = "thread doctor · Esc to close".into();
    }

    fn compute_thread_doctor_data(
        &self,
        threads_root_id: Uuid,
    ) -> super::super::modal::ThreadDoctorData {
        // Per-thread reverse-link tally (same shape
        // as the picker walker).
        let mut link_tally: std::collections::HashMap<Uuid, usize> =
            std::collections::HashMap::new();
        for node in self.hierarchy.iter() {
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            for target in &node.linked_paragraphs {
                *link_tally.entry(*target).or_insert(0) += 1;
            }
        }
        let mut status_counts: std::collections::BTreeMap<String, usize> =
            std::collections::BTreeMap::new();
        let mut weight_counts: std::collections::BTreeMap<String, usize> =
            std::collections::BTreeMap::new();
        let mut zero_links: Vec<String> = Vec::new();
        let mut payoff_unfired: Vec<String> = Vec::new();
        let mut dormant: Vec<String> = Vec::new();
        let mut tension_sum: i64 = 0;
        let mut tension_n = 0usize;
        let mut thread_count = 0usize;
        for id in self.hierarchy.collect_subtree(threads_root_id) {
            if id == threads_root_id {
                continue;
            }
            let Some(node) = self.hierarchy.get(id) else { continue; };
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            thread_count += 1;
            let body = match self.store.get_content(id) {
                Ok(Some(b)) => b,
                _ => continue,
            };
            let body_str = std::str::from_utf8(&body).unwrap_or("");
            let parsed: ThreadBody =
                serde_hjson::from_str(body_str).unwrap_or_default();
            let status_key = if parsed.status.is_empty() {
                "(empty)".to_string()
            } else {
                parsed.status.clone()
            };
            *status_counts.entry(status_key).or_insert(0) += 1;
            let weight_key = if parsed.weight.is_empty() {
                "(empty)".to_string()
            } else {
                parsed.weight.clone()
            };
            *weight_counts.entry(weight_key).or_insert(0) += 1;
            let display_name = if parsed.title.trim().is_empty() {
                node.title.clone()
            } else {
                parsed.title.clone()
            };
            let links = link_tally.get(&id).copied().unwrap_or(0);
            if links == 0 && !parsed.status.eq_ignore_ascii_case("setup") {
                zero_links.push(display_name.clone());
            }
            if parsed.status.eq_ignore_ascii_case("payoff") && links == 0 {
                payoff_unfired.push(display_name.clone());
            }
            if parsed.status.eq_ignore_ascii_case("develop") && links <= 1 {
                dormant.push(display_name.clone());
            }
            tension_sum += parsed.tension as i64;
            tension_n += 1;
        }
        let avg_tension = if tension_n > 0 {
            tension_sum as f32 / tension_n as f32
        } else {
            0.0
        };
        super::super::modal::ThreadDoctorData {
            thread_count,
            avg_tension,
            status_distribution: status_counts.into_iter().collect(),
            weight_distribution: weight_counts.into_iter().collect(),
            zero_links,
            payoff_unfired,
            dormant,
        }
    }

    /// 1.2.14+ Phase D.4 — modal key handler.
    /// Esc / Enter close.
    pub(super) fn thread_doctor_handle_key(&mut self, key: KeyEvent) -> bool {
        if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
            self.modal = Modal::None;
            return true;
        }
        true
    }
}
