//! The ratatui cinema — the `kranth` interactive shell.
//!
//! Boots the alternate screen with raw input, drives a single ratatui app,
//! exits cleanly on Ctrl-C / Esc. Two screens — `Splash` (idle prompt) and
//! `Cinema` (persona grid + reaction feed + sparklines). `tachyonfx::dissolve`
//! handles transitions. Terminal title bar animates with a Braille spinner +
//! current phase whenever a debate is running.
//!
//! v0.1.0 ships the visual end-to-end driven by a local `Simulator`
//! (see `simulator.rs`). When the SDK lands, we keep the same accessor
//! contract on `Simulator` and feed it from a tokio task draining live SSE.

mod live;
mod rain;
mod simulator;

use live::LiveDebate;
use rain::Rain;

use std::io::{self, Stdout, Write};
use std::time::Instant;

use anyhow::{Context, Result};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Sparkline, Wrap};
use ratatui::Frame;
use ratatui::Terminal;
use tachyonfx::{fx, Duration as FxDuration, Effect, EffectRenderer};
use tui_big_text::{BigText, PixelSize};

use crate::personas::{Archetype, ARCHETYPES};
use crate::theme::{Capability, Palette};

use simulator::{Phase, PersonaState, Reaction, Sentiment, Simulator, Verdict};

/// Sim-or-live state driver — the cinema doesn't care which one is feeding
/// it. Sim runs deterministically offline (cheap, no API call) and is what
/// `/demo` and `start` activate. Any real idea kicks off a `Live` driver that
/// talks to `api.kranth.com` over reqwest + SSE.
enum DebateState {
    Sim(Simulator),
    Live(LiveDebate),
}

impl DebateState {
    fn tick(&mut self, dt: std::time::Duration) {
        match self {
            Self::Sim(s) => s.tick(dt),
            Self::Live(l) => l.tick(dt),
        }
    }
    fn idea(&self) -> &str {
        match self {
            Self::Sim(s) => s.idea(),
            Self::Live(l) => l.idea(),
        }
    }
    fn phase(&self) -> Phase {
        match self {
            Self::Sim(s) => s.phase(),
            Self::Live(l) => l.phase(),
        }
    }
    fn elapsed(&self) -> std::time::Duration {
        match self {
            Self::Sim(s) => s.elapsed(),
            Self::Live(l) => l.elapsed(),
        }
    }
    fn personas(&self) -> [PersonaState; 10] {
        match self {
            Self::Sim(s) => *s.personas(),
            Self::Live(l) => l.personas(),
        }
    }
    fn reactions(&self) -> Vec<Reaction> {
        match self {
            Self::Sim(s) => s.reactions().to_vec(),
            Self::Live(l) => l.reactions(),
        }
    }
    fn hostile_history(&self) -> Vec<u64> {
        match self {
            Self::Sim(s) => s.hostile_history().to_vec(),
            Self::Live(l) => l.hostile_history().to_vec(),
        }
    }
    fn neutral_history(&self) -> Vec<u64> {
        match self {
            Self::Sim(s) => s.neutral_history().to_vec(),
            Self::Live(l) => l.neutral_history().to_vec(),
        }
    }
    fn aligned_history(&self) -> Vec<u64> {
        match self {
            Self::Sim(s) => s.aligned_history().to_vec(),
            Self::Live(l) => l.aligned_history().to_vec(),
        }
    }
    fn verdict(&self) -> Option<Verdict> {
        match self {
            Self::Sim(s) => s.verdict().copied(),
            Self::Live(l) => l.verdict(),
        }
    }
    /// Live-only: token text accumulated from `text.fragment` events. None in
    /// sim mode (the simulator renders structured reaction cards instead).
    fn streaming_text(&self) -> Option<String> {
        match self {
            Self::Sim(_) => None,
            Self::Live(l) => Some(l.streaming_text()),
        }
    }
    /// Live-only: synthesis summary fetched at debate.end.
    fn verdict_text(&self) -> Option<String> {
        match self {
            Self::Sim(_) => None,
            Self::Live(l) => l.verdict_text(),
        }
    }
    /// Live-only: surfaced when the API call fails (401, 402, 422, network).
    fn error(&self) -> Option<String> {
        match self {
            Self::Sim(_) => None,
            Self::Live(l) => l.error(),
        }
    }
    fn is_live(&self) -> bool {
        matches!(self, Self::Live(_))
    }
}

const FRAME_BUDGET_MS: u64 = 33;
const PROMPT_MAX_LEN: usize = 280;
const TITLE_SPINNER_MS: u128 = 130;

/// Braille spinner used in the terminal title bar.
const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

/// Canned idea the `/demo` (or `start`) command fires — picked because every
/// archetype has a clear position on it, so the social feed reads as a tight
/// debate rather than a wall of polite neutrals.
const DEMO_IDEA: &str =
    "Launch a $29/mo Rust devtool — bottom-up, no enterprise plan, day-one open core.";

pub fn run(api_url: &str, api_key: &str, account_label: &str) -> Result<()> {
    let mut term = setup_terminal().context("entering alternate screen")?;
    let mut app = App::new(
        api_url.to_string(),
        api_key.to_string(),
        account_label.to_string(),
    );

    let result = (|| -> Result<()> {
        let mut last_frame = Instant::now();
        loop {
            let now = Instant::now();
            let dt = now.duration_since(last_frame);
            last_frame = now;

            app.tick(dt);

            term.draw(|f| draw(f, &mut app, dt)).context("ratatui draw")?;

            if app.quit {
                break;
            }

            if event::poll(std::time::Duration::from_millis(FRAME_BUDGET_MS))? {
                match event::read()? {
                    Event::Key(key) if key.kind == KeyEventKind::Press => app.handle_key(key),
                    Event::Resize(_, _) => {}
                    _ => {}
                }
            }
        }
        Ok(())
    })();

    restore_terminal(&mut term);
    result
}

// ─── terminal setup / teardown ───────────────────────────────────────────

type Term = Terminal<CrosstermBackend<Stdout>>;

fn setup_terminal() -> Result<Term> {
    enable_raw_mode().context("enable raw mode")?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, SetTitle("Kranth")).context("enter alt screen")?;
    let backend = CrosstermBackend::new(stdout);
    Terminal::new(backend).context("init ratatui terminal")
}

fn restore_terminal(term: &mut Term) {
    // Reset the title to something neutral so the user's terminal doesn't
    // stay stuck on a half-completed phase string after we hand back.
    let _ = execute!(term.backend_mut(), SetTitle(""));
    let _ = disable_raw_mode();
    let _ = execute!(term.backend_mut(), LeaveAlternateScreen);
    let _ = term.show_cursor();
}

// ─── app state ───────────────────────────────────────────────────────────

// Sim and Live carry different working-set sizes (Live holds Arc<Mutex<…>> +
// a few growing Vecs vs Sim which is mostly stack arrays). The size delta
// triggers `clippy::large_enum_variant` but boxing one variant would force a
// heap-indirection on every accessor and offers no measurable benefit when
// we only ever own a single `Screen::Cinema { state }` at a time.
#[allow(clippy::large_enum_variant)]
enum Screen {
    Splash,
    Cinema {
        state: DebateState,
        bell_rung: bool,
        sweep_in: Option<Effect>,
    },
}

pub struct App {
    api_url: String,
    api_key: String,
    palette: Palette,
    account_label: String,
    screen: Screen,
    input: String,
    cursor: usize,
    quit: bool,
    transition: Option<Effect>,
    bell_enabled: bool,
    /// When true, the next debate is created with `voice_enabled = true`
    /// and `LiveDebate` auto-plays turn audio via the system player at
    /// completion. Persisted in `~/.kranth/config.toml`; flipped via
    /// `/voice on|off`.
    voice_enabled: bool,
    /// Debate mode used when the user submits an idea. Persisted in
    /// `~/.kranth/config.toml`; flipped via `/mode <m>` slash command.
    mode: String,
    spinner_idx: usize,
    last_title_update_ms: u128,
    elapsed_total_ms: u128,
    last_title_text: String,
    /// Transient status line under the prompt — shown for ~2s after a slash
    /// command lands, then auto-clears. Used for `/bell on`, `/mode panel`,
    /// `/personas` overlays, etc.
    status_message: Option<String>,
    status_until_ms: u128,
    /// `/personas` overlay state — `Some(state)` opens an arrow-nav card over
    /// the splash with the selected archetype expanded; `None` closed.
    personas_overlay: Option<PersonasOverlay>,
    /// Wall-clock ms of last keypress on splash. Drives the matrix-rain
    /// screensaver: any idle window past `IDLE_THRESHOLD_MS` activates it,
    /// the next keypress resets the timer + clears the screensaver.
    last_input_ms: u128,
    /// Matrix-rain backdrop state, present only while the screensaver is up.
    /// Lazily reconstructed when the terminal area changes.
    rain: Option<Rain>,
}

/// Spotlight state for `/personas`. Selection is an index into the
/// *filtered* archetype list (which equals all 10 when `filter.is_empty()`).
#[derive(Default)]
pub struct PersonasOverlay {
    pub selected: usize,
    pub filter: String,
}

impl PersonasOverlay {
    /// Archetype slugs/labels matching the current filter, in original order.
    /// Empty filter returns all 10 entries.
    pub fn matches(&self) -> Vec<usize> {
        if self.filter.is_empty() {
            return (0..crate::personas::ARCHETYPES.len()).collect();
        }
        let needle = self.filter.to_ascii_lowercase();
        crate::personas::ARCHETYPES
            .iter()
            .enumerate()
            .filter_map(|(i, a)| {
                if a.label.to_ascii_lowercase().contains(&needle)
                    || a.slug.to_ascii_lowercase().contains(&needle)
                {
                    Some(i)
                } else {
                    None
                }
            })
            .collect()
    }
}

#[cfg(test)]
mod overlay_tests {
    use super::PersonasOverlay;

    #[test]
    fn empty_filter_returns_all_ten() {
        let o = PersonasOverlay::default();
        assert_eq!(o.matches().len(), 10);
    }

    #[test]
    fn filter_by_label_substring() {
        let o = PersonasOverlay {
            selected: 0,
            filter: "tech".into(),
        };
        let m = o.matches();
        assert_eq!(m.len(), 1, "only Early Adopter / Tech should match");
    }

    #[test]
    fn filter_by_slug_substring() {
        let o = PersonasOverlay {
            selected: 0,
            filter: "invest".into(),
        };
        let m = o.matches();
        assert_eq!(m.len(), 1, "only investor_pattern_match should match");
    }

    #[test]
    fn case_insensitive() {
        let lower = PersonasOverlay { selected: 0, filter: "designer".into() };
        let upper = PersonasOverlay { selected: 0, filter: "DESIGNER".into() };
        let mixed = PersonasOverlay { selected: 0, filter: "DeSiGnEr".into() };
        assert_eq!(lower.matches(), upper.matches());
        assert_eq!(lower.matches(), mixed.matches());
    }

    #[test]
    fn nonsense_filter_returns_empty() {
        let o = PersonasOverlay {
            selected: 0,
            filter: "xqzqxqz".into(),
        };
        assert!(o.matches().is_empty());
    }
}

const STATUS_DURATION_MS: u128 = 2500;
const ALLOWED_MODES: &[&str] = &["1v1", "panel", "press", "pre-mortem", "team-debate"];

impl App {
    fn new(api_url: String, api_key: String, account_label: String) -> Self {
        let cfg = crate::config::Config::load().unwrap_or_default();
        let bell_enabled = std::env::var("KRANTH_BELL")
            .map(|v| matches!(v.as_str(), "1" | "true" | "on" | "yes"))
            .unwrap_or(cfg.bell);
        let voice_enabled = cfg.voice_by_default;
        let mode = cfg
            .mode
            .clone()
            .filter(|m| ALLOWED_MODES.contains(&m.as_str()))
            .unwrap_or_else(|| "panel".to_string());
        Self {
            api_url,
            api_key,
            palette: Palette::new(Capability::detect()),
            account_label,
            screen: Screen::Splash,
            input: String::new(),
            cursor: 0,
            quit: false,
            transition: None,
            bell_enabled,
            voice_enabled,
            mode,
            spinner_idx: 0,
            last_title_update_ms: 0,
            elapsed_total_ms: 0,
            last_title_text: "Kranth".to_string(),
            status_message: None,
            status_until_ms: 0,
            personas_overlay: None,
            last_input_ms: 0,
            rain: None,
        }
    }

    fn set_status(&mut self, msg: impl Into<String>) {
        self.status_message = Some(msg.into());
        self.status_until_ms = self.elapsed_total_ms + STATUS_DURATION_MS;
    }

    fn save_prefs(&self) {
        let cfg = crate::config::Config {
            mode: Some(self.mode.clone()),
            bell: self.bell_enabled,
            voice_by_default: self.voice_enabled,
            ..Default::default()
        };
        let _ = cfg.save();
    }

    /// Returns true when the input was consumed as a slash command. Caller
    /// (handle_splash_key Enter arm) should NOT treat it as an idea.
    fn handle_slash(&mut self, line: &str) -> bool {
        let trimmed = line.trim();
        if !trimmed.starts_with('/') {
            return false;
        }
        let body = &trimmed[1..];
        let mut parts = body.split_whitespace();
        let cmd = parts.next().unwrap_or("").to_ascii_lowercase();
        let arg = parts.next().unwrap_or("").to_ascii_lowercase();

        match cmd.as_str() {
            "help" | "h" | "?" => {
                self.set_status(
                    "type an idea  ·  /demo  ·  /mode <m>  ·  /bell on|off  ·  /personas  ·  /quit",
                );
            }
            "quit" | "exit" | "q" => {
                self.quit = true;
            }
            "bell" => match arg.as_str() {
                "on" | "true" | "1" | "yes" => {
                    self.bell_enabled = true;
                    self.save_prefs();
                    self.set_status("bell on — terminal bell on verdict drop");
                }
                "off" | "false" | "0" | "no" | "" => {
                    self.bell_enabled = false;
                    self.save_prefs();
                    self.set_status("bell off");
                }
                other => self.set_status(format!("/bell expects on|off (got {other:?})")),
            },
            "voice" => match arg.as_str() {
                "on" | "true" | "1" | "yes" => {
                    self.voice_enabled = true;
                    self.save_prefs();
                    self.set_status(
                        "voice on — completed debates auto-play turn audio via system player",
                    );
                }
                "off" | "false" | "0" | "no" | "" => {
                    self.voice_enabled = false;
                    self.save_prefs();
                    self.set_status("voice off");
                }
                other => self.set_status(format!("/voice expects on|off (got {other:?})")),
            },
            "mode" => {
                if arg.is_empty() {
                    self.set_status(format!(
                        "current mode: {}  ·  options: {}",
                        self.mode,
                        ALLOWED_MODES.join(" / ")
                    ));
                } else if ALLOWED_MODES.contains(&arg.as_str()) {
                    self.mode = arg.clone();
                    self.save_prefs();
                    self.set_status(format!("mode → {}", self.mode));
                } else {
                    self.set_status(format!(
                        "unknown mode {arg:?}  ·  pick one of: {}",
                        ALLOWED_MODES.join(" / ")
                    ));
                }
            }
            "personas" | "roster" => {
                self.personas_overlay = Some(PersonasOverlay::default());
                self.status_message = None;
            }
            "demo" | "start" => {
                // Caller already handles /demo via the existing idea path; we
                // mirror it here so /demo from this branch is identical.
                return false;
            }
            other => {
                self.set_status(format!("unknown command /{other} — try /help"));
            }
        }
        true
    }

    fn tick(&mut self, dt: std::time::Duration) {
        self.elapsed_total_ms = self.elapsed_total_ms.saturating_add(dt.as_millis());

        // Expire slash-command status line after its TTL.
        if self.status_message.is_some() && self.elapsed_total_ms >= self.status_until_ms {
            self.status_message = None;
        }

        // Matrix-rain idle screensaver — only on the splash, never during a
        // running cinema or with the personas overlay open. Once active the
        // animation ticks forward each frame.
        let on_splash = matches!(self.screen, Screen::Splash) && self.personas_overlay.is_none();
        let idle_ms = self.elapsed_total_ms.saturating_sub(self.last_input_ms);
        if on_splash && rain::is_idle(idle_ms) {
            if let Some(rain) = self.rain.as_mut() {
                rain.tick(dt.as_millis());
            }
        } else if !on_splash {
            // Wake / cinema kills the screensaver immediately.
            self.rain = None;
        }

        if let Screen::Cinema {
            state,
            bell_rung,
            sweep_in,
        } = &mut self.screen
        {
            let before = state.phase();
            state.tick(dt);
            let after = state.phase();

            if before != Phase::VerdictReveal && after == Phase::VerdictReveal {
                if self.bell_enabled && !*bell_rung {
                    let _ = io::stdout().write_all(b"\x07");
                    let _ = io::stdout().flush();
                    *bell_rung = true;
                }
                *sweep_in = Some(fx::dissolve(FxDuration::from_millis(500)));
            }
        }

        // Animate the terminal title bar at ~7 Hz so multitasking users get
        // a tick of progress in their window list / dock without yanking
        // focus from the actual TUI.
        if self
            .elapsed_total_ms
            .saturating_sub(self.last_title_update_ms)
            >= TITLE_SPINNER_MS
        {
            self.last_title_update_ms = self.elapsed_total_ms;
            self.spinner_idx = (self.spinner_idx + 1) % SPINNER.len();
            let next_title = self.compute_title();
            if next_title != self.last_title_text {
                let mut stdout = io::stdout();
                let _ = execute!(stdout, SetTitle(&*next_title));
                let _ = stdout.flush();
                self.last_title_text = next_title;
            }
        }
    }

    fn compute_title(&self) -> String {
        match &self.screen {
            Screen::Splash => "Kranth".to_string(),
            Screen::Cinema { state, .. } => {
                let sp = SPINNER[self.spinner_idx];
                match state.phase() {
                    Phase::Waking => format!("Kranth {sp} waking personas"),
                    Phase::Streaming => format!("Kranth {sp} reactions"),
                    Phase::VerdictReveal => "Kranth · verdict drop".to_string(),
                    Phase::Done => state
                        .verdict()
                        .map(|v| {
                            if state.is_live() {
                                "Kranth · complete".to_string()
                            } else {
                                format!("Kranth · {}% aligned", v.aligned_pct)
                            }
                        })
                        .unwrap_or_else(|| "Kranth · complete".to_string()),
                }
            }
        }
    }

    fn handle_key(&mut self, key: KeyEvent) {
        // Any keypress wakes the screensaver and resets the idle timer.
        self.last_input_ms = self.elapsed_total_ms;
        if self.rain.is_some() {
            self.rain = None;
            // First key after the screensaver dismisses it; only act on
            // navigation/exit chords — character inserts feel jarring as
            // the wake key.
            if matches!(key.code, KeyCode::Char(_)) && !key.modifiers.contains(KeyModifiers::CONTROL) {
                return;
            }
        }
        if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
            self.quit = true;
            return;
        }
        match self.screen {
            Screen::Splash => self.handle_splash_key(key),
            Screen::Cinema { .. } => self.handle_cinema_key(key),
        }
    }

    fn handle_splash_key(&mut self, key: KeyEvent) {
        // `/personas` overlay owns nav + filter input. Type to filter
        // (atuin-style), Up/Down/j/k to navigate matches, Esc to dismiss.
        if let Some(overlay) = self.personas_overlay.as_mut() {
            match key.code {
                KeyCode::Esc => {
                    self.personas_overlay = None;
                    return;
                }
                KeyCode::Up => {
                    let matches = overlay.matches();
                    if !matches.is_empty() {
                        let n = matches.len();
                        overlay.selected = (overlay.selected + n - 1) % n;
                    }
                    return;
                }
                KeyCode::Down => {
                    let matches = overlay.matches();
                    if !matches.is_empty() {
                        overlay.selected = (overlay.selected + 1) % matches.len();
                    }
                    return;
                }
                KeyCode::PageUp | KeyCode::Home => {
                    overlay.selected = 0;
                    return;
                }
                KeyCode::PageDown | KeyCode::End => {
                    let n = overlay.matches().len();
                    overlay.selected = n.saturating_sub(1);
                    return;
                }
                KeyCode::Enter => {
                    self.personas_overlay = None;
                    return;
                }
                KeyCode::Backspace => {
                    overlay.filter.pop();
                    overlay.selected = 0;
                    return;
                }
                // j/k bindings only fire as nav when the filter is empty —
                // once you start typing they're characters, not vim nav.
                KeyCode::Char('j') if overlay.filter.is_empty() => {
                    let n = overlay.matches().len();
                    if n > 0 {
                        overlay.selected = (overlay.selected + 1) % n;
                    }
                    return;
                }
                KeyCode::Char('k') if overlay.filter.is_empty() => {
                    let n = overlay.matches().len();
                    if n > 0 {
                        overlay.selected = (overlay.selected + n - 1) % n;
                    }
                    return;
                }
                KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
                    overlay.filter.push(c);
                    overlay.selected = 0;
                    return;
                }
                _ => {}
            }
        }

        match key.code {
            KeyCode::Esc => {
                if self.input.is_empty() {
                    self.quit = true;
                } else {
                    self.input.clear();
                    self.cursor = 0;
                }
            }
            KeyCode::Enter => {
                let trimmed = self.input.trim().to_string();
                if trimmed.is_empty() {
                    return;
                }
                // Slash commands first — `/bell`, `/mode`, `/personas`, `/help`,
                // `/quit` are handled in-place without launching a debate.
                // `/demo` and `/start` flow through to the cinema below.
                let lower = trimmed.to_ascii_lowercase();
                let is_demo = matches!(lower.as_str(), "/demo" | "demo" | "start");
                if !is_demo && self.handle_slash(&trimmed) {
                    self.input.clear();
                    self.cursor = 0;
                    return;
                }
                let idea = if is_demo {
                    DEMO_IDEA.to_string()
                } else {
                    trimmed
                };
                self.input.clear();
                self.cursor = 0;
                self.transition = Some(fx::dissolve(FxDuration::from_millis(420)));
                // `/demo` keeps the deterministic local simulator (cheap, no
                // API call, always succeeds). Any other input hits the live
                // backend through `LiveDebate`.
                let state = if is_demo {
                    DebateState::Sim(Simulator::new(idea))
                } else {
                    DebateState::Live(LiveDebate::new(
                        self.api_url.clone(),
                        self.api_key.clone(),
                        idea,
                        self.mode.clone(),
                        self.voice_enabled,
                    ))
                };
                self.screen = Screen::Cinema {
                    state,
                    bell_rung: false,
                    sweep_in: None,
                };
            }
            KeyCode::Backspace => {
                if self.cursor > 0 && !self.input.is_empty() {
                    self.cursor -= 1;
                    self.input.remove(self.cursor);
                }
            }
            KeyCode::Delete => {
                if self.cursor < self.input.len() {
                    self.input.remove(self.cursor);
                }
            }
            KeyCode::Left => self.cursor = self.cursor.saturating_sub(1),
            KeyCode::Right => self.cursor = (self.cursor + 1).min(self.input.len()),
            KeyCode::Home => self.cursor = 0,
            KeyCode::End => self.cursor = self.input.len(),
            KeyCode::Char(c) => {
                if self.input.len() < PROMPT_MAX_LEN {
                    self.input.insert(self.cursor, c);
                    self.cursor += c.len_utf8();
                }
            }
            _ => {}
        }
    }

    fn handle_cinema_key(&mut self, key: KeyEvent) {
        match key.code {
            KeyCode::Esc | KeyCode::Char('q') => {
                self.transition = Some(fx::dissolve(FxDuration::from_millis(300)));
                self.screen = Screen::Splash;
            }
            _ => {}
        }
    }
}

// ─── top-level draw ──────────────────────────────────────────────────────

fn draw(f: &mut Frame, app: &mut App, dt: std::time::Duration) {
    let area = f.area();

    // Matrix-rain backdrop — drawn first so the splash widgets sit on top.
    // Only active on Splash + past the idle threshold + with no overlay.
    let on_splash = matches!(app.screen, Screen::Splash) && app.personas_overlay.is_none();
    let idle_ms = app.elapsed_total_ms.saturating_sub(app.last_input_ms);
    if on_splash && rain::is_idle(idle_ms) {
        if app.rain.is_none() {
            app.rain = Some(Rain::new(area));
        }
        if let Some(rain) = app.rain.as_ref() {
            let palette = &app.palette;
            rain.render(f.buffer_mut(), area, palette);
        }
    }

    match &app.screen {
        Screen::Splash => draw_splash(f, area, app),
        Screen::Cinema { state, .. } => draw_cinema(f, area, &app.palette, state),
    }

    if let Screen::Cinema { state, .. } = &app.screen {
        if matches!(state.phase(), Phase::VerdictReveal | Phase::Done) {
            draw_verdict_reveal(f, area, &app.palette, state);
        }
    }

    let fx_dt = FxDuration::from_millis(dt.as_millis().min(u128::from(u32::MAX)) as u32);
    if let Screen::Cinema { sweep_in, .. } = &mut app.screen {
        if let Some(effect) = sweep_in.as_mut() {
            f.render_effect(effect, area, fx_dt);
            if effect.done() {
                *sweep_in = None;
            }
        }
    }
    if let Some(effect) = app.transition.as_mut() {
        f.render_effect(effect, area, fx_dt);
        if effect.done() {
            app.transition = None;
        }
    }
}

// ─── splash screen — banner + idle prompt ────────────────────────────────

fn draw_splash(f: &mut Frame, area: Rect, app: &App) {
    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(8),
            Constraint::Length(4),
            Constraint::Min(0),
            Constraint::Length(3),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(area);

    f.render_widget(banner_paragraph(&app.palette), layout[0]);
    f.render_widget(subtitle_paragraph(&app.palette, &app.account_label, &app.mode), layout[1]);
    f.render_widget(prompt_paragraph(&app.palette, &app.input), layout[3]);
    f.render_widget(help_line_splash(&app.palette), layout[4]);
    f.render_widget(status_line(&app.palette, app.status_message.as_deref()), layout[5]);

    let prompt_area = layout[3];
    let inner = prompt_area.inner(Margin {
        vertical: 1,
        horizontal: 3,
    });
    let cursor_x = inner.x.saturating_add(app.cursor as u16);
    if cursor_x < inner.x + inner.width {
        f.set_cursor_position((cursor_x, inner.y));
    }

    // Overlay last so it draws on top of the splash without disturbing it.
    if let Some(overlay) = app.personas_overlay.as_ref() {
        draw_personas_overlay(f, area, &app.palette, overlay);
    }
}

/// Atuin-style spotlight overlay for the 10 archetypes. Up / Down + j/k
/// navigate the filtered list; Backspace pops the filter; typing characters
/// narrows the list (case-insensitive substring on label OR slug).
/// PageUp / PageDown jump to ends; Esc dismisses; Enter closes.
fn draw_personas_overlay(f: &mut Frame, area: Rect, palette: &Palette, overlay: &PersonasOverlay) {
    let card_w = area.width.saturating_sub(8).clamp(56, 100);
    let card_h = (crate::personas::ARCHETYPES.len() as u16 + 9).min(area.height.saturating_sub(2));
    let card_area = centered_rect(area, card_w, card_h);

    let matches = overlay.matches();
    let selected_clamped = if matches.is_empty() {
        0
    } else {
        overlay.selected.min(matches.len() - 1)
    };
    let selected_arch_idx = matches.get(selected_clamped).copied();

    // Backing card — solid border + canvas fill so the overlay looks like a
    // proper modal floating over the splash.
    f.render_widget(
        Block::default()
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded)
            .border_style(Style::default().fg(palette.text_primary()))
            .style(Style::default().bg(palette.canvas()))
            .title(Span::styled(
                " personas ",
                Style::default()
                    .fg(palette.text_muted())
                    .add_modifier(Modifier::DIM),
            )),
        card_area,
    );

    let inner = card_area.inner(Margin {
        vertical: 1,
        horizontal: 2,
    });

    // Header + filter row + body + footer.
    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Min(0),
            Constraint::Length(1),
        ])
        .split(inner);

    let header_text = format!(
        "  {} of {} · type to filter · ↑/↓ to navigate · Esc to dismiss",
        matches.len(),
        crate::personas::ARCHETYPES.len(),
    );
    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            header_text,
            Style::default()
                .fg(palette.text_muted())
                .add_modifier(Modifier::DIM),
        ))),
        layout[0],
    );

    // Filter input row — shows `▌ <filter>_` or `▌ (no filter)` placeholder.
    let filter_line = if overlay.filter.is_empty() {
        Line::from(vec![
            Span::styled("  ▌  ", palette.ghost_style()),
            Span::styled(
                "(no filter — try typing 'tech' or 'invest')",
                Style::default()
                    .fg(palette.text_ghost())
                    .add_modifier(Modifier::DIM | Modifier::ITALIC),
            ),
        ])
    } else {
        Line::from(vec![
            Span::styled(
                "  ▌  ",
                Style::default()
                    .fg(palette.reaction_positive())
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                overlay.filter.clone(),
                palette.primary_style().add_modifier(Modifier::BOLD),
            ),
            Span::styled("_", palette.muted_style().add_modifier(Modifier::DIM)),
        ])
    };
    f.render_widget(Paragraph::new(filter_line), layout[1]);
    // Blank spacer row.

    // Two columns: list on left (28 cols), detail on right (rest).
    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Length(28), Constraint::Min(0)])
        .split(layout[3]);

    // Left: filtered list with selection caret.
    let mut list_lines: Vec<Line<'static>> = Vec::with_capacity(matches.len().max(1));
    if matches.is_empty() {
        list_lines.push(Line::from(Span::styled(
            "  no matches",
            Style::default()
                .fg(palette.text_ghost())
                .add_modifier(Modifier::ITALIC),
        )));
    } else {
        for (filtered_idx, &arch_idx) in matches.iter().enumerate() {
            let arch = &crate::personas::ARCHETYPES[arch_idx];
            let active = filtered_idx == selected_clamped;
            let prefix = if active { "▌ " } else { "  " };
            let style = if active {
                palette.primary_style().add_modifier(Modifier::BOLD)
            } else {
                palette.muted_style()
            };
            list_lines.push(Line::from(vec![
                Span::styled(
                    prefix,
                    Style::default()
                        .fg(palette.reaction_positive())
                        .add_modifier(if active { Modifier::BOLD } else { Modifier::DIM }),
                ),
                Span::styled(format!("{:<22}", arch.label), style),
            ]));
        }
    }
    f.render_widget(Paragraph::new(Text::from(list_lines)), cols[0]);

    // Right: detail card for the selected archetype.
    if let Some(arch) = selected_arch_idx.and_then(|i| crate::personas::ARCHETYPES.get(i)) {
        let mut detail: Vec<Line<'static>> = Vec::new();
        detail.push(Line::from(Span::styled(
            arch.label.to_string(),
            palette.primary_style().add_modifier(Modifier::BOLD),
        )));
        detail.push(Line::from(Span::styled(
            format!("slug: {}", arch.slug),
            Style::default()
                .fg(palette.text_muted())
                .add_modifier(Modifier::DIM),
        )));
        detail.push(Line::from(""));
        for row in arch.glyph {
            detail.push(Line::from(Span::styled(
                (*row).to_string(),
                palette.primary_style(),
            )));
        }
        detail.push(Line::from(""));
        detail.push(Line::from(Span::styled(
            "Sample reaction:",
            Style::default()
                .fg(palette.text_muted())
                .add_modifier(Modifier::DIM),
        )));
        for line in wrap_body(arch.reaction, (cols[1].width as usize).saturating_sub(4).max(20)) {
            detail.push(Line::from(vec![
                Span::styled("  ", palette.ghost_style()),
                Span::styled(line, palette.muted_style()),
            ]));
        }
        f.render_widget(
            Paragraph::new(Text::from(detail)).wrap(Wrap { trim: false }),
            cols[1],
        );
    }

    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            "  Enter to close",
            Style::default()
                .fg(palette.text_ghost())
                .add_modifier(Modifier::DIM),
        ))),
        layout[3],
    );
}

fn status_line<'a>(palette: &Palette, msg: Option<&'a str>) -> Paragraph<'a> {
    match msg {
        Some(text) => Paragraph::new(Line::from(Span::styled(
            format!("  {text}"),
            palette.primary_style().add_modifier(Modifier::BOLD),
        ))),
        None => Paragraph::new(""),
    }
}

/// Splash lockup: 3×3 brand glyph on the left, KRANTH wordmark on the right,
/// both 6 rows tall. Glyph tiles are 4 cells wide × 2 rows tall — terminal
/// cells are ~2:1 portrait, so this keeps the glyph looking square.
fn banner_paragraph(palette: &Palette) -> Paragraph<'static> {
    let solid_style = palette.primary_style().add_modifier(Modifier::BOLD);
    let dim_style = Style::default()
        .fg(palette.text_ghost())
        .add_modifier(Modifier::DIM);

    // Per-row glyph cells: each cell is "████" (solid), "░░░░" (dim) or "    " (empty).
    // The KRANTH pattern: dim-top-left + solid-top-right, solid-middle,
    // solid-bottom-left + dim-bottom-right.
    let solid = || Span::styled("████", solid_style);
    let dim = || Span::styled("░░░░", dim_style);
    let empty = || Span::raw("    ");
    let gap = || Span::raw("  ");
    let lockup_gap = || Span::raw("    ");

    // 6 banner rows of the existing KRANTH wordmark.
    let banner_rows: [&'static str; 6] = [
        "██╗  ██╗██████╗  █████╗ ███╗   ██╗████████╗██╗  ██╗",
        "██║ ██╔╝██╔══██╗██╔══██╗████╗  ██║╚══██╔══╝██║  ██║",
        "█████╔╝ ██████╔╝███████║██╔██╗ ██║   ██║   ███████║",
        "██╔═██╗ ██╔══██╗██╔══██║██║╚██╗██║   ██║   ██╔══██║",
        "██║  ██╗██║  ██║██║  ██║██║ ╚████║   ██║   ██║  ██║",
        "╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═══╝   ╚═╝   ╚═╝  ╚═╝",
    ];

    // 6-row glyph: each tile is 2 rows tall, so the 3 tile-rows pair up as
    // (banner rows 0-1) ← top tiles, (rows 2-3) ← middle tile, (rows 4-5)
    // ← bottom tiles.
    let glyph_rows: [[Span<'static>; 5]; 6] = [
        // Top tiles, row 0: dim, empty, solid
        [dim(), gap(), empty(), gap(), solid()],
        [dim(), gap(), empty(), gap(), solid()],
        // Middle tile, row 0
        [empty(), gap(), solid(), gap(), empty()],
        [empty(), gap(), solid(), gap(), empty()],
        // Bottom tiles, row 0: solid, empty, dim
        [solid(), gap(), empty(), gap(), dim()],
        [solid(), gap(), empty(), gap(), dim()],
    ];

    let mut lines: Vec<Line<'static>> = Vec::with_capacity(8);
    lines.push(Line::from(""));
    for (i, banner_row) in banner_rows.iter().enumerate() {
        let mut spans: Vec<Span<'static>> = glyph_rows[i].to_vec();
        spans.push(lockup_gap());
        spans.push(Span::styled(*banner_row, solid_style));
        lines.push(Line::from(spans).alignment(Alignment::Center));
    }
    lines.push(Line::from(""));

    Paragraph::new(Text::from(lines))
}

fn subtitle_paragraph(palette: &Palette, account: &str, mode: &str) -> Paragraph<'static> {
    let lines = vec![
        Line::from(Span::styled(
            "Stress-test ideas against adversarial AI personas.",
            palette.muted_style(),
        ))
        .alignment(Alignment::Center),
        Line::from(Span::styled(
            format!("{account}  ·  {mode} mode  ·  type /demo to see a full run"),
            palette.ghost_style(),
        ))
        .alignment(Alignment::Center),
    ];
    Paragraph::new(Text::from(lines))
}

fn prompt_paragraph<'a>(palette: &Palette, input: &'a str) -> Paragraph<'a> {
    let inner = Span::styled(input, palette.primary_style());
    Paragraph::new(Line::from(inner)).block(
        Block::default()
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded)
            .border_style(Style::default().fg(palette.text_muted()))
            .padding(Padding::horizontal(2))
            .title(Span::styled(
                " idea ",
                Style::default()
                    .fg(palette.text_muted())
                    .add_modifier(Modifier::DIM),
            )),
    )
}

fn help_line_splash(palette: &Palette) -> Paragraph<'static> {
    Paragraph::new(Line::from(Span::styled(
        "  Enter to run  ·  /demo for a canned walkthrough  ·  Esc to clear  ·  Ctrl-C to quit",
        Style::default()
            .fg(palette.text_ghost())
            .add_modifier(Modifier::DIM),
    )))
}

// ─── cinema screen — header + persona grid + reaction feed + sparklines ──

fn draw_cinema(f: &mut Frame, area: Rect, palette: &Palette, state: &DebateState) {
    let outer = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(5), // KRANTH small logo + status row + bottom rule
            Constraint::Min(0),    // body
            Constraint::Length(1), // help line
        ])
        .split(area);

    render_cinema_header(f, outer[0], palette, state);

    let body = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
        .split(outer[1]);

    render_persona_grid(f, body[0], palette, state);
    render_verdict_pane(f, body[1], palette, state);

    f.render_widget(help_line_cinema(palette), outer[2]);
}

fn render_cinema_header(f: &mut Frame, area: Rect, palette: &Palette, state: &DebateState) {
    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(4), Constraint::Length(1)])
        .split(area);

    // Three-column header lockup: brand glyph (3×3 ascending-diagonal grid,
    // matching `web/public/icon-source.svg`) · wordmark (BigText "KRANTH")
    // · status info (idea + phase + timer). Glyph and word read as one
    // logo unit; status sits off to the right.
    let row = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Length(13),
            Constraint::Length(28),
            Constraint::Min(0),
        ])
        .split(layout[0]);

    f.render_widget(render_kranth_glyph(palette), row[0]);

    let wordmark = BigText::builder()
        .pixel_size(PixelSize::Quadrant)
        .style(palette.primary_style().add_modifier(Modifier::BOLD))
        .lines(vec![Line::from("KRANTH")])
        .build();
    f.render_widget(wordmark, row[1]);

    let phase_label = match state.phase() {
        Phase::Waking => "waking personas",
        Phase::Streaming => "personas reacting",
        Phase::VerdictReveal => "verdict drop",
        Phase::Done => "complete",
    };
    let elapsed_s = state.elapsed().as_millis() as f32 / 1000.0;
    let status_lines = vec![
        Line::from(""),
        Line::from(vec![
            Span::styled(
                "  stress-testing  ",
                Style::default()
                    .fg(palette.text_muted())
                    .add_modifier(Modifier::DIM),
            ),
            Span::styled(truncate(state.idea(), 70), palette.primary_style()),
        ]),
        Line::from(vec![
            Span::styled(
                "  ",
                Style::default(),
            ),
            Span::styled(
                phase_label,
                Style::default()
                    .fg(palette.text_muted())
                    .add_modifier(Modifier::DIM),
            ),
            Span::styled("    ", Style::default()),
            Span::styled(
                format!("{elapsed_s:.1}s"),
                Style::default()
                    .fg(palette.text_ghost())
                    .add_modifier(Modifier::DIM),
            ),
        ]),
    ];
    f.render_widget(Paragraph::new(Text::from(status_lines)), row[2]);

    // Subtle bottom rule under the whole header.
    f.render_widget(
        Block::default()
            .borders(Borders::BOTTOM)
            .border_style(Style::default().fg(palette.text_ghost())),
        layout[1],
    );
}

fn render_persona_grid(f: &mut Frame, area: Rect, palette: &Palette, state: &DebateState) {
    let block = Block::default()
        .borders(Borders::NONE)
        .padding(Padding::new(2, 2, 1, 0));
    let inner = block.inner(area);
    f.render_widget(block, area);

    let mut lines: Vec<Line<'static>> = Vec::with_capacity(60);
    lines.push(Line::from(Span::styled(
        "personas",
        Style::default()
            .fg(palette.text_muted())
            .add_modifier(Modifier::DIM),
    )));
    lines.push(Line::from(""));

    let personas = state.personas();

    for (row_idx, row) in ARCHETYPES.chunks(2).enumerate() {
        let left = personas.get(row_idx * 2);
        let right = personas.get(row_idx * 2 + 1);

        for r in 0..5 {
            let mut spans: Vec<Span<'static>> = Vec::with_capacity(4);
            spans.extend(cell_glyph_row(palette, row.first(), left.copied(), r));
            spans.push(Span::raw("   "));
            if let Some(arch) = row.get(1) {
                spans.extend(cell_glyph_row(palette, Some(arch), right.copied(), r));
            }
            lines.push(Line::from(spans));
        }

        let mut label_spans: Vec<Span<'static>> = Vec::with_capacity(6);
        label_spans.push(label_with_dot(palette, row.first(), left.copied()));
        label_spans.push(Span::raw("   "));
        if let Some(arch) = row.get(1) {
            label_spans.push(label_with_dot(palette, Some(arch), right.copied()));
        }
        lines.push(Line::from(label_spans));
        lines.push(Line::from(""));
    }

    f.render_widget(Paragraph::new(Text::from(lines)), inner);
}

fn cell_glyph_row(
    palette: &Palette,
    archetype: Option<&Archetype>,
    state: Option<simulator::PersonaState>,
    row: usize,
) -> Vec<Span<'static>> {
    let Some(arch) = archetype else {
        // Matches the 10-char-wide emblem glyphs in `personas.rs`.
        return vec![Span::raw("          ")];
    };
    let line = arch.glyph.get(row).copied().unwrap_or("");
    let style = match state.and_then(|s| s.activated_at_ms.map(|_| s.sentiment)) {
        // Only sentiment leaks colour — chrome stays mono. Aligned personas
        // glow green, neutral come up bright-white, hostile stay restrained.
        Some(Sentiment::Aligned) => Style::default().fg(palette.reaction_positive()),
        Some(Sentiment::Neutral) => palette.primary_style(),
        Some(Sentiment::Hostile) => Style::default()
            .fg(palette.text_muted())
            .add_modifier(Modifier::BOLD),
        None => Style::default()
            .fg(palette.text_ghost())
            .add_modifier(Modifier::DIM),
    };
    vec![Span::styled(line.to_string(), style)]
}

fn label_with_dot(
    palette: &Palette,
    archetype: Option<&Archetype>,
    state: Option<simulator::PersonaState>,
) -> Span<'static> {
    let Some(arch) = archetype else {
        return Span::raw("           ");
    };
    let label = truncate(arch.label, 18);
    let dot = match state.and_then(|s| s.activated_at_ms.map(|_| s.sentiment)) {
        Some(Sentiment::Aligned) => "●",
        Some(Sentiment::Neutral) => "○",
        Some(Sentiment::Hostile) => "×",
        None => "·",
    };
    let composed = format!("{dot}  {label}");
    Span::styled(
        composed,
        if state.and_then(|s| s.activated_at_ms).is_some() {
            palette.primary_style()
        } else {
            palette.muted_style()
        },
    )
}

fn render_verdict_pane(f: &mut Frame, area: Rect, palette: &Palette, state: &DebateState) {
    let block = Block::default()
        .borders(Borders::LEFT)
        .border_style(Style::default().fg(palette.text_ghost()))
        .padding(Padding::new(2, 1, 1, 0));
    let inner = block.inner(area);
    f.render_widget(block, area);

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(0), Constraint::Length(7)])
        .split(inner);

    render_reaction_feed(f, layout[0], palette, state);
    render_sentiment_bars(f, layout[1], palette, state);
}

fn render_reaction_feed(f: &mut Frame, area: Rect, palette: &Palette, state: &DebateState) {
    let block = Block::default()
        .borders(Borders::NONE)
        .padding(Padding::new(0, 0, 0, 0));
    let inner = block.inner(area);
    f.render_widget(block, area);

    // Live-mode short-circuit: an API error reads as a single dim card so the
    // user has a clear path back. Suppresses the rest of the feed render.
    if let Some(err) = state.error() {
        let max_cols = area.width.saturating_sub(4) as usize;
        let mut lines = vec![
            Line::from(Span::styled(
                "couldn’t reach api.kranth.com",
                Style::default()
                    .fg(palette.text_primary())
                    .add_modifier(Modifier::BOLD),
            )),
            Line::from(""),
        ];
        for body_row in wrap_body(&err, max_cols.max(20)) {
            lines.push(Line::from(Span::styled(body_row, palette.muted_style())));
        }
        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled(
            "  Esc to return.  Then `kranth login` if the key is missing.",
            palette.ghost_style(),
        )));
        f.render_widget(Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), inner);
        return;
    }

    // Live-mode happy path: render the streaming token text as the feed
    // body (real persona cards land in the next brick once we map
    // speaker_id → archetype).
    if state.is_live() {
        let mut lines: Vec<Line<'static>> = Vec::with_capacity(8);
        lines.push(Line::from(Span::styled(
            "live verdict stream",
            Style::default()
                .fg(palette.text_muted())
                .add_modifier(Modifier::DIM),
        )));
        lines.push(Line::from(""));
        let body = state.streaming_text().unwrap_or_default();
        if body.trim().is_empty() {
            lines.push(Line::from(Span::styled(
                "  waiting for personas to react…",
                palette.ghost_style(),
            )));
        } else {
            let max_cols = area.width.saturating_sub(2) as usize;
            for paragraph in body.split("\n\n") {
                for body_row in wrap_body(paragraph, max_cols.max(20)) {
                    lines.push(Line::from(Span::styled(body_row, palette.muted_style())));
                }
                lines.push(Line::from(""));
            }
        }
        f.render_widget(Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), inner);
        return;
    }

    let mut lines: Vec<Line<'static>> = Vec::with_capacity(48);
    lines.push(Line::from(Span::styled(
        "reactions",
        Style::default()
            .fg(palette.text_muted())
            .add_modifier(Modifier::DIM),
    )));
    lines.push(Line::from(""));

    let reactions = state.reactions();
    // Show last ~5 reactions so the feed reads as a scrolling timeline
    // without overflowing the pane on a small terminal.
    let visible_window = 6usize;
    let start = reactions.len().saturating_sub(visible_window);
    let visible = &reactions[start..];

    for reaction in visible {
        let arch = match ARCHETYPES.get(reaction.persona_idx) {
            Some(a) => a,
            None => continue,
        };
        // Sentiment-only colour — keeps the feed brand-pure. Border char
        // varies too so the three sentiment classes read at a glance even
        // for the colour-blind subset of the audience.
        let (border_char, accent_color, accent_modifier) = match reaction.sentiment {
            Sentiment::Aligned => (
                "▌  ",
                palette.reaction_positive(),
                Modifier::BOLD,
            ),
            Sentiment::Neutral => (
                "┃  ",
                palette.text_primary(),
                Modifier::empty(),
            ),
            Sentiment::Hostile => (
                "┊  ",
                palette.text_muted(),
                Modifier::DIM,
            ),
        };
        let border_style = Style::default().fg(accent_color).add_modifier(accent_modifier);

        // Header: <border> Label · sentiment
        let header = Line::from(vec![
            Span::styled(border_char, border_style),
            Span::styled(
                arch.label.to_string(),
                palette.primary_style().add_modifier(Modifier::BOLD),
            ),
            Span::styled("  ·  ", palette.ghost_style()),
            Span::styled(
                reaction.sentiment.label().to_string(),
                Style::default().fg(accent_color),
            ),
        ]);
        lines.push(header);

        // Body — wrap onto multiple lines manually so the border char
        // continues down the side of each wrapped row.
        let max_body_cols = area.width.saturating_sub(4) as usize;
        for body_row in wrap_body(arch.reaction, max_body_cols.max(20)) {
            lines.push(Line::from(vec![
                Span::styled(border_char, border_style),
                Span::styled(body_row, palette.muted_style()),
            ]));
        }
        lines.push(Line::from(""));
    }

    if reactions.is_empty() && matches!(state.phase(), Phase::Waking) {
        lines.push(Line::from(Span::styled(
            "  waiting for personas to wake…",
            palette.ghost_style(),
        )));
    }

    f.render_widget(Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), inner);
}

fn wrap_body(body: &str, max_cols: usize) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    let mut current = String::new();
    for word in body.split_whitespace() {
        if current.is_empty() {
            current.push_str(word);
            continue;
        }
        if current.len() + 1 + word.len() <= max_cols {
            current.push(' ');
            current.push_str(word);
        } else {
            out.push(std::mem::take(&mut current));
            current.push_str(word);
        }
    }
    if !current.is_empty() {
        out.push(current);
    }
    out
}

fn render_sentiment_bars(f: &mut Frame, area: Rect, palette: &Palette, state: &DebateState) {
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Length(2),
            Constraint::Length(2),
            Constraint::Length(2),
        ])
        .split(area);

    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            "sentiment",
            Style::default()
                .fg(palette.text_muted())
                .add_modifier(Modifier::DIM),
        ))),
        rows[0],
    );

    let aligned = state.aligned_history();
    let neutral = state.neutral_history();
    let hostile = state.hostile_history();
    render_one_bar(
        f,
        rows[1],
        palette,
        "aligned",
        &aligned,
        palette.reaction_positive(),
    );
    render_one_bar(
        f,
        rows[2],
        palette,
        "neutral",
        &neutral,
        palette.text_muted(),
    );
    render_one_bar(
        f,
        rows[3],
        palette,
        "hostile",
        &hostile,
        palette.text_primary(),
    );
}

fn render_one_bar(
    f: &mut Frame,
    area: Rect,
    palette: &Palette,
    label: &'static str,
    data: &[u64],
    color: ratatui::style::Color,
) {
    let split = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Length(10), Constraint::Min(0)])
        .split(area);

    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            label,
            Style::default()
                .fg(palette.text_muted())
                .add_modifier(Modifier::DIM),
        ))),
        split[0],
    );

    let sparkline = Sparkline::default()
        .data(data)
        .style(Style::default().fg(color))
        .max(10);
    f.render_widget(sparkline, split[1]);
}

fn help_line_cinema(palette: &Palette) -> Paragraph<'static> {
    Paragraph::new(Line::from(Span::styled(
        "  Esc / q to abort  ·  Ctrl-C to quit",
        Style::default()
            .fg(palette.text_ghost())
            .add_modifier(Modifier::DIM),
    )))
}

// ─── verdict-reveal overlay ──────────────────────────────────────────────

fn draw_verdict_reveal(f: &mut Frame, area: Rect, palette: &Palette, state: &DebateState) {
    let Some(verdict) = state.verdict() else {
        return;
    };

    // Card scales with the available width so the BigText headline never
    // clips on narrow terminals (the previous 60-wide fixed card cropped
    // "STRONGLY ALIGNED" mid-glyph — single-word headlines fix the content
    // side; this fixes the container side).
    let card_w = area.width.saturating_sub(8).clamp(40, 96);
    let card_h = 11u16;
    let card_area = centered_rect(area, card_w, card_h);

    f.render_widget(
        Block::default()
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded)
            .border_style(Style::default().fg(palette.text_primary())),
        card_area,
    );

    let inner = card_area.inner(Margin {
        vertical: 1,
        horizontal: 3,
    });

    let card_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Length(4),
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(inner);

    f.render_widget(
        Paragraph::new("verdict")
            .style(
                Style::default()
                    .fg(palette.text_muted())
                    .add_modifier(Modifier::DIM),
            )
            .alignment(Alignment::Center),
        card_layout[0],
    );

    let big = BigText::builder()
        .pixel_size(PixelSize::HalfHeight)
        .style(palette.primary_style().add_modifier(Modifier::BOLD))
        .alignment(Alignment::Center)
        .lines(vec![Line::from(verdict.headline)])
        .build();
    f.render_widget(big, card_layout[1]);

    // In live mode the simulator-style sentiment breakdown is meaningless
    // (we don't compute percentages yet — that lands when we wire the
    // synthesis events properly). Surface the synthesis summary text
    // instead. Sim mode keeps the breakdown line.
    let breakdown = if state.is_live() {
        // Card has 1 row for the breakdown line; clip the synthesis summary
        // to ~card_w characters so it never wraps and breaks the layout.
        let breakdown_max = card_w.saturating_sub(8) as usize;
        state
            .verdict_text()
            .map(|t| truncate(&t, breakdown_max.max(40)))
            .unwrap_or_else(|| "synthesis pending — Esc to return".to_string())
    } else {
        format!(
            "{a}% aligned   ·   {n}% neutral   ·   {h}% hostile",
            a = verdict.aligned_pct,
            n = verdict.neutral_pct,
            h = verdict.hostile_pct,
        )
    };
    f.render_widget(
        Paragraph::new(breakdown)
            .style(palette.muted_style())
            .alignment(Alignment::Center),
        card_layout[3],
    );
    f.render_widget(
        Paragraph::new("Esc to return to the prompt")
            .style(
                Style::default()
                    .fg(palette.text_ghost())
                    .add_modifier(Modifier::DIM),
            )
            .alignment(Alignment::Center),
        card_layout[4],
    );
}

// ─── helpers ─────────────────────────────────────────────────────────────

fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
    let w = width.min(area.width);
    let h = height.min(area.height);
    let x = area.x + (area.width.saturating_sub(w)) / 2;
    let y = area.y + (area.height.saturating_sub(h)) / 2;
    Rect::new(x, y, w, h)
}

fn truncate(s: &str, max_chars: usize) -> String {
    if s.chars().count() <= max_chars {
        s.to_string()
    } else {
        let mut out: String = s.chars().take(max_chars.saturating_sub(1)).collect();
        out.push('…');
        out
    }
}

/// The Kranth glyph — 3×3 grid mirroring `web/public/icon-source.svg`:
/// solid ascending diagonal (bottom-left → middle → top-right) plus dim
/// descending-diagonal corners (top-left + bottom-right). Each tile is
/// `██` for solid and `░░` for dim. Three rows tall × 10 cells wide.
fn render_kranth_glyph(palette: &Palette) -> Paragraph<'static> {
    let solid_style = palette.primary_style().add_modifier(Modifier::BOLD);
    let dim_style = Style::default()
        .fg(palette.text_ghost())
        .add_modifier(Modifier::DIM);

    let solid = || Span::styled("██", solid_style);
    let dim = || Span::styled("░░", dim_style);
    let empty = || Span::raw("  ");
    let gap = || Span::raw("  ");

    let lines = vec![
        // Row 0: dim · empty · solid   (top-left dim, top-right solid)
        Line::from(vec![dim(), gap(), empty(), gap(), solid()]),
        // Row 1: empty · solid · empty (only the middle tile lights up)
        Line::from(vec![empty(), gap(), solid(), gap(), empty()]),
        // Row 2: solid · empty · dim   (bottom-left solid, bottom-right dim)
        Line::from(vec![solid(), gap(), empty(), gap(), dim()]),
    ];
    Paragraph::new(Text::from(lines))
}
