//! Interactive terminal UI (Ratatui) — launched when `svault` is run with no
//! subcommand. Covers the full Step 1 workflow without leaving the keyboard:
//! list vaults, create, lock/unlock, edit settings, and add/view/delete secrets.
//!
//! Lock awareness is central: secret screens and the settings editor require an
//! unlocked vault (cached session passphrase). When a locked vault is selected,
//! the action is routed through the unlock prompt and resumed on success.

mod theme;
mod ui;

use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::widgets::TableState;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use crate::crypto::VaultKey;
use crate::meta::{AccessConfig, AllowAgent, LoginMethod, VaultMeta, VaultSettings};
use crate::policy::SecretRule;
use crate::session;
use crate::vault::{list_vault_dirs, Vault, SVAULT_DIR};

/// Enter the alternate screen, run the event loop, restore the terminal.
pub fn run() -> Result<()> {
    // Everything recorded from here on is a TUI action.
    crate::usage::set_source(crate::usage::Source::Tui);
    let mut terminal = ratatui::init();
    // Bracketed paste lets us receive a whole pasted string (passphrases,
    // recovery codes, bundle paths) as one event instead of key-by-key.
    let _ = crossterm::execute!(std::io::stdout(), crossterm::event::EnableBracketedPaste);
    let mut app = App::new();
    let result = app.event_loop(&mut terminal);
    let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableBracketedPaste);
    ratatui::restore();
    result
}

// ── Status messages ────────────────────────────────────────────────────────────

#[derive(Clone, Copy, PartialEq)]
pub enum MsgKind {
    Info,
    Ok,
    Warn,
    Error,
}

pub struct Status {
    pub kind: MsgKind,
    pub text: String,
}

// ── Vault rows ───────────────────────────────────────────────────────────────

#[derive(Clone)]
pub struct VaultRow {
    pub name: String,
    pub storage: String,
    pub dir: PathBuf,
    pub description: String,
    pub created: String,
    pub unlocked: bool,
}

fn load_vaults() -> Vec<VaultRow> {
    list_vault_dirs()
        .into_iter()
        .filter_map(|dir| {
            let meta = VaultMeta::load_unverified(&dir).ok()?;
            let unlocked = session::is_unlocked(&dir);
            Some(VaultRow {
                name: meta.name,
                storage: meta.storage,
                dir,
                description: meta.description,
                created: short_date(&meta.created_at),
                unlocked,
            })
        })
        .collect()
}

/// Format an RFC 3339 timestamp as a local `YYYY-MM-DD` date for display.
/// Falls back to the first 10 chars (the date part) if parsing fails.
fn short_date(rfc3339: &str) -> String {
    chrono::DateTime::parse_from_rfc3339(rfc3339)
        .map(|t| {
            t.with_timezone(&chrono::Local)
                .format("%Y-%m-%d")
                .to_string()
        })
        .unwrap_or_else(|_| rfc3339.chars().take(10).collect())
}

// ── Screens ────────────────────────────────────────────────────────────────────

/// What to do after a successful unlock prompt.
#[derive(Clone, Copy)]
pub enum Pending {
    List,
    Secrets,
    Settings,
    /// A renamed import: re-sign `meta.name` to the form's name once the
    /// passphrase opens the freshly-imported vault.
    FinishImport,
}

/// The focusable fields of the create form, in display order. Handlers match on
/// the field (not a bare index) so the draw order and the key logic can never
/// drift apart. Storage and login method are fixed (local / passphrase) today,
/// so they are shown as a static note rather than a pickable field.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CreateField {
    Name,
    Description,
    AllowMode,
    AllowList,
    RateLimit,
    Autolock,
    AutolockTimer,
    DefaultTier,
    Judge,
    Passphrase,
    Confirm,
}

impl CreateField {
    pub const ORDER: [CreateField; 11] = [
        CreateField::Name,
        CreateField::Description,
        CreateField::AllowMode,
        CreateField::AllowList,
        CreateField::RateLimit,
        CreateField::Autolock,
        CreateField::AutolockTimer,
        CreateField::DefaultTier,
        CreateField::Judge,
        CreateField::Passphrase,
        CreateField::Confirm,
    ];
}

pub struct CreateForm {
    pub name: String,
    pub description: String,
    pub allow_mode: usize, // 0 all · 1 none · 2 list
    pub allow_list: String,
    pub rate_limit: String,
    pub autolock: bool,
    pub autolock_timer: String,
    pub default_tier: usize, // 0 low · 1 medium · 2 high
    pub judge: bool,
    pub passphrase: String,
    pub confirm: String,
    pub focus: usize,
    pub error: Option<String>,
}

impl CreateForm {
    const FIELDS: usize = CreateField::ORDER.len();

    fn new() -> Self {
        let default_name = std::env::current_dir()
            .ok()
            .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
            .unwrap_or_else(|| "my-vault".to_string());
        Self {
            name: default_name,
            description: String::new(),
            allow_mode: 0,
            allow_list: String::new(),
            rate_limit: "10/hour".to_string(),
            autolock: true,
            autolock_timer: "1d".to_string(),
            default_tier: 0,
            judge: false,
            passphrase: String::new(),
            confirm: String::new(),
            focus: 0,
            error: None,
        }
    }

    pub fn current(&self) -> CreateField {
        CreateField::ORDER[self.focus]
    }

    /// Whether the focused field accepts typed/pasted text (drives the caret).
    pub fn focus_is_text(&self) -> bool {
        !matches!(
            self.current(),
            CreateField::AllowMode
                | CreateField::Autolock
                | CreateField::DefaultTier
                | CreateField::Judge
        )
    }

    /// The editable string behind the focused field, if it is a text field.
    fn text_field(&mut self) -> Option<&mut String> {
        Some(match self.current() {
            CreateField::Name => &mut self.name,
            CreateField::Description => &mut self.description,
            CreateField::AllowList => &mut self.allow_list,
            CreateField::RateLimit => &mut self.rate_limit,
            CreateField::AutolockTimer => &mut self.autolock_timer,
            CreateField::Passphrase => &mut self.passphrase,
            CreateField::Confirm => &mut self.confirm,
            _ => return None,
        })
    }
}

/// Focusable fields of the settings form, in display order. See [`CreateField`].
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SettingsField {
    Description,
    AllowMode,
    AllowList,
    RateLimit,
    Autolock,
    AutolockTimer,
    DefaultTier,
    Judge,
}

impl SettingsField {
    pub const ORDER: [SettingsField; 8] = [
        SettingsField::Description,
        SettingsField::AllowMode,
        SettingsField::AllowList,
        SettingsField::RateLimit,
        SettingsField::Autolock,
        SettingsField::AutolockTimer,
        SettingsField::DefaultTier,
        SettingsField::Judge,
    ];
}

pub struct SettingsForm {
    pub vault_dir: PathBuf,
    pub name: String,
    pub description: String,
    pub allow_mode: usize,
    pub allow_list: String,
    pub rate_limit: String,
    pub autolock: bool,
    pub autolock_timer: String,
    pub default_tier: usize,
    pub judge: bool,
    pub focus: usize,
    pub error: Option<String>,
}

impl SettingsForm {
    const FIELDS: usize = SettingsField::ORDER.len();

    fn from_meta(
        vault_dir: PathBuf,
        meta: VaultMeta,
        policy: &crate::policy::VaultPolicyData,
    ) -> Self {
        let (allow_mode, allow_list) = match &policy.access.allow_agent {
            AllowAgent::Bool(true) => (0, String::new()),
            AllowAgent::Bool(false) => (1, String::new()),
            AllowAgent::List(v) => (2, v.join(", ")),
        };
        Self {
            vault_dir,
            name: meta.name,
            description: meta.description,
            allow_mode,
            allow_list,
            rate_limit: policy.access.rate_limit.clone(),
            autolock: meta.settings.autolock,
            autolock_timer: meta.settings.autolock_timer,
            default_tier: tier_idx(policy.default_tier),
            judge: policy.judge.enabled.unwrap_or(false),
            focus: 0,
            error: None,
        }
    }

    pub fn current(&self) -> SettingsField {
        SettingsField::ORDER[self.focus]
    }

    pub fn focus_is_text(&self) -> bool {
        !matches!(
            self.current(),
            SettingsField::AllowMode
                | SettingsField::Autolock
                | SettingsField::DefaultTier
                | SettingsField::Judge
        )
    }

    fn text_field(&mut self) -> Option<&mut String> {
        Some(match self.current() {
            SettingsField::Description => &mut self.description,
            SettingsField::AllowList => &mut self.allow_list,
            SettingsField::RateLimit => &mut self.rate_limit,
            SettingsField::AutolockTimer => &mut self.autolock_timer,
            _ => return None,
        })
    }
}

pub struct UnlockForm {
    pub vault_dir: PathBuf,
    pub name: String,
    pub passphrase: String,
    pub error: Option<String>,
    pub pending: Pending,
}

pub struct Reveal {
    pub name: String,
    /// Wrapped so a revealed secret is wiped from memory when the modal closes (#6).
    pub value: zeroize::Zeroizing<String>,
    pub masked: bool,
}

pub struct SecretScreen {
    pub vault_dir: PathBuf,
    pub name: String,
    pub secrets: Vec<String>,
    /// Per-secret classification from the encrypted policy, shown alongside each
    /// name (tier/scope/require-reason/description) so the policy that governs an
    /// agent `get` is visible without leaving the browser.
    pub classifications: BTreeMap<String, SecretRule>,
    /// The vault's default tier (from the decrypted policy), used to prefill the
    /// add/classify forms.
    pub default_tier: usize,
    pub list_state: TableState,
    pub reveal: Option<Reveal>,
    pub pending_delete: Option<String>,
}

impl SecretScreen {
    fn selected_name(&self) -> Option<String> {
        self.list_state
            .selected()
            .and_then(|i| self.secrets.get(i).cloned())
    }
}

pub struct SecretAddForm {
    pub vault_dir: PathBuf,
    pub vault_name: String,
    pub name: String,
    pub value: String,
    pub scope: String,
    pub description: String,
    pub tier: usize, // 0 low · 1 medium · 2 high
    pub require_reason: bool,
    pub focus: usize, // 0 name · 1 value · 2 scope · 3 description · 4 tier · 5 require_reason
    pub error: Option<String>,
}

impl SecretAddForm {
    const FIELDS: usize = 6;
    /// Text-entry fields (name/value/scope/description) show a caret; tier/require_reason don't.
    fn focus_is_text(&self) -> bool {
        self.focus < 4
    }
}

/// Reclassify an existing secret: edit the scope/tier/require-reason/description
/// stored in the signed `meta.yaml`. The secret *value* is never touched here —
/// this only edits the policy classification the gate reads.
pub struct ClassifyForm {
    pub vault_dir: PathBuf,
    pub vault_name: String,
    pub secret: String,
    pub scope: String,
    pub description: String,
    pub tier: usize, // 0 low · 1 medium · 2 high
    pub require_reason: bool,
    pub focus: usize, // 0 scope · 1 description · 2 tier · 3 require_reason
    pub error: Option<String>,
}

impl ClassifyForm {
    const FIELDS: usize = 4;
    /// Text-entry fields (scope/description) show a caret; tier/require_reason don't.
    fn focus_is_text(&self) -> bool {
        self.focus < 2
    }
}

/// The focusable rows of the global judge-management screen, in display order.
/// The first rows edit the global `JudgeConfig`; the last three are actions
/// (set/remove the OpenRouter key, dry-run the model, save the config).
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum JudgeField {
    Enabled,
    Model,
    AllowThreshold,
    HighThreshold,
    Timeout,
    ApiKey,
    Test,
    Save,
}

impl JudgeField {
    pub const ORDER: [JudgeField; 8] = [
        JudgeField::Enabled,
        JudgeField::Model,
        JudgeField::AllowThreshold,
        JudgeField::HighThreshold,
        JudgeField::Timeout,
        JudgeField::ApiKey,
        JudgeField::Test,
        JudgeField::Save,
    ];
}

/// Manage the global AI judge: the `.svault/config.yaml` `[judge]` section plus
/// the `0600` OpenRouter key file. This is the TUI equivalent of `svault judge
/// set-key / status / remove-key / test`, with the global enable/model/threshold
/// knobs that previously required hand-editing the config.
pub struct JudgeForm {
    pub enabled: bool,
    pub model: String,
    pub allow_threshold: String,
    pub high_threshold: String,
    pub timeout: String,
    /// Display-only summary of where the key resolves from; recomputed after a
    /// set/remove so the screen always reflects the on-disk state.
    pub key_status: String,
    pub focus: usize,
    pub error: Option<String>,
    /// Result of the last `test` action (kind + message), shown under the form.
    pub test_result: Option<(MsgKind, String)>,
    /// When `Some`, the screen is in key-entry mode (masked input); the string is
    /// the key being typed. `Esc` cancels, `Enter` writes it `0600`.
    pub key_entry: Option<String>,
}

impl JudgeForm {
    const FIELDS: usize = JudgeField::ORDER.len();

    /// Build from the persisted global config and current key source.
    fn load() -> Self {
        let cfg = crate::config::SvaultConfig::load();
        let j = &cfg.judge;
        Self {
            enabled: j.enabled,
            model: j.model.clone(),
            allow_threshold: j.allow_threshold.to_string(),
            high_threshold: j.high_threshold.to_string(),
            timeout: j.timeout_secs.to_string(),
            key_status: key_status_line(j),
            focus: 0,
            error: None,
            test_result: None,
            key_entry: None,
        }
    }

    pub fn current(&self) -> JudgeField {
        JudgeField::ORDER[self.focus]
    }

    pub fn focus_is_text(&self) -> bool {
        matches!(
            self.current(),
            JudgeField::Model
                | JudgeField::AllowThreshold
                | JudgeField::HighThreshold
                | JudgeField::Timeout
        )
    }

    fn text_field(&mut self) -> Option<&mut String> {
        Some(match self.current() {
            JudgeField::Model => &mut self.model,
            JudgeField::AllowThreshold => &mut self.allow_threshold,
            JudgeField::HighThreshold => &mut self.high_threshold,
            JudgeField::Timeout => &mut self.timeout,
            _ => return None,
        })
    }
}

/// One-line, value-free summary of where the OpenRouter key resolves from.
fn key_status_line(cfg: &crate::config::JudgeConfig) -> String {
    match crate::config::key_source(cfg) {
        crate::config::KeySource::Env => {
            format!("from ${} (environment)", crate::config::KEY_ENV)
        }
        crate::config::KeySource::File(p) => format!("present  ({})", p.display()),
        crate::config::KeySource::None => "none — press enter to set".to_string(),
    }
}

pub struct ImportForm {
    pub path: String,
    pub error: Option<String>,
}

pub struct RecoverForm {
    pub vault_dir: PathBuf,
    pub name: String,
    pub code: String,
    pub new_pass: String,
    pub confirm: String,
    pub focus: usize, // 0 code · 1 new passphrase · 2 confirm
    pub error: Option<String>,
}

impl RecoverForm {
    const FIELDS: usize = 3;

    fn field_mut(&mut self) -> &mut String {
        match self.focus {
            0 => &mut self.code,
            1 => &mut self.new_pass,
            _ => &mut self.confirm,
        }
    }
}

/// A read-only view of a vault's recent usage events (human + agent activity).
pub struct ActivityScreen {
    pub name: String,
    pub events: Vec<crate::usage::Event>,
    pub state: TableState,
}

pub enum Screen {
    List,
    Create(CreateForm),
    Settings(SettingsForm),
    Unlock(UnlockForm),
    Secrets(SecretScreen),
    SecretAdd(SecretAddForm),
    /// Shows the one-time recovery code after a vault is created. Dismissed only
    /// by an explicit 'y' confirmation that the code has been saved.
    RecoveryCode(String),
    /// Import a vault from a bundle file (path entry).
    Import(ImportForm),
    /// Recover a vault: enter the code + a new passphrase.
    Recover(RecoverForm),
    /// Recent usage timeline for the selected vault.
    Activity(ActivityScreen),
    /// Reclassify a secret's policy (scope/tier/require-reason/description).
    Classify(ClassifyForm),
    /// Manage the global AI judge (config + OpenRouter key + dry-run test).
    Judge(JudgeForm),
}

// ── App ────────────────────────────────────────────────────────────────────────

pub struct App {
    pub screen: Screen,
    pub vaults: Vec<VaultRow>,
    pub list_state: TableState,
    pub status: Option<Status>,
    pub should_quit: bool,
    /// When set, the help overlay is shown over the current screen.
    pub show_help: bool,
    /// When set, a "quit?" confirmation popup is shown.
    pub confirm_quit: bool,
    /// Whether a background daemon is currently running (Unix). Shown in the
    /// header; refreshed on startup, after a vault refresh, and after toggling.
    pub daemon_running: bool,
}

impl App {
    fn new() -> Self {
        let vaults = load_vaults();
        let mut list_state = TableState::default();
        if !vaults.is_empty() {
            list_state.select(Some(0));
        }
        let daemon_running = crate::daemon::is_running(&crate::daemon::base_dir());
        Self {
            screen: Screen::List,
            vaults,
            list_state,
            status: None,
            should_quit: false,
            show_help: false,
            confirm_quit: false,
            daemon_running,
        }
    }

    fn event_loop(&mut self, terminal: &mut ratatui::DefaultTerminal) -> Result<()> {
        while !self.should_quit {
            terminal.draw(|frame| ui::draw(frame, self))?;
            match event::read()? {
                Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key(key)?,
                Event::Paste(text) => self.on_paste(text),
                _ => {}
            }
        }
        Ok(())
    }

    /// Append a pasted string to the focused text field of the current screen.
    /// Newlines are stripped so a multi-line paste can't break field layout.
    fn on_paste(&mut self, text: String) {
        if self.show_help {
            return;
        }
        let text = text.replace(['\n', '\r'], "");
        if text.is_empty() {
            return;
        }
        match &mut self.screen {
            Screen::Create(form) => {
                if let Some(s) = form.text_field() {
                    s.push_str(&text);
                    form.error = None;
                }
            }
            Screen::Settings(form) => {
                if let Some(s) = form.text_field() {
                    s.push_str(&text);
                    form.error = None;
                }
            }
            Screen::Unlock(form) => {
                form.passphrase.push_str(&text);
                form.error = None;
            }
            Screen::SecretAdd(form) => {
                if form.focus == 0 {
                    form.name.push_str(&text);
                } else {
                    form.value.push_str(&text);
                }
                form.error = None;
            }
            Screen::Import(form) => {
                form.path.push_str(&text);
                form.error = None;
            }
            Screen::Recover(form) => {
                form.field_mut().push_str(&text);
                form.error = None;
            }
            Screen::Classify(form) => {
                match form.focus {
                    0 => form.scope.push_str(&text),
                    1 => form.description.push_str(&text),
                    _ => {}
                }
                form.error = None;
            }
            Screen::Judge(form) => {
                if let Some(buf) = form.key_entry.as_mut() {
                    buf.push_str(&text);
                } else if let Some(s) = form.text_field() {
                    s.push_str(&text);
                    form.error = None;
                }
            }
            _ => {}
        }
    }

    // ── Status helpers ──────────────────────────────────────────────────────

    fn set_status(&mut self, kind: MsgKind, text: impl Into<String>) {
        self.status = Some(Status {
            kind,
            text: text.into(),
        });
    }

    // ── Vault list helpers ────────────────────────────────────────────────────

    fn refresh_vaults(&mut self) {
        self.vaults = load_vaults();
        if self.vaults.is_empty() {
            self.list_state.select(None);
        } else {
            let i = self
                .list_state
                .selected()
                .unwrap_or(0)
                .min(self.vaults.len() - 1);
            self.list_state.select(Some(i));
        }
        self.refresh_daemon();
    }

    fn refresh_daemon(&mut self) {
        self.daemon_running = crate::daemon::is_running(&crate::daemon::base_dir());
    }

    /// Start the daemon if it's stopped, stop it if it's running, then refresh
    /// the indicator. Uses the quiet variants so nothing is printed over the TUI;
    /// the outcome goes to the status line.
    fn toggle_daemon(&mut self) {
        let result = if self.daemon_running {
            crate::daemon::stop_quiet()
        } else {
            crate::daemon::start_quiet()
        };
        match result {
            Ok(msg) => self.set_status(MsgKind::Ok, msg),
            Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
        }
        self.refresh_daemon();
    }

    fn selected_vault(&self) -> Option<VaultRow> {
        self.list_state
            .selected()
            .and_then(|i| self.vaults.get(i).cloned())
    }

    fn select_next(&mut self) {
        if self.vaults.is_empty() {
            return;
        }
        let i = self
            .list_state
            .selected()
            .map_or(0, |i| (i + 1) % self.vaults.len());
        self.list_state.select(Some(i));
    }

    fn select_prev(&mut self) {
        if self.vaults.is_empty() {
            return;
        }
        let len = self.vaults.len();
        let i = self
            .list_state
            .selected()
            .map_or(0, |i| (i + len - 1) % len);
        self.list_state.select(Some(i));
    }

    // ── Key dispatch ────────────────────────────────────────────────────────

    fn on_key(&mut self, key: KeyEvent) -> Result<()> {
        // The quit popup takes the next key: enter confirms, anything else cancels.
        if self.confirm_quit {
            match key.code {
                KeyCode::Enter => self.should_quit = true,
                _ => self.confirm_quit = false,
            }
            return Ok(());
        }
        // The help overlay swallows the next key (any key closes it).
        if self.show_help {
            self.show_help = false;
            return Ok(());
        }
        // Take ownership of the current screen so handlers can move its form
        // state freely; each handler is responsible for setting the next screen.
        let screen = std::mem::replace(&mut self.screen, Screen::List);
        match screen {
            Screen::List => self.key_list(key)?,
            Screen::Create(form) => self.key_create(form, key)?,
            Screen::Settings(form) => self.key_settings(form, key)?,
            Screen::Unlock(form) => self.key_unlock(form, key)?,
            Screen::Secrets(scr) => self.key_secrets(scr, key)?,
            Screen::SecretAdd(form) => self.key_secret_add(form, key)?,
            Screen::RecoveryCode(code) => self.key_recovery_code(code, key),
            Screen::Import(form) => self.key_import(form, key)?,
            Screen::Recover(form) => self.key_recover(form, key),
            Screen::Activity(scr) => self.key_activity(scr, key),
            Screen::Classify(form) => self.key_classify(form, key)?,
            Screen::Judge(form) => self.key_judge(form, key)?,
        }
        Ok(())
    }

    // ── Activity screen ─────────────────────────────────────────────────────────

    fn key_activity(&mut self, mut scr: ActivityScreen, key: KeyEvent) {
        match key.code {
            KeyCode::Esc | KeyCode::Char('b') => {
                self.screen = Screen::List;
                return;
            }
            KeyCode::Char('q') => self.confirm_quit = true,
            KeyCode::Down | KeyCode::Char('j') => activity_move(&mut scr, true),
            KeyCode::Up | KeyCode::Char('k') => activity_move(&mut scr, false),
            _ => {}
        }
        self.screen = Screen::Activity(scr);
    }

    /// Open the read-only activity timeline for the selected vault. The usage
    /// log isn't a secret (it holds no values), so no unlock is required.
    ///
    /// Global, vault-independent events (judge config + OpenRouter key changes,
    /// recorded at the `.svault` base) are folded in and sorted with the vault's
    /// own events, so a policy/judge change made from the `J` screen is visible
    /// in the audit timeline too.
    fn start_activity(&mut self) {
        let Some(v) = self.selected_vault() else {
            return;
        };
        let mut events = crate::usage::recent(&v.dir, 200);
        events.extend(crate::usage::recent(Path::new(SVAULT_DIR), 200));
        // RFC 3339 UTC timestamps sort correctly lexicographically; newest first.
        events.sort_by(|a, b| b.ts.cmp(&a.ts));
        events.truncate(200);
        let mut state = TableState::default();
        if !events.is_empty() {
            state.select(Some(0));
        }
        self.screen = Screen::Activity(ActivityScreen {
            name: v.name,
            events,
            state,
        });
    }

    /// The recovery-code screen requires an explicit confirmation ('y') that the
    /// code was saved — any other key keeps it on screen, so it can't be
    /// dismissed by accident before the user has written it down.
    fn key_recovery_code(&mut self, code: String, key: KeyEvent) {
        if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
            self.screen = Screen::List;
        } else {
            self.screen = Screen::RecoveryCode(code);
        }
    }

    // ── Import screen ─────────────────────────────────────────────────────────

    fn key_import(&mut self, mut form: ImportForm, key: KeyEvent) -> Result<()> {
        match key.code {
            KeyCode::Esc => self.screen = Screen::List,
            KeyCode::Backspace => {
                form.path.pop();
                form.error = None;
                self.screen = Screen::Import(form);
            }
            KeyCode::Char(c) => {
                form.path.push(c);
                form.error = None;
                self.screen = Screen::Import(form);
            }
            KeyCode::Enter => {
                let path = form.path.trim();
                if path.is_empty() {
                    form.error = Some("Enter a path to a .svault-export.json file".into());
                    self.screen = Screen::Import(form);
                    return Ok(());
                }
                let base = std::path::Path::new(SVAULT_DIR);
                let result = std::fs::read_to_string(path)
                    .map_err(|e| anyhow::anyhow!("cannot read {path}: {e}"))
                    .and_then(|raw| {
                        let bundle = crate::portable::parse_bundle(&raw)?;
                        let target = crate::portable::unique_vault_name(base, &bundle.name);
                        crate::portable::import_bundle_as(&raw, base, &target)?;
                        Ok((bundle.name, target))
                    });
                match result {
                    Ok((orig, target)) => {
                        let dir = base.join(&target);
                        crate::usage::human(&dir, "import", None);
                        if target == orig {
                            self.refresh_vaults();
                            self.set_status(MsgKind::Ok, format!("Imported '{target}'"));
                            self.screen = Screen::List;
                        } else {
                            // Name collided → re-sign meta.name once the
                            // passphrase opens the freshly-imported vault.
                            self.set_status(
                                MsgKind::Info,
                                format!("'{orig}' exists — importing as '{target}'; enter passphrase to finish"),
                            );
                            self.screen = Screen::Unlock(UnlockForm {
                                vault_dir: dir,
                                name: target,
                                passphrase: String::new(),
                                error: None,
                                pending: Pending::FinishImport,
                            });
                        }
                    }
                    Err(e) => {
                        form.error = Some(format!("{e}"));
                        self.screen = Screen::Import(form);
                    }
                }
            }
            _ => self.screen = Screen::Import(form),
        }
        Ok(())
    }

    // ── Recover screen ──────────────────────────────────────────────────────────

    fn key_recover(&mut self, mut form: RecoverForm, key: KeyEvent) {
        match key.code {
            KeyCode::Esc => self.screen = Screen::List,
            KeyCode::Tab | KeyCode::Down => {
                form.focus = (form.focus + 1) % RecoverForm::FIELDS;
                self.screen = Screen::Recover(form);
            }
            KeyCode::Up => {
                form.focus = (form.focus + RecoverForm::FIELDS - 1) % RecoverForm::FIELDS;
                self.screen = Screen::Recover(form);
            }
            KeyCode::Backspace => {
                form.field_mut().pop();
                form.error = None;
                self.screen = Screen::Recover(form);
            }
            KeyCode::Char(c) => {
                form.field_mut().push(c);
                form.error = None;
                self.screen = Screen::Recover(form);
            }
            KeyCode::Enter => {
                // Advance through fields; submit from the last one.
                if form.focus < RecoverForm::FIELDS - 1 {
                    form.focus += 1;
                    self.screen = Screen::Recover(form);
                    return;
                }
                self.submit_recover(form);
            }
            _ => self.screen = Screen::Recover(form),
        }
    }

    fn submit_recover(&mut self, mut form: RecoverForm) {
        if let Err(e) = crate::passphrase::meets_floor(&form.new_pass) {
            form.error = Some(e);
            form.new_pass.clear();
            form.confirm.clear();
            form.focus = 1;
            self.screen = Screen::Recover(form);
            return;
        }
        if form.new_pass != form.confirm {
            form.error = Some("Passphrases do not match".into());
            form.new_pass.clear();
            form.confirm.clear();
            form.focus = 1;
            self.screen = Screen::Recover(form);
            return;
        }
        match crate::recovery::recover_and_rekey(&form.vault_dir, &form.code, &form.new_pass) {
            Ok(_) => {
                crate::usage::human(&form.vault_dir, "recover", None);
                session::lock(&form.vault_dir).ok();
                self.refresh_vaults();
                self.set_status(
                    MsgKind::Ok,
                    format!(
                        "Passphrase reset for '{}'. Recovery code unchanged.",
                        form.name
                    ),
                );
                self.screen = Screen::List;
            }
            Err(e) => {
                form.error = Some(format!("{e}"));
                form.code.clear();
                form.focus = 0;
                self.screen = Screen::Recover(form);
            }
        }
    }

    // ── List screen ─────────────────────────────────────────────────────────

    fn key_list(&mut self, key: KeyEvent) -> Result<()> {
        self.screen = Screen::List;
        match key.code {
            KeyCode::Char('q') | KeyCode::Esc => self.confirm_quit = true,
            KeyCode::Down | KeyCode::Char('j') => self.select_next(),
            KeyCode::Up | KeyCode::Char('k') => self.select_prev(),
            KeyCode::Char('c') => self.screen = Screen::Create(CreateForm::new()),
            KeyCode::Char('u') => self.unlock_selected()?,
            KeyCode::Char('l') => self.lock_selected()?,
            KeyCode::Char('s') => self.open_settings()?,
            KeyCode::Char('e') => self.export_selected(),
            KeyCode::Char('i') => {
                self.screen = Screen::Import(ImportForm {
                    path: String::new(),
                    error: None,
                })
            }
            KeyCode::Char('r') => self.start_recover(),
            KeyCode::Char('v') => self.start_activity(),
            KeyCode::Char('d') => self.toggle_daemon(),
            KeyCode::Char('J') => self.screen = Screen::Judge(JudgeForm::load()),
            KeyCode::Char('?') | KeyCode::Char('h') => self.show_help = true,
            KeyCode::Enter => self.open_secrets()?,
            _ => {}
        }
        Ok(())
    }

    /// Export the selected vault to a timestamped bundle in the CWD, so repeated
    /// exports never clobber an earlier one: `<name>-<YYYYMMDD-HHMMSS>.svault-export.json`.
    fn export_selected(&mut self) {
        let Some(v) = self.selected_vault() else {
            return;
        };
        let meta = match VaultMeta::load_unverified(&v.dir) {
            Ok(m) => m,
            Err(e) => {
                self.set_status(MsgKind::Error, format!("Cannot read vault: {e}"));
                return;
            }
        };
        match crate::portable::build_bundle(&v.dir, &meta.name, &meta.storage) {
            Ok(json) => {
                let ts = chrono::Local::now().format("%Y%m%d-%H%M%S");
                let out = format!("{}-{}.svault-export.json", meta.name, ts);
                // The bundle wraps the vault key — write it owner-only, matching
                // the CLI export path. A default-umask write would be world-readable.
                match crate::secfile::write_owner_only(Path::new(&out), json.as_bytes()) {
                    Ok(_) => {
                        // Keep the bundle out of git so it can't be pushed by mistake.
                        crate::portable::ensure_export_gitignored(Path::new("."));
                        // Show the absolute path so the file is easy to find — a
                        // bare filename leaves the user guessing which directory.
                        let shown = std::fs::canonicalize(&out)
                            .map(|p| p.display().to_string())
                            .unwrap_or(out);
                        crate::usage::human(&v.dir, "export", None);
                        self.set_status(MsgKind::Ok, format!("Exported '{}' to {shown}", v.name))
                    }
                    Err(e) => self.set_status(MsgKind::Error, format!("Export failed: {e}")),
                }
            }
            Err(e) => self.set_status(MsgKind::Error, format!("Export failed: {e}")),
        }
    }

    /// Open the recover form for the selected vault (must have a recovery file).
    fn start_recover(&mut self) {
        let Some(v) = self.selected_vault() else {
            return;
        };
        if !crate::recovery::exists(&v.dir) {
            self.set_status(
                MsgKind::Error,
                format!("Vault '{}' has no recovery file", v.name),
            );
            return;
        }
        self.screen = Screen::Recover(RecoverForm {
            vault_dir: v.dir,
            name: v.name,
            code: String::new(),
            new_pass: String::new(),
            confirm: String::new(),
            focus: 0,
            error: None,
        });
    }

    fn unlock_selected(&mut self) -> Result<()> {
        let Some(v) = self.selected_vault() else {
            return Ok(());
        };
        if v.unlocked {
            self.set_status(
                MsgKind::Info,
                format!("Vault '{}' is already unlocked", v.name),
            );
        } else {
            self.screen = Screen::Unlock(UnlockForm {
                vault_dir: v.dir,
                name: v.name,
                passphrase: String::new(),
                error: None,
                pending: Pending::List,
            });
        }
        Ok(())
    }

    fn lock_selected(&mut self) -> Result<()> {
        let Some(v) = self.selected_vault() else {
            return Ok(());
        };
        if !v.unlocked {
            self.set_status(
                MsgKind::Info,
                format!("Vault '{}' is already locked", v.name),
            );
            return Ok(());
        }
        session::lock(&v.dir)?;
        crate::usage::human(&v.dir, "lock", None);
        self.set_status(MsgKind::Ok, format!("Vault '{}' locked", v.name));
        self.refresh_vaults();
        Ok(())
    }

    fn open_secrets(&mut self) -> Result<()> {
        let Some(v) = self.selected_vault() else {
            return Ok(());
        };
        if v.unlocked {
            self.enter_secrets(&v.dir, &v.name)?;
        } else {
            self.screen = Screen::Unlock(UnlockForm {
                vault_dir: v.dir,
                name: v.name,
                passphrase: String::new(),
                error: None,
                pending: Pending::Secrets,
            });
        }
        Ok(())
    }

    fn open_settings(&mut self) -> Result<()> {
        let Some(v) = self.selected_vault() else {
            return Ok(());
        };
        if !v.unlocked {
            self.screen = Screen::Unlock(UnlockForm {
                vault_dir: v.dir,
                name: v.name,
                passphrase: String::new(),
                error: None,
                pending: Pending::Settings,
            });
            return Ok(());
        }
        // Access + judge config are encrypted, so open the vault (it's unlocked)
        // to read the policy rather than the public meta.yaml.
        let Some(key) = session::get_key(&v.dir) else {
            self.screen = Screen::Unlock(UnlockForm {
                vault_dir: v.dir,
                name: v.name,
                passphrase: String::new(),
                error: None,
                pending: Pending::Settings,
            });
            return Ok(());
        };
        match Vault::open_with_key(&v.dir, VaultKey::from_bytes(key)) {
            Ok(vault) => {
                self.screen = Screen::Settings(SettingsForm::from_meta(
                    v.dir,
                    vault.meta.clone(),
                    &vault.policy,
                ));
            }
            Err(e) => self.set_status(MsgKind::Error, format!("Cannot open vault: {e}")),
        }
        Ok(())
    }

    /// Open the vault with the cached passphrase and show its secret list.
    fn enter_secrets(&mut self, dir: &Path, name: &str) -> Result<()> {
        let Some(key) = session::get_key(dir) else {
            self.screen = Screen::Unlock(UnlockForm {
                vault_dir: dir.to_path_buf(),
                name: name.to_string(),
                passphrase: String::new(),
                error: None,
                pending: Pending::Secrets,
            });
            return Ok(());
        };
        match Vault::open_with_key(dir, VaultKey::from_bytes(key)) {
            Ok(vault) => {
                let secrets = vault.list_secret_names().unwrap_or_default();
                let mut list_state = TableState::default();
                if !secrets.is_empty() {
                    list_state.select(Some(0));
                }
                self.screen = Screen::Secrets(SecretScreen {
                    vault_dir: dir.to_path_buf(),
                    name: name.to_string(),
                    classifications: vault.policy.secrets.clone(),
                    default_tier: tier_idx(vault.policy.default_tier),
                    secrets,
                    list_state,
                    reveal: None,
                    pending_delete: None,
                });
            }
            Err(e) => self.set_status(MsgKind::Error, format!("Cannot open vault: {e}")),
        }
        Ok(())
    }

    // ── Create screen ───────────────────────────────────────────────────────

    fn key_create(&mut self, mut form: CreateForm, key: KeyEvent) -> Result<()> {
        match key.code {
            KeyCode::Esc => {
                self.screen = Screen::List;
                return Ok(());
            }
            KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % CreateForm::FIELDS,
            KeyCode::BackTab | KeyCode::Up => {
                form.focus = (form.focus + CreateForm::FIELDS - 1) % CreateForm::FIELDS
            }
            KeyCode::Enter => {
                if form.focus == CreateForm::FIELDS - 1 {
                    return self.submit_create(form);
                }
                form.focus += 1;
            }
            KeyCode::Left => create_adjust(&mut form, false),
            KeyCode::Right => create_adjust(&mut form, true),
            KeyCode::Backspace => {
                if let Some(s) = form.text_field() {
                    s.pop();
                }
            }
            KeyCode::Char(c) => {
                if c == ' ' && form.current() == CreateField::Autolock {
                    form.autolock = !form.autolock; // space toggles auto-lock
                } else if c == ' ' && form.current() == CreateField::Judge {
                    form.judge = !form.judge;
                } else if c == ' ' && form.current() == CreateField::DefaultTier {
                    form.default_tier = cycle(form.default_tier, 3, true);
                } else if let Some(s) = form.text_field() {
                    s.push(c);
                    form.error = None;
                }
            }
            _ => {}
        }
        self.screen = Screen::Create(form);
        Ok(())
    }

    fn submit_create(&mut self, mut form: CreateForm) -> Result<()> {
        let name = form.name.trim().to_string();
        if name.is_empty() {
            form.error = Some("Name is required".into());
            self.screen = Screen::Create(form);
            return Ok(());
        }
        let vault_dir = PathBuf::from(SVAULT_DIR).join(&name);
        if vault_dir.exists() {
            let existing = VaultMeta::load_unverified(&vault_dir)
                .map(|m| m.storage)
                .unwrap_or_else(|_| "local".to_string());
            form.error = Some(format!(
                "a vault named '{name}' already exists ({existing}:{name}) — names must be unique across storage"
            ));
            self.screen = Screen::Create(form);
            return Ok(());
        }
        if form.passphrase.is_empty() {
            form.error = Some("Passphrase is required".into());
            self.screen = Screen::Create(form);
            return Ok(());
        }
        if let Err(e) = crate::passphrase::meets_floor(&form.passphrase) {
            form.error = Some(e);
            self.screen = Screen::Create(form);
            return Ok(());
        }
        if form.passphrase != form.confirm {
            form.error = Some("Passphrases do not match".into());
            self.screen = Screen::Create(form);
            return Ok(());
        }

        let allow_agent = match form.allow_mode {
            0 => AllowAgent::Bool(true),
            1 => AllowAgent::Bool(false),
            _ => AllowAgent::List(parse_agents(&form.allow_list)),
        };
        // Storage is local and login is passphrase today; VaultMeta defaults to
        // "local" storage, so we only carry the wired settings forward.
        let meta = VaultMeta::new(
            name.clone(),
            form.description.clone(),
            VaultSettings {
                autolock: form.autolock,
                autolock_timer: form.autolock_timer.clone(),
                login_method: LoginMethod::Passphrase,
            },
        );
        // The access rules, default tier, and judge override are encrypted in
        // the vault payload, not the plaintext meta.yaml.
        let mut vault_policy = crate::policy::VaultPolicyData {
            access: AccessConfig {
                allow_agent,
                rate_limit: form.rate_limit.clone(),
            },
            default_tier: tier_at(form.default_tier),
            ..crate::policy::VaultPolicyData::default()
        };
        vault_policy.judge.enabled = Some(form.judge);

        match Vault::init(&vault_dir, &form.passphrase, meta, vault_policy) {
            Ok(vault) => {
                // Generate and store the recovery code, then show it once.
                let code = crate::recovery::generate_code();
                if let Err(e) = crate::recovery::write(&vault_dir, vault.key(), &code) {
                    self.refresh_vaults();
                    self.set_status(
                        MsgKind::Warn,
                        format!(
                            "Vault '{name}' created, but recovery code could not be saved: {e}"
                        ),
                    );
                    self.screen = Screen::List;
                    return Ok(());
                }
                crate::usage::human(&vault_dir, "vault.create", None);
                self.refresh_vaults();
                self.set_status(MsgKind::Ok, format!("Vault '{name}' created"));
                self.screen = Screen::RecoveryCode(code);
            }
            Err(e) => {
                form.error = Some(format!("{e}"));
                self.screen = Screen::Create(form);
            }
        }
        Ok(())
    }

    // ── Settings screen ─────────────────────────────────────────────────────

    fn key_settings(&mut self, mut form: SettingsForm, key: KeyEvent) -> Result<()> {
        match key.code {
            KeyCode::Esc => {
                self.screen = Screen::List;
                return Ok(());
            }
            KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % SettingsForm::FIELDS,
            KeyCode::BackTab | KeyCode::Up => {
                form.focus = (form.focus + SettingsForm::FIELDS - 1) % SettingsForm::FIELDS
            }
            KeyCode::Enter => {
                if form.focus == SettingsForm::FIELDS - 1 {
                    return self.submit_settings(form);
                }
                form.focus += 1;
            }
            KeyCode::Left => settings_adjust(&mut form, false),
            KeyCode::Right => settings_adjust(&mut form, true),
            KeyCode::Backspace => {
                if let Some(s) = form.text_field() {
                    s.pop();
                }
            }
            KeyCode::Char(c) => {
                if c == ' ' && form.current() == SettingsField::Autolock {
                    form.autolock = !form.autolock;
                } else if c == ' ' && form.current() == SettingsField::Judge {
                    form.judge = !form.judge;
                } else if c == ' ' && form.current() == SettingsField::DefaultTier {
                    form.default_tier = cycle(form.default_tier, 3, true);
                } else if let Some(s) = form.text_field() {
                    s.push(c);
                    form.error = None;
                }
            }
            _ => {}
        }
        self.screen = Screen::Settings(form);
        Ok(())
    }

    fn submit_settings(&mut self, mut form: SettingsForm) -> Result<()> {
        let Some(key) = session::get_key(&form.vault_dir) else {
            self.set_status(
                MsgKind::Error,
                "Vault is locked — unlock before editing settings",
            );
            self.screen = Screen::List;
            return Ok(());
        };
        let vault = match Vault::open_with_key(&form.vault_dir, VaultKey::from_bytes(key)) {
            Ok(v) => v,
            Err(e) => {
                form.error = Some(format!("{e}"));
                self.screen = Screen::Settings(form);
                return Ok(());
            }
        };

        let allow_agent = match form.allow_mode {
            0 => AllowAgent::Bool(true),
            1 => AllowAgent::Bool(false),
            _ => AllowAgent::List(parse_agents(&form.allow_list)),
        };

        // Public meta gets the description + behavioural settings; the policy
        // surface (access, default tier, judge override) is written encrypted.
        let mut meta = vault.meta.clone();
        meta.description = form.description.clone();
        meta.settings.autolock = form.autolock;
        meta.settings.autolock_timer = form.autolock_timer.clone();

        let mut vault_policy = vault.policy.clone();
        vault_policy.access.allow_agent = allow_agent;
        vault_policy.access.rate_limit = form.rate_limit.clone();
        vault_policy.default_tier = tier_at(form.default_tier);
        vault_policy.judge.enabled = Some(form.judge);

        match vault
            .save_meta(&meta)
            .and_then(|_| vault.save_policy(&vault_policy))
        {
            Ok(_) => {
                crate::usage::human(&form.vault_dir, "settings.update", None);
                self.refresh_vaults();
                self.set_status(MsgKind::Ok, format!("Settings for '{}' saved", form.name));
                self.screen = Screen::List;
            }
            Err(e) => {
                form.error = Some(format!("{e}"));
                self.screen = Screen::Settings(form);
            }
        }
        Ok(())
    }

    // ── Unlock screen ───────────────────────────────────────────────────────

    fn key_unlock(&mut self, mut form: UnlockForm, key: KeyEvent) -> Result<()> {
        match key.code {
            KeyCode::Esc => {
                // Cancelling a renamed import leaves a vault whose dir name and
                // meta.name disagree — drop it so nothing half-imported lingers.
                if matches!(form.pending, Pending::FinishImport) {
                    let _ = std::fs::remove_dir_all(&form.vault_dir);
                    self.refresh_vaults();
                    self.set_status(MsgKind::Warn, "Import cancelled");
                }
                self.screen = Screen::List;
            }
            KeyCode::Backspace => {
                form.passphrase.pop();
                self.screen = Screen::Unlock(form);
            }
            KeyCode::Enter => match Vault::open(&form.vault_dir, &form.passphrase) {
                Ok(vault) => {
                    session::unlock_with_key(&form.vault_dir, vault.key().bytes())?;
                    crate::usage::human(&form.vault_dir, "unlock", None);
                    self.refresh_vaults();
                    self.set_status(MsgKind::Ok, format!("Vault '{}' unlocked", form.name));
                    match form.pending {
                        Pending::List => self.screen = Screen::List,
                        Pending::Secrets => self.enter_secrets(&form.vault_dir, &form.name)?,
                        Pending::Settings => {
                            self.screen = Screen::Settings(SettingsForm::from_meta(
                                form.vault_dir,
                                vault.meta.clone(),
                                &vault.policy,
                            ));
                        }
                        Pending::FinishImport => {
                            // Re-sign meta.name so it matches the (suffixed) dir.
                            let mut meta = vault.meta.clone();
                            meta.name = form.name.clone();
                            vault.save_meta(&meta)?;
                            self.refresh_vaults();
                            self.set_status(MsgKind::Ok, format!("Imported as '{}'", form.name));
                            self.screen = Screen::List;
                        }
                    }
                }
                Err(_) => {
                    form.error = Some("Wrong passphrase".into());
                    form.passphrase.clear();
                    self.screen = Screen::Unlock(form);
                }
            },
            KeyCode::Char(c) => {
                form.passphrase.push(c);
                form.error = None;
                self.screen = Screen::Unlock(form);
            }
            _ => self.screen = Screen::Unlock(form),
        }
        Ok(())
    }

    // ── Secret list screen ────────────────────────────────────────────────────

    fn key_secrets(&mut self, mut scr: SecretScreen, key: KeyEvent) -> Result<()> {
        // Reveal modal takes priority.
        if scr.reveal.is_some() {
            match key.code {
                KeyCode::Char(' ') => {
                    if let Some(r) = scr.reveal.as_mut() {
                        r.masked = !r.masked;
                    }
                }
                KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => scr.reveal = None,
                _ => {}
            }
            self.screen = Screen::Secrets(scr);
            return Ok(());
        }

        // Delete confirmation takes priority.
        if let Some(target) = scr.pending_delete.clone() {
            match key.code {
                KeyCode::Char('y') => {
                    self.delete_secret(&mut scr, &target);
                    scr.pending_delete = None;
                }
                _ => scr.pending_delete = None,
            }
            self.screen = Screen::Secrets(scr);
            return Ok(());
        }

        match key.code {
            KeyCode::Esc | KeyCode::Char('b') => {
                self.screen = Screen::List;
                return Ok(());
            }
            KeyCode::Char('q') => {
                self.confirm_quit = true;
            }
            KeyCode::Down | KeyCode::Char('j') => secrets_next(&mut scr),
            KeyCode::Up | KeyCode::Char('k') => secrets_prev(&mut scr),
            KeyCode::Char('a') => {
                // Default the tier to the vault's default tier (from the policy).
                let default_tier = scr.default_tier;
                self.screen = Screen::SecretAdd(SecretAddForm {
                    vault_dir: scr.vault_dir.clone(),
                    vault_name: scr.name.clone(),
                    name: String::new(),
                    value: String::new(),
                    scope: "misc".to_string(),
                    description: String::new(),
                    tier: default_tier,
                    require_reason: false,
                    focus: 0,
                    error: None,
                });
                return Ok(());
            }
            KeyCode::Char('c') => {
                if let Some(name) = scr.selected_name() {
                    // Prefill from the existing classification, or the vault's
                    // default tier when the secret has none yet.
                    let rule = scr.classifications.get(&name).cloned();
                    let default_tier = scr.default_tier;
                    self.screen = Screen::Classify(ClassifyForm {
                        vault_dir: scr.vault_dir.clone(),
                        vault_name: scr.name.clone(),
                        secret: name.clone(),
                        scope: rule
                            .as_ref()
                            .map(|r| r.scope.clone())
                            .filter(|s| !s.is_empty())
                            .unwrap_or_else(|| "misc".to_string()),
                        description: rule
                            .as_ref()
                            .map(|r| r.description.clone())
                            .unwrap_or_default(),
                        tier: rule
                            .as_ref()
                            .map(|r| tier_idx(r.tier))
                            .unwrap_or(default_tier),
                        require_reason: rule.as_ref().map(|r| r.require_reason).unwrap_or(false),
                        focus: 0,
                        error: None,
                    });
                    return Ok(());
                }
            }
            KeyCode::Enter | KeyCode::Char('g') => self.reveal_secret(&mut scr),
            KeyCode::Char('d') => {
                if let Some(name) = scr.selected_name() {
                    scr.pending_delete = Some(name);
                }
            }
            KeyCode::Char('l') => {
                session::lock(&scr.vault_dir)?;
                crate::usage::human(&scr.vault_dir, "lock", None);
                self.set_status(MsgKind::Ok, format!("Vault '{}' locked", scr.name));
                self.refresh_vaults();
                self.screen = Screen::List;
                return Ok(());
            }
            KeyCode::Char('?') | KeyCode::Char('h') => self.show_help = true,
            _ => {}
        }
        self.screen = Screen::Secrets(scr);
        Ok(())
    }

    fn reveal_secret(&mut self, scr: &mut SecretScreen) {
        let Some(name) = scr.selected_name() else {
            return;
        };
        let Some(key) = session::get_key(&scr.vault_dir) else {
            self.set_status(MsgKind::Error, "Vault is locked");
            return;
        };
        match Vault::open_with_key(&scr.vault_dir, VaultKey::from_bytes(key))
            .and_then(|v| v.get_secret(&name))
        {
            Ok(Some(value)) => {
                crate::usage::human(&scr.vault_dir, "secret.reveal", Some(&name));
                scr.reveal = Some(Reveal {
                    name,
                    value,
                    masked: true,
                })
            }
            Ok(None) => self.set_status(MsgKind::Error, format!("Secret '{name}' not found")),
            Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
        }
    }

    fn delete_secret(&mut self, scr: &mut SecretScreen, name: &str) {
        let Some(key) = session::get_key(&scr.vault_dir) else {
            self.set_status(MsgKind::Error, "Vault is locked");
            return;
        };
        match Vault::open_with_key(&scr.vault_dir, VaultKey::from_bytes(key)) {
            Ok(vault) => match vault.remove_secret(name) {
                Ok(true) => {
                    scr.secrets = vault.list_secret_names().unwrap_or_default();
                    let sel = if scr.secrets.is_empty() {
                        None
                    } else {
                        Some(
                            scr.list_state
                                .selected()
                                .unwrap_or(0)
                                .min(scr.secrets.len() - 1),
                        )
                    };
                    scr.list_state.select(sel);
                    crate::usage::human(&scr.vault_dir, "secret.remove", Some(name));
                    self.set_status(MsgKind::Ok, format!("Secret '{name}' removed"));
                }
                Ok(false) => self.set_status(MsgKind::Error, format!("Secret '{name}' not found")),
                Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
            },
            Err(e) => self.set_status(MsgKind::Error, format!("{e}")),
        }
    }

    // ── Secret add screen ───────────────────────────────────────────────────

    fn key_secret_add(&mut self, mut form: SecretAddForm, key: KeyEvent) -> Result<()> {
        match key.code {
            KeyCode::Esc => {
                let (dir, name) = (form.vault_dir.clone(), form.vault_name.clone());
                self.enter_secrets(&dir, &name)?;
                return Ok(());
            }
            KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % SecretAddForm::FIELDS,
            KeyCode::BackTab | KeyCode::Up => {
                form.focus = (form.focus + SecretAddForm::FIELDS - 1) % SecretAddForm::FIELDS
            }
            KeyCode::Left => secret_add_adjust(&mut form, false),
            KeyCode::Right => secret_add_adjust(&mut form, true),
            KeyCode::Enter => {
                if form.focus == SecretAddForm::FIELDS - 1 {
                    return self.submit_secret_add(form);
                }
                form.focus += 1;
            }
            KeyCode::Backspace => match form.focus {
                0 => {
                    form.name.pop();
                }
                1 => {
                    form.value.pop();
                }
                2 => {
                    form.scope.pop();
                }
                3 => {
                    form.description.pop();
                }
                _ => {}
            },
            KeyCode::Char(c) => match form.focus {
                0 => {
                    form.name.push(c);
                    form.error = None;
                }
                1 => {
                    form.value.push(c);
                    form.error = None;
                }
                2 => {
                    form.scope.push(c);
                    form.error = None;
                }
                3 => {
                    form.description.push(c);
                    form.error = None;
                }
                4 if c == ' ' => form.tier = cycle(form.tier, 3, true),
                5 if c == ' ' => form.require_reason = !form.require_reason,
                _ => {}
            },
            _ => {}
        }
        self.screen = Screen::SecretAdd(form);
        Ok(())
    }

    fn submit_secret_add(&mut self, mut form: SecretAddForm) -> Result<()> {
        if form.name.trim().is_empty() {
            form.error = Some("Secret name is required".into());
            self.screen = Screen::SecretAdd(form);
            return Ok(());
        }
        let Some(key) = session::get_key(&form.vault_dir) else {
            self.set_status(MsgKind::Error, "Vault is locked");
            self.screen = Screen::List;
            return Ok(());
        };
        match Vault::open_with_key(&form.vault_dir, VaultKey::from_bytes(key)) {
            Ok(vault) => match vault.add_secret(form.name.trim(), &form.value) {
                Ok(_) => {
                    // Classify in the encrypted policy so the gate applies to
                    // TUI-added secrets too (scope/tier/require_reason from the form).
                    let scope = if form.scope.trim().is_empty() {
                        "misc".to_string()
                    } else {
                        form.scope.trim().to_string()
                    };
                    let mut vault_policy = vault.policy.clone();
                    vault_policy.secrets.insert(
                        form.name.trim().to_string(),
                        crate::policy::SecretRule {
                            scope,
                            tier: tier_at(form.tier),
                            require_reason: form.require_reason,
                            description: form.description.trim().to_string(),
                        },
                    );
                    let _ = vault.save_policy(&vault_policy);
                    crate::usage::human(&form.vault_dir, "secret.add", Some(form.name.trim()));
                    self.set_status(MsgKind::Ok, format!("Secret '{}' added", form.name.trim()));
                    let (dir, name) = (form.vault_dir.clone(), form.vault_name.clone());
                    self.enter_secrets(&dir, &name)?;
                }
                Err(e) => {
                    form.error = Some(format!("{e}"));
                    self.screen = Screen::SecretAdd(form);
                }
            },
            Err(e) => {
                form.error = Some(format!("{e}"));
                self.screen = Screen::SecretAdd(form);
            }
        }
        Ok(())
    }

    // ── Classify screen ───────────────────────────────────────────────────────

    fn key_classify(&mut self, mut form: ClassifyForm, key: KeyEvent) -> Result<()> {
        match key.code {
            KeyCode::Esc => {
                let (dir, name) = (form.vault_dir.clone(), form.vault_name.clone());
                self.enter_secrets(&dir, &name)?;
                return Ok(());
            }
            KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % ClassifyForm::FIELDS,
            KeyCode::BackTab | KeyCode::Up => {
                form.focus = (form.focus + ClassifyForm::FIELDS - 1) % ClassifyForm::FIELDS
            }
            KeyCode::Left => classify_adjust(&mut form, false),
            KeyCode::Right => classify_adjust(&mut form, true),
            KeyCode::Enter => {
                if form.focus == ClassifyForm::FIELDS - 1 {
                    return self.submit_classify(form);
                }
                form.focus += 1;
            }
            KeyCode::Backspace => match form.focus {
                0 => {
                    form.scope.pop();
                }
                1 => {
                    form.description.pop();
                }
                _ => {}
            },
            KeyCode::Char(c) => match form.focus {
                0 => {
                    form.scope.push(c);
                    form.error = None;
                }
                1 => {
                    form.description.push(c);
                    form.error = None;
                }
                2 if c == ' ' => form.tier = cycle(form.tier, 3, true),
                3 if c == ' ' => form.require_reason = !form.require_reason,
                _ => {}
            },
            _ => {}
        }
        self.screen = Screen::Classify(form);
        Ok(())
    }

    fn submit_classify(&mut self, mut form: ClassifyForm) -> Result<()> {
        let Some(key) = session::get_key(&form.vault_dir) else {
            self.set_status(MsgKind::Error, "Vault is locked");
            self.screen = Screen::List;
            return Ok(());
        };
        match Vault::open_with_key(&form.vault_dir, VaultKey::from_bytes(key)) {
            Ok(vault) => {
                let scope = if form.scope.trim().is_empty() {
                    "misc".to_string()
                } else {
                    form.scope.trim().to_string()
                };
                let mut vault_policy = vault.policy.clone();
                vault_policy.secrets.insert(
                    form.secret.clone(),
                    SecretRule {
                        scope,
                        tier: tier_at(form.tier),
                        require_reason: form.require_reason,
                        description: form.description.trim().to_string(),
                    },
                );
                match vault.save_policy(&vault_policy) {
                    Ok(_) => {
                        crate::usage::human(&form.vault_dir, "secret.classify", Some(&form.secret));
                        self.set_status(
                            MsgKind::Ok,
                            format!("Classification for '{}' saved", form.secret),
                        );
                        let (dir, name) = (form.vault_dir.clone(), form.vault_name.clone());
                        self.enter_secrets(&dir, &name)?;
                    }
                    Err(e) => {
                        form.error = Some(format!("{e}"));
                        self.screen = Screen::Classify(form);
                    }
                }
            }
            Err(e) => {
                form.error = Some(format!("{e}"));
                self.screen = Screen::Classify(form);
            }
        }
        Ok(())
    }

    // ── Judge management screen ─────────────────────────────────────────────────

    fn key_judge(&mut self, mut form: JudgeForm, key: KeyEvent) -> Result<()> {
        // Key-entry sub-mode takes the next keys (masked OpenRouter key input).
        if let Some(mut buf) = form.key_entry.take() {
            match key.code {
                KeyCode::Esc => { /* cancelled — drop the buffer */ }
                KeyCode::Backspace => {
                    buf.pop();
                    form.key_entry = Some(buf);
                }
                KeyCode::Char(c) => {
                    buf.push(c);
                    form.key_entry = Some(buf);
                }
                KeyCode::Enter => self.save_judge_key(&mut form, &buf),
                _ => form.key_entry = Some(buf),
            }
            self.screen = Screen::Judge(form);
            return Ok(());
        }

        match key.code {
            KeyCode::Esc => {
                self.screen = Screen::List;
                return Ok(());
            }
            KeyCode::Tab | KeyCode::Down => form.focus = (form.focus + 1) % JudgeForm::FIELDS,
            KeyCode::BackTab | KeyCode::Up => {
                form.focus = (form.focus + JudgeForm::FIELDS - 1) % JudgeForm::FIELDS
            }
            KeyCode::Left | KeyCode::Right if form.current() == JudgeField::Enabled => {
                form.enabled = !form.enabled;
            }
            // Delete on the key row removes the stored key file.
            KeyCode::Delete if form.current() == JudgeField::ApiKey => {
                self.remove_judge_key(&mut form)
            }
            KeyCode::Enter => match form.current() {
                JudgeField::ApiKey => form.key_entry = Some(String::new()),
                JudgeField::Test => self.run_judge_test(&mut form),
                JudgeField::Save => return self.submit_judge(form),
                JudgeField::Enabled => form.enabled = !form.enabled,
                _ => form.focus += 1,
            },
            KeyCode::Backspace => {
                if let Some(s) = form.text_field() {
                    s.pop();
                }
            }
            KeyCode::Char(c) => {
                if c == ' ' && form.current() == JudgeField::Enabled {
                    form.enabled = !form.enabled;
                } else if let Some(s) = form.text_field() {
                    s.push(c);
                    form.error = None;
                }
            }
            _ => {}
        }
        self.screen = Screen::Judge(form);
        Ok(())
    }

    /// Persist the typed OpenRouter key as a `0600` file, then refresh the status.
    fn save_judge_key(&mut self, form: &mut JudgeForm, key: &str) {
        let key = key.trim();
        if key.is_empty() {
            form.error = Some("empty key — nothing written".into());
            return;
        }
        let cfg = crate::config::SvaultConfig::load();
        match crate::config::set_openrouter_key(&cfg.judge, key) {
            Ok(path) => {
                form.key_status = key_status_line(&cfg.judge);
                form.error = None;
                log_judge("judge.key.set", None);
                self.set_status(
                    MsgKind::Ok,
                    format!("OpenRouter key stored at {} (0600)", path.display()),
                );
            }
            Err(e) => form.error = Some(format!("could not store key: {e}")),
        }
    }

    fn remove_judge_key(&mut self, form: &mut JudgeForm) {
        let cfg = crate::config::SvaultConfig::load();
        match crate::config::remove_openrouter_key(&cfg.judge) {
            Ok(Some(path)) => {
                log_judge("judge.key.remove", None);
                self.set_status(MsgKind::Ok, format!("Removed key file {}", path.display()));
            }
            Ok(None) => self.set_status(MsgKind::Info, "No key file to remove"),
            Err(e) => self.set_status(MsgKind::Error, format!("could not remove key: {e}")),
        }
        form.key_status = key_status_line(&cfg.judge);
    }

    /// Dry-run the configured model against a sample request (the TUI equivalent
    /// of `svault judge test`). Blocks the UI briefly for the HTTP round-trip.
    fn run_judge_test(&mut self, form: &mut JudgeForm) {
        let mut cfg = crate::config::SvaultConfig::load().judge;
        // Verify the plumbing regardless of the global toggle, and use the
        // (possibly unsaved) model the user is editing.
        cfg.enabled = true;
        if !form.model.trim().is_empty() {
            cfg.model = form.model.trim().to_string();
        }
        let Some(rt) = crate::judge::JudgeRuntime::from_config(&cfg) else {
            form.test_result = Some((
                MsgKind::Error,
                format!(
                    "No OpenRouter key — set one here, or export ${}",
                    crate::config::KEY_ENV
                ),
            ));
            return;
        };
        let model = rt.model.clone();
        let ctx = crate::judge::JudgeContext {
            caller: "claude-code",
            scope: "database",
            reason: "run the nightly database migration to apply pending changes",
            secret: "DB_URL",
            tier: crate::policy::Tier::Medium,
            vault: "demo-vault",
            vault_description: "",
            secret_description: "",
            recent: "no prior requests in the last hour",
        };
        form.test_result = Some(match crate::judge::evaluate(&rt, &model, &ctx) {
            crate::judge::JudgeVerdict::Allow { score, rationale } => {
                (MsgKind::Ok, format!("ALLOW (score {score}) — {rationale}"))
            }
            crate::judge::JudgeVerdict::Deny { score, rationale } => {
                (MsgKind::Warn, format!("DENY (score {score}) — {rationale}"))
            }
            crate::judge::JudgeVerdict::Unavailable { err } => {
                (MsgKind::Error, format!("unavailable: {err}"))
            }
        });
    }

    fn submit_judge(&mut self, mut form: JudgeForm) -> Result<()> {
        let parse_u8 = |s: &str, what: &str| -> std::result::Result<u8, String> {
            s.trim()
                .parse::<u8>()
                .map_err(|_| format!("{what} must be a number 0-100"))
        };
        let allow = match parse_u8(&form.allow_threshold, "allow threshold") {
            Ok(v) => v,
            Err(e) => {
                form.error = Some(e);
                self.screen = Screen::Judge(form);
                return Ok(());
            }
        };
        let high = match parse_u8(&form.high_threshold, "high threshold") {
            Ok(v) => v,
            Err(e) => {
                form.error = Some(e);
                self.screen = Screen::Judge(form);
                return Ok(());
            }
        };
        let timeout = match form.timeout.trim().parse::<u64>() {
            Ok(v) if v > 0 => v,
            _ => {
                form.error = Some("timeout must be a positive number of seconds".into());
                self.screen = Screen::Judge(form);
                return Ok(());
            }
        };
        if form.model.trim().is_empty() {
            form.error = Some("model is required".into());
            self.screen = Screen::Judge(form);
            return Ok(());
        }

        let mut cfg = crate::config::SvaultConfig::load();
        cfg.judge.enabled = form.enabled;
        cfg.judge.model = form.model.trim().to_string();
        cfg.judge.allow_threshold = allow;
        cfg.judge.high_threshold = high;
        cfg.judge.timeout_secs = timeout;
        match cfg.save() {
            Ok(_) => {
                log_judge(
                    "judge.config",
                    Some(if form.enabled { "enabled" } else { "disabled" }),
                );
                self.set_status(MsgKind::Ok, "Judge config saved");
                self.screen = Screen::List;
            }
            Err(e) => {
                form.error = Some(format!("could not save config: {e}"));
                self.screen = Screen::Judge(form);
            }
        }
        Ok(())
    }
}

// ── Free helpers ───────────────────────────────────────────────────────────────

fn parse_agents(raw: &str) -> Vec<String> {
    raw.split(',')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect()
}

/// Tier <-> picker-index helpers (0 low · 1 medium · 2 high).
fn tier_at(idx: usize) -> crate::policy::Tier {
    match idx {
        1 => crate::policy::Tier::Medium,
        2 => crate::policy::Tier::High,
        _ => crate::policy::Tier::Low,
    }
}
fn tier_idx(t: crate::policy::Tier) -> usize {
    match t {
        crate::policy::Tier::Low => 0,
        crate::policy::Tier::Medium => 1,
        crate::policy::Tier::High => 2,
    }
}
pub fn tier_label(idx: usize) -> &'static str {
    match idx {
        1 => "medium",
        2 => "high",
        _ => "low",
    }
}

fn create_adjust(form: &mut CreateForm, forward: bool) {
    match form.current() {
        CreateField::AllowMode => form.allow_mode = cycle(form.allow_mode, 3, forward),
        CreateField::Autolock => form.autolock = !form.autolock,
        CreateField::DefaultTier => form.default_tier = cycle(form.default_tier, 3, forward),
        CreateField::Judge => form.judge = !form.judge,
        _ => {}
    }
}

fn settings_adjust(form: &mut SettingsForm, forward: bool) {
    match form.current() {
        SettingsField::AllowMode => form.allow_mode = cycle(form.allow_mode, 3, forward),
        SettingsField::Autolock => form.autolock = !form.autolock,
        SettingsField::DefaultTier => form.default_tier = cycle(form.default_tier, 3, forward),
        SettingsField::Judge => form.judge = !form.judge,
        _ => {}
    }
}

fn secret_add_adjust(form: &mut SecretAddForm, forward: bool) {
    match form.focus {
        4 => form.tier = cycle(form.tier, 3, forward),
        5 => form.require_reason = !form.require_reason,
        _ => {}
    }
}

fn classify_adjust(form: &mut ClassifyForm, forward: bool) {
    match form.focus {
        2 => form.tier = cycle(form.tier, 3, forward),
        3 => form.require_reason = !form.require_reason,
        _ => {}
    }
}

/// Record a global (vault-independent) judge/policy change to `.svault/usage.log`
/// so it shows in the audit timeline. The judge config + OpenRouter key are
/// global, so they aren't tied to any one vault. Best-effort; never blocks the
/// action (it's a no-op when `.svault/` doesn't exist yet).
fn log_judge(action: &str, detail: Option<&str>) {
    crate::usage::human(Path::new(SVAULT_DIR), action, detail);
}

fn cycle(current: usize, len: usize, forward: bool) -> usize {
    if forward {
        (current + 1) % len
    } else {
        (current + len - 1) % len
    }
}

fn secrets_next(scr: &mut SecretScreen) {
    if scr.secrets.is_empty() {
        return;
    }
    let i = scr
        .list_state
        .selected()
        .map_or(0, |i| (i + 1) % scr.secrets.len());
    scr.list_state.select(Some(i));
}

fn secrets_prev(scr: &mut SecretScreen) {
    if scr.secrets.is_empty() {
        return;
    }
    let len = scr.secrets.len();
    let i = scr.list_state.selected().map_or(0, |i| (i + len - 1) % len);
    scr.list_state.select(Some(i));
}

/// Move the activity cursor (which also scrolls the table). Clamps at the ends
/// rather than wrapping, so paging through a long history feels natural.
fn activity_move(scr: &mut ActivityScreen, down: bool) {
    if scr.events.is_empty() {
        return;
    }
    let last = scr.events.len() - 1;
    let i = scr.state.selected().unwrap_or(0);
    let next = if down {
        (i + 1).min(last)
    } else {
        i.saturating_sub(1)
    };
    scr.state.select(Some(next));
}

// ── Tests ────────────────────────────────────────────────────────────────────────
//
// These exercise key dispatch and field logic directly — no terminal needed —
// which is what the field-enum refactor unlocked. They lock in the bug fixes so
// the focus indices can't silently drift again.

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

    fn bare_app(screen: Screen) -> App {
        App {
            screen,
            vaults: Vec::new(),
            list_state: TableState::default(),
            status: None,
            should_quit: false,
            show_help: false,
            confirm_quit: false,
            daemon_running: false,
        }
    }

    fn press(app: &mut App, code: KeyCode) {
        app.on_key(KeyEvent::new(code, KeyModifiers::empty()))
            .unwrap();
    }

    fn idx(field: CreateField) -> usize {
        CreateField::ORDER.iter().position(|f| *f == field).unwrap()
    }

    fn create_at(field: CreateField) -> Screen {
        let mut form = CreateForm::new();
        form.focus = idx(field);
        Screen::Create(form)
    }

    /// Regression: space in the rate-limit field must type a space, not flip
    /// auto-lock (the original off-by-one). See CreateField wiring.
    #[test]
    fn space_in_rate_limit_types_a_space_and_leaves_autolock_alone() {
        let mut app = bare_app(create_at(CreateField::RateLimit));
        press(&mut app, KeyCode::Char(' '));
        let Screen::Create(form) = &app.screen else {
            panic!("expected create screen")
        };
        assert!(form.rate_limit.ends_with(' '));
        assert!(form.autolock, "auto-lock must not toggle from rate-limit");
    }

    #[test]
    fn space_on_the_autolock_field_toggles_it() {
        let mut app = bare_app(create_at(CreateField::Autolock));
        press(&mut app, KeyCode::Char(' '));
        let Screen::Create(form) = &app.screen else {
            panic!("expected create screen")
        };
        assert!(!form.autolock);
    }

    #[test]
    fn create_field_order_matches_field_count() {
        assert_eq!(CreateField::ORDER.len(), CreateForm::FIELDS);
        assert_eq!(SettingsField::ORDER.len(), SettingsForm::FIELDS);
    }

    #[test]
    fn focus_is_text_excludes_pickers_and_toggles() {
        let mut form = CreateForm::new();
        form.focus = idx(CreateField::AllowMode);
        assert!(!form.focus_is_text());
        form.focus = idx(CreateField::Autolock);
        assert!(!form.focus_is_text());
        form.focus = idx(CreateField::DefaultTier);
        assert!(!form.focus_is_text());
        form.focus = idx(CreateField::Judge);
        assert!(!form.focus_is_text());
        form.focus = idx(CreateField::Passphrase);
        assert!(form.focus_is_text());
    }

    #[test]
    fn space_on_judge_field_toggles_it() {
        let mut app = bare_app(create_at(CreateField::Judge));
        press(&mut app, KeyCode::Char(' '));
        let Screen::Create(form) = &app.screen else {
            panic!("expected create screen")
        };
        assert!(form.judge, "space must toggle the AI judge on");
    }

    #[test]
    fn secret_add_tier_cycles_and_classifies() {
        let form = SecretAddForm {
            vault_dir: PathBuf::from("."),
            vault_name: "v".into(),
            name: String::new(),
            value: String::new(),
            scope: "misc".into(),
            description: String::new(),
            tier: 0,
            require_reason: false,
            focus: 4, // tier picker
            error: None,
        };
        let mut app = bare_app(Screen::SecretAdd(form));
        press(&mut app, KeyCode::Right);
        let Screen::SecretAdd(f) = &app.screen else {
            panic!("expected secret-add screen")
        };
        assert_eq!(f.tier, 1, "right arrow cycles tier low → medium");
        assert_eq!(tier_at(f.tier), crate::policy::Tier::Medium);
    }

    #[test]
    fn classify_form_cycles_tier_and_toggles_reason() {
        let form = ClassifyForm {
            vault_dir: PathBuf::from("."),
            vault_name: "v".into(),
            secret: "DB_URL".into(),
            scope: "database".into(),
            description: String::new(),
            tier: 0,
            require_reason: false,
            focus: 2, // tier picker
            error: None,
        };
        let mut app = bare_app(Screen::Classify(form));
        press(&mut app, KeyCode::Right);
        let Screen::Classify(f) = &app.screen else {
            panic!("expected classify screen")
        };
        assert_eq!(f.tier, 1, "right arrow cycles tier low → medium");
        // Move to the require-reason row and toggle it with space.
        press(&mut app, KeyCode::Down);
        press(&mut app, KeyCode::Char(' '));
        let Screen::Classify(f) = &app.screen else {
            panic!("expected classify screen")
        };
        assert!(f.require_reason, "space toggles require-reason on");
    }

    #[test]
    fn judge_screen_toggles_enabled_and_opens_key_entry() {
        let form = JudgeForm {
            enabled: false,
            model: "google/gemini-2.5-flash".into(),
            allow_threshold: "60".into(),
            high_threshold: "80".into(),
            timeout: "6".into(),
            key_status: "none".into(),
            focus: 0, // Enabled row
            error: None,
            test_result: None,
            key_entry: None,
        };
        let mut app = bare_app(Screen::Judge(form));
        press(&mut app, KeyCode::Char(' '));
        let Screen::Judge(f) = &app.screen else {
            panic!("expected judge screen")
        };
        assert!(f.enabled, "space toggles the global judge on");

        // Jump to the API-key row and open the masked key-entry sub-mode.
        let key_row = JudgeField::ORDER
            .iter()
            .position(|x| *x == JudgeField::ApiKey)
            .unwrap();
        if let Screen::Judge(f) = &mut app.screen {
            f.focus = key_row;
        }
        press(&mut app, KeyCode::Enter);
        let Screen::Judge(f) = &app.screen else {
            panic!("expected judge screen")
        };
        assert!(
            f.key_entry.is_some(),
            "enter on the key row starts key entry"
        );
    }

    #[test]
    fn down_wraps_from_last_create_field_to_first() {
        let mut form = CreateForm::new();
        form.focus = CreateForm::FIELDS - 1;
        let mut app = bare_app(Screen::Create(form));
        press(&mut app, KeyCode::Down);
        let Screen::Create(form) = &app.screen else {
            panic!("expected create screen")
        };
        assert_eq!(form.focus, 0);
    }

    #[test]
    fn paste_appends_to_the_focused_field() {
        let mut app = bare_app(create_at(CreateField::Passphrase));
        app.on_paste("Str0ng!Pass#99".to_string());
        let Screen::Create(form) = &app.screen else {
            panic!("expected create screen")
        };
        assert_eq!(form.passphrase, "Str0ng!Pass#99");
    }

    #[test]
    fn paste_strips_newlines() {
        let mut app = bare_app(Screen::Import(ImportForm {
            path: String::new(),
            error: None,
        }));
        app.on_paste("/tmp/v.svault-export.json\n".to_string());
        let Screen::Import(form) = &app.screen else {
            panic!("expected import screen")
        };
        assert_eq!(form.path, "/tmp/v.svault-export.json");
    }

    #[test]
    fn help_opens_from_list_and_any_key_closes_it() {
        let mut app = bare_app(Screen::List);
        press(&mut app, KeyCode::Char('?'));
        assert!(app.show_help);
        press(&mut app, KeyCode::Char('x'));
        assert!(!app.show_help);
    }

    #[test]
    fn help_also_opens_with_h() {
        let mut app = bare_app(Screen::List);
        press(&mut app, KeyCode::Char('h'));
        assert!(app.show_help);
        press(&mut app, KeyCode::Esc);
        assert!(!app.show_help);
    }

    #[test]
    fn quit_from_list_asks_for_confirmation_then_enter_quits() {
        let mut app = bare_app(Screen::List);
        press(&mut app, KeyCode::Char('q'));
        assert!(app.confirm_quit, "q should open the quit confirmation");
        assert!(!app.should_quit, "q alone must not quit");
        press(&mut app, KeyCode::Enter);
        assert!(app.should_quit, "enter confirms the quit");
    }

    #[test]
    fn any_key_other_than_enter_cancels_the_quit_popup() {
        let mut app = bare_app(Screen::List);
        app.confirm_quit = true;
        press(&mut app, KeyCode::Esc);
        assert!(!app.confirm_quit, "esc cancels");
        assert!(!app.should_quit);
    }

    #[test]
    fn recovery_code_screen_needs_y_to_dismiss() {
        let mut app = bare_app(Screen::RecoveryCode("AAAA-BBBB-CCCC".into()));
        press(&mut app, KeyCode::Char('x'));
        assert!(
            matches!(app.screen, Screen::RecoveryCode(_)),
            "a non-y key keeps the code on screen"
        );
        press(&mut app, KeyCode::Char('y'));
        assert!(
            matches!(app.screen, Screen::List),
            "y confirms and returns to the list"
        );
    }
}
