//! Pure state machine — zero crossterm imports.
//!
//! All screen-transition and key-dispatch logic lives here so tests can drive
//! [`App`] state without a real terminal.  The event loop in `events.rs`
//! translates crossterm events into [`AppInput`] and calls [`dispatch_input`].

use std::collections::HashMap;
use tsafe_core::{
    audit::{AuditClipboardContext, AuditContext, AuditEntry, AuditLog},
    errors::SafeError,
    namespace_bulk::{apply_namespace_copy, apply_namespace_move, plan_namespace_bulk},
    profile::{audit_log_path, list_profiles, validate_profile_name, vault_path},
    snapshot,
    vault::Vault,
};

#[cfg(test)]
use crate::app::empty_sensitive;
use crate::app::{
    emit_delete_audit, emit_reveal_audit, sensitive_string, App, EditFocus, LoginFocus,
    PendingUndo, Reveal, Screen, SensitiveString, CLIPBOARD_TTL, REVEAL_TTL, UNDO_TTL,
};
use std::time::Instant;

/// Abstract input event — no crossterm dependency.
///
/// The event loop translates crossterm events into this enum so that tests
/// can call [`dispatch_input`] without a real terminal.
#[derive(Debug, Clone, PartialEq)]
pub enum AppInput {
    /// A printable character typed by the user (includes space `' '`).
    Char(char),
    Backspace,
    Enter,
    Esc,
    Tab,
    Up,
    Down,
    CtrlC,
}

/// Dispatch one abstract input to the application state.
///
/// Returns `true` when the application should quit.
pub fn dispatch_input(
    app: &mut App,
    input: AppInput,
    password_store: &mut Option<SensitiveString>,
) -> bool {
    // Global: Ctrl-C always quits.
    if input == AppInput::CtrlC {
        return true;
    }

    // Help overlay intercepts all keys when visible.
    if app.help_visible {
        match &input {
            AppInput::Esc | AppInput::Char('?') => app.help_visible = false,
            _ => {}
        }
        return false;
    }

    // Toggle help with '?' except while typing into a password/search field.
    let question_goes_to_field = matches!(
        app.screen,
        Screen::EditSecret { .. } | Screen::NewProfile { .. } | Screen::RotatePassword
    ) || (app.screen == Screen::Login
        && app.login_focus == LoginFocus::Password)
        || (app.screen == Screen::Dashboard && app.search_mode);
    if input == AppInput::Char('?') && !question_goes_to_field {
        app.help_visible = true;
        return false;
    }

    match app.screen.clone() {
        Screen::Login => dispatch_login(app, input),
        Screen::Dashboard => dispatch_dashboard(app, input, password_store),
        Screen::EditSecret { is_new } => dispatch_edit(app, input, is_new, password_store),
        Screen::ConfirmDelete => dispatch_confirm_delete(app, input, password_store),
        Screen::RotatePassword => dispatch_rotate(app, input, password_store),
        Screen::SnapshotRestore => dispatch_snapshot_restore(app, input, password_store),
        Screen::AuditLog => dispatch_audit_log(app, input),
        Screen::NewProfile { step } => dispatch_new_profile(app, input, step, password_store),
        Screen::History { .. } => dispatch_history(app, input),
        Screen::MoveSecret { source } => {
            dispatch_move_secret(app, input, &source.clone(), password_store)
        }
        Screen::NsBulk { copy, from } => {
            dispatch_ns_bulk(app, input, copy, &from.clone(), password_store)
        }
        Screen::Quitting => true,
    }
}

// ── Login ─────────────────────────────────────────────────────────────────────

fn dispatch_login(app: &mut App, input: AppInput) -> bool {
    match input {
        // While the master password field is focused, every character is literal (n / q / ? included).
        AppInput::Char(c) if app.login_focus == LoginFocus::Password => {
            app.password_buf.push(c);
            app.login_error = None;
        }
        AppInput::Char('q')
            if app.password_buf.is_empty() && app.login_focus == LoginFocus::Profile =>
        {
            return true;
        }
        AppInput::Char('n')
            if app.password_buf.is_empty() && app.login_focus == LoginFocus::Profile =>
        {
            app.new_profile_name.clear();
            let _ = std::mem::take(&mut app.new_profile_pw1);
            let _ = std::mem::take(&mut app.new_profile_pw2);
            app.new_profile_error = None;
            app.screen = Screen::NewProfile { step: 0 };
        }
        AppInput::Tab => {
            if app.profiles.is_empty() {
                return false;
            }
            app.login_focus = match app.login_focus {
                LoginFocus::Profile => LoginFocus::Password,
                LoginFocus::Password => LoginFocus::Profile,
            };
            app.login_error = None;
        }
        AppInput::Down if app.login_focus == LoginFocus::Profile => {
            if !app.profiles.is_empty() {
                app.profile_cursor = (app.profile_cursor + 1) % app.profiles.len();
                app.login_error = None;
                let _ = std::mem::take(&mut app.password_buf);
            }
        }
        AppInput::Up if app.login_focus == LoginFocus::Profile => {
            if !app.profiles.is_empty() {
                app.profile_cursor =
                    (app.profile_cursor + app.profiles.len() - 1) % app.profiles.len();
                app.login_error = None;
                let _ = std::mem::take(&mut app.password_buf);
            }
        }
        // Profile row focused: typing switches to the password field.
        AppInput::Char(c) => {
            app.password_buf.push(c);
            app.login_error = None;
            app.login_focus = LoginFocus::Password;
        }
        AppInput::Backspace => {
            app.password_buf.pop();
        }
        AppInput::Enter => {
            // try_login moves password_buf; lib.rs's run_loop snapshots it beforehand
            // and sets password_store on successful transition to Dashboard.
            app.try_login();
        }
        AppInput::Esc => {
            let _ = std::mem::take(&mut app.password_buf);
            app.login_error = None;
        }
        _ => {}
    }
    false
}

// ── Dashboard ─────────────────────────────────────────────────────────────────

fn dispatch_dashboard(
    app: &mut App,
    input: AppInput,
    password_store: &mut Option<SensitiveString>,
) -> bool {
    if app.search_mode {
        return dispatch_dashboard_search(app, input);
    }

    match input {
        AppInput::Char('q') => return true,

        AppInput::Down | AppInput::Char('j') => {
            let len = app.visible_entries().len();
            if len > 0 {
                app.secret_cursor = (app.secret_cursor + 1) % len;
            }
            app.clear_status();
        }
        AppInput::Up | AppInput::Char('k') => {
            let len = app.visible_entries().len();
            if len > 0 {
                app.secret_cursor = (app.secret_cursor + len - 1) % len;
            }
            app.clear_status();
        }

        // Expand/collapse namespace group under cursor
        AppInput::Char(' ') => {
            let entries = app.visible_entries();
            if let Some(crate::app::ListEntry::Namespace { name, .. }) =
                entries.get(app.secret_cursor)
            {
                let ns = name.clone();
                if app.collapsed_namespaces.contains(&ns) {
                    app.collapsed_namespaces.remove(&ns);
                } else {
                    app.collapsed_namespaces.insert(ns);
                }
            }
        }

        AppInput::Char('p') => {
            *password_store = None;
            let _ = std::mem::take(&mut app.password_buf);
            app.clear_active_session();
            app.login_error = None;
            app.login_focus = if app.profiles.is_empty() {
                LoginFocus::Profile
            } else {
                LoginFocus::Password
            };
            app.screen = Screen::Login;
        }

        AppInput::Char('/') => {
            app.search_mode = true;
            app.search_query.clear();
        }

        AppInput::Char('r') => {
            if let Some(key_name) = app.selected_key() {
                if app.revealed.contains_key(&key_name) {
                    // User-initiated toggle-off — silent in the audit log.
                    // Silence vs. `secret.reveal_expired` distinguishes user
                    // action from auto-conceal.
                    app.revealed.remove(&key_name);
                    app.set_status("Hidden.");
                } else if let Some(vault) = app.session_vault.as_ref() {
                    match vault.get(&key_name) {
                        Ok(value) => {
                            app.revealed.insert(
                                key_name.clone(),
                                Reveal {
                                    value: sensitive_string((*value).clone()),
                                    revealed_at: std::time::Instant::now(),
                                },
                            );
                            emit_reveal_audit(
                                app.active_profile.as_deref(),
                                "secret.reveal_started",
                                Some(&key_name),
                            );
                            app.set_status(format!(
                                "Revealed '{key_name}' (auto-conceals in {}s)",
                                REVEAL_TTL.as_secs()
                            ));
                        }
                        Err(e) => app.set_status(format!("Error: {e}")),
                    }
                } else {
                    app.set_status("Error: active vault session missing. Log in again.");
                }
            }
        }

        AppInput::Char('n') => {
            app.screen = Screen::EditSecret { is_new: true };
            app.edit_key.clear();
            app.edit_value.clear();
            app.edit_error = None;
            app.edit_focus = EditFocus::Key;
        }

        AppInput::Char('e') | AppInput::Enter => {
            if let Some(key_name) = app.selected_key() {
                if let Some(vault) = app.session_vault.as_ref() {
                    let value = vault
                        .get(&key_name)
                        .map(|v| (*v).clone())
                        .unwrap_or_default();
                    app.edit_key = key_name;
                    app.edit_value = value;
                    app.edit_error = None;
                    app.edit_focus = EditFocus::Value;
                    app.screen = Screen::EditSecret { is_new: false };
                } else {
                    app.set_status("Error: active vault session missing. Log in again.");
                }
            }
        }

        AppInput::Char('d') => {
            if app.selected_key().is_some() {
                app.screen = Screen::ConfirmDelete;
            }
        }

        // Undo the most recent delete if still within the grace window.
        // No-op when no pending undo exists. Restores via vault.set with the
        // original tags. On vault.set failure, emits a distinct
        // `secret.delete_undo_failed` audit op so the log doesn't lie about
        // a successful restore.
        AppInput::Char('u') => {
            let Some(undo) = app.pending_undo.take() else {
                return false;
            };
            // Reset the countdown-tracking field so the next undo (if any)
            // is not spuriously gated by a stale value from this one.
            app.last_undo_status_secs = None;
            let Some(mut vault) = app.session_vault.take() else {
                app.set_status("✗ Active vault session missing. Log in again.");
                return false;
            };
            let profile = app.active_profile.clone();
            let key = undo.key.clone();
            // Wrap the cloned plaintext in Zeroizing so it zeros on drop —
            // a plain String would survive the vault.set call without
            // scrubbing, which is the actual leak window. The original
            // SensitiveString in `undo` zeros when `undo` drops at end of
            // scope; the explicit `drop(undo)` is unnecessary now.
            let value = sensitive_string((*undo.value).clone());
            let tags = undo.tags.clone();
            match vault.set(&key, &value, tags) {
                Ok(()) => {
                    app.reload_from_vault(&vault);
                    app.session_vault = Some(vault);
                    emit_delete_audit(profile.as_deref(), "secret.delete_undone", Some(&key), None);
                    app.set_status(format!("✓ Restored '{key}'"));
                }
                Err(e) => {
                    app.session_vault = Some(vault);
                    let msg = e.to_string();
                    emit_delete_audit(
                        profile.as_deref(),
                        "secret.delete_undo_failed",
                        Some(&key),
                        Some(msg.clone()),
                    );
                    app.set_status(format!("✗ Undo failed: {msg}"));
                }
            }
        }

        // Vim-style yank: copy the focused secret to the system clipboard,
        // arming a 30-second compare-then-clear timer in the tick loop.
        // No-op when the cursor sits on a namespace header (`selected_key`
        // returns `None` for those rows).
        AppInput::Char('y') => {
            if let Some(key_name) = app.selected_key() {
                if let Some(vault) = app.session_vault.as_ref() {
                    match vault.get(&key_name) {
                        Ok(boxed) => {
                            let value = sensitive_string((*boxed).clone());
                            let copy_result = app.copy_to_clipboard(&key_name, value);
                            if let Some(profile) = app.active_profile.clone() {
                                let reason = copy_result.as_ref().err().map(|e| e.to_string());
                                let excluded_from_history = match &copy_result {
                                    Ok(outcome) => outcome.excluded_from_history,
                                    Err(_) => Some(false),
                                };
                                let entry = match &copy_result {
                                    Ok(_) => AuditEntry::success(
                                        &profile,
                                        "clipboard.copy",
                                        Some(&key_name),
                                    ),
                                    Err(err) => AuditEntry::failure(
                                        &profile,
                                        "clipboard.copy_failed",
                                        Some(&key_name),
                                        &err.to_string(),
                                    ),
                                }
                                .with_context(
                                    AuditContext::from_clipboard(AuditClipboardContext {
                                        ttl_secs: CLIPBOARD_TTL.as_secs(),
                                        reason,
                                        excluded_from_history,
                                        cleared_verified: None,
                                    }),
                                );
                                AuditLog::new(&audit_log_path(&profile)).append(&entry).ok();
                            }
                            if let Err(err) = copy_result {
                                app.set_status(err.to_string());
                            }
                        }
                        Err(e) => app.set_status(format!("Error: {e}")),
                    }
                } else {
                    app.set_status("Error: active vault session missing. Log in again.");
                }
            }
        }

        AppInput::Char('R') => {
            if app.session_vault.is_some() && app.active_profile.is_some() {
                app.rotate_step = 0;
                let _ = std::mem::take(&mut app.rotate_new1);
                let _ = std::mem::take(&mut app.rotate_new2);
                app.rotate_error = None;
                app.screen = Screen::RotatePassword;
            }
        }

        AppInput::Char('S') | AppInput::Char('s') => {
            if let Some(profile) = app.active_profile.clone() {
                match snapshot::list(&profile) {
                    Ok(paths) if !paths.is_empty() => {
                        app.snapshot_cursor = paths.len().saturating_sub(1);
                        app.snapshot_paths = paths;
                        app.screen = Screen::SnapshotRestore;
                    }
                    Ok(_) => app.set_status("No snapshots available for this profile."),
                    Err(e) => app.set_status(format!("Snapshot error: {e}")),
                }
            }
        }

        AppInput::Char('a') => {
            if let Some(profile) = app.active_profile.clone() {
                let log = AuditLog::new(&audit_log_path(&profile));
                let entries = log.read(Some(200)).unwrap_or_default();
                app.audit_lines = entries
                    .iter()
                    .map(|e| {
                        format!(
                            "[{}] {:8} {:?}  {}  {}",
                            e.timestamp.format("%Y-%m-%d %H:%M:%S"),
                            e.operation,
                            e.status,
                            e.key.as_deref().unwrap_or("-"),
                            e.message.as_deref().unwrap_or(""),
                        )
                    })
                    .collect();
                app.audit_scroll = 0;
                app.screen = Screen::AuditLog;
            }
        }

        // Copy whole namespace (header row) or open single-key move.
        AppInput::Char('c') => {
            if let Some(ns) = app.selected_namespace() {
                app.mv_dest_buf.clear();
                app.mv_error = None;
                app.screen = Screen::NsBulk {
                    copy: true,
                    from: ns,
                };
            }
        }

        // Move whole namespace when a group header is selected; else move one key.
        AppInput::Char('m') => {
            if let Some(ns) = app.selected_namespace() {
                app.mv_dest_buf.clear();
                app.mv_error = None;
                app.screen = Screen::NsBulk {
                    copy: false,
                    from: ns,
                };
            } else if let Some(key_name) = app.selected_key() {
                app.mv_dest_buf = key_name.clone();
                app.mv_error = None;
                app.screen = Screen::MoveSecret { source: key_name };
            }
        }

        // View version history for the selected key.
        AppInput::Char('h') => {
            if let Some(key_name) = app.selected_key() {
                if let Some(vault) = app.session_vault.as_ref() {
                    match vault.history(&key_name) {
                        Ok(entries) => {
                            app.history_entries = entries;
                            app.history_scroll = 0;
                            app.screen = Screen::History { key: key_name };
                        }
                        Err(e) => app.set_status(format!("History error: {e}")),
                    }
                } else {
                    app.set_status("Error: active vault session missing. Log in again.");
                }
            }
        }

        // Cycle colour theme.
        AppInput::Char('T') => {
            app.theme = app.theme.cycle();
            app.save_theme();
            app.set_status(format!("Theme: {}", app.theme.as_str()));
        }

        _ => {}
    }
    false
}

fn dispatch_dashboard_search(app: &mut App, input: AppInput) -> bool {
    match input {
        AppInput::Esc | AppInput::Enter => {
            app.search_mode = false;
            app.secret_cursor = 0;
        }
        AppInput::Char(c) => {
            app.search_query.push(c);
            app.secret_cursor = 0;
        }
        AppInput::Backspace => {
            app.search_query.pop();
            app.secret_cursor = 0;
        }
        _ => {}
    }
    false
}

// ── Edit modal ────────────────────────────────────────────────────────────────

fn dispatch_edit(
    app: &mut App,
    input: AppInput,
    is_new: bool,
    _password_store: &mut Option<SensitiveString>,
) -> bool {
    match input {
        AppInput::Esc => {
            use zeroize::Zeroize;
            app.edit_value.zeroize();
            app.edit_value.clear();
            app.screen = Screen::Dashboard;
            app.edit_error = None;
        }
        AppInput::Tab => {
            app.edit_focus = match app.edit_focus {
                EditFocus::Key => EditFocus::Value,
                EditFocus::Value => EditFocus::Key,
            };
        }
        AppInput::Char(c) => match app.edit_focus {
            EditFocus::Key => app.edit_key.push(c),
            EditFocus::Value => app.edit_value.push(c),
        },
        AppInput::Backspace => match app.edit_focus {
            EditFocus::Key => {
                app.edit_key.pop();
            }
            EditFocus::Value => {
                app.edit_value.pop();
            }
        },
        AppInput::Enter => {
            let key_name = app.edit_key.trim().to_string();
            if key_name.is_empty() {
                app.edit_error = Some("KEY must not be empty.".into());
                return false;
            }
            if let (Some(profile), Some(mut vault)) =
                (app.active_profile.clone(), app.session_vault.take())
            {
                if is_new && vault.list().contains(&key_name.as_str()) {
                    app.edit_error = Some(format!(
                        "'{key_name}' already exists — rename the key or delete it first."
                    ));
                    app.session_vault = Some(vault);
                    return false;
                }
                match vault.set(&key_name, &app.edit_value, HashMap::new()) {
                    Ok(()) => {
                        let op = if is_new { "set" } else { "update" };
                        AuditLog::new(&audit_log_path(&profile))
                            .append(&AuditEntry::success(&profile, op, Some(&key_name)))
                            .ok();
                        // Any vault mutation at the same key invalidates a
                        // pending undo for that key — prevents `u` from
                        // silently overwriting the new entry. ISC-18b.
                        app.finalize_pending_undo_for_key(&key_name);
                        app.reload_from_vault(&vault);
                        app.session_vault = Some(vault);
                        app.revealed.remove(&key_name);
                        use zeroize::Zeroize;
                        app.edit_value.zeroize();
                        app.edit_value.clear();
                        app.set_status(format!("✓ Saved '{key_name}'"));
                        app.screen = Screen::Dashboard;
                    }
                    Err(e) => {
                        app.edit_error = Some(format!("Save failed: {e}"));
                        app.session_vault = Some(vault);
                    }
                }
            } else {
                app.edit_error = Some("No active vault session. Log in again.".into());
            }
        }
        _ => {}
    }
    false
}

// ── Confirm delete ────────────────────────────────────────────────────────────

fn dispatch_confirm_delete(
    app: &mut App,
    input: AppInput,
    _password_store: &mut Option<SensitiveString>,
) -> bool {
    match input {
        // Accept 'y', 'Y', or Enter. Case-insensitive: users with CapsLock on
        // or who habitually hold Shift after typing a password would otherwise
        // be silently ignored, leaving them stranded on the confirm screen.
        AppInput::Char('y') | AppInput::Char('Y') | AppInput::Enter => {
            if let (Some(key_name), Some(profile), Some(mut vault)) = (
                app.selected_key(),
                app.active_profile.clone(),
                app.session_vault.take(),
            ) {
                // A previous undo at the SAME key auto-finalises here so the
                // upcoming new snapshot can't be silently overwritten by a
                // later `u` press.
                app.finalize_pending_undo_for_key(&key_name);

                // Snapshot value + tags BEFORE the delete so we can restore.
                // vault.get fails (e.g. SecretNotFound) — fall through to the
                // normal delete error path; no undo is offered.
                let snapshot = vault.get(&key_name).ok().map(|v| {
                    let tags = vault
                        .file()
                        .secrets
                        .get(&key_name)
                        .map(|e| e.tags.clone())
                        .unwrap_or_default();
                    (sensitive_string((*v).clone()), tags)
                });

                match vault.delete(&key_name) {
                    Ok(()) => {
                        // Existing audit op — backward-compat for consumers
                        // that grep for `"delete"`. The new
                        // `secret.delete_finalized` op fires at expiry; the
                        // pair tells the full story.
                        AuditLog::new(&audit_log_path(&profile))
                            .append(&AuditEntry::success(&profile, "delete", Some(&key_name)))
                            .ok();
                        app.revealed.remove(&key_name);
                        app.reload_from_vault(&vault);
                        app.session_vault = Some(vault);

                        // Arm the undo only if we successfully snapshot the
                        // value. A snapshot failure means we have nothing to
                        // restore — no false-undo ghost entry per ISC-A5.
                        if let Some((value, tags)) = snapshot {
                            app.pending_undo = Some(PendingUndo {
                                key: key_name.clone(),
                                value,
                                tags,
                                deleted_at: Instant::now(),
                            });
                            // First status is set immediately so the user sees
                            // the affordance even before the next tick. The
                            // tick loop overwrites it each tick with the
                            // countdown — that's by design.
                            app.set_status(format!(
                                "Deleted '{key_name}' — press u to undo ({}s)",
                                UNDO_TTL.as_secs()
                            ));
                        } else {
                            app.set_status(format!("✓ Deleted '{key_name}'"));
                        }
                        app.screen = Screen::Dashboard;
                    }
                    Err(e) => {
                        app.session_vault = Some(vault);
                        app.set_status(format!("✗ Delete error: {e}"));
                        app.screen = Screen::Dashboard;
                    }
                }
            } else {
                app.set_status("✗ Active vault session missing. Log in again.");
                app.screen = Screen::Dashboard;
            }
        }
        AppInput::Char('n') | AppInput::Char('N') | AppInput::Esc => {
            app.screen = Screen::Dashboard;
        }
        _ => {}
    }
    false
}

// ── Audit log ─────────────────────────────────────────────────────────────────

fn dispatch_audit_log(app: &mut App, input: AppInput) -> bool {
    match input {
        AppInput::Char('q') | AppInput::Esc => {
            app.screen = Screen::Dashboard;
        }
        AppInput::Down | AppInput::Char('j') => {
            if app.audit_scroll + 1 < app.audit_lines.len() {
                app.audit_scroll += 1;
            }
        }
        AppInput::Up | AppInput::Char('k') => {
            app.audit_scroll = app.audit_scroll.saturating_sub(1);
        }
        _ => {}
    }
    false
}

// ── Rotate password modal ─────────────────────────────────────────────────────

fn dispatch_rotate(
    app: &mut App,
    input: AppInput,
    password_store: &mut Option<SensitiveString>,
) -> bool {
    match input {
        AppInput::Esc => {
            app.screen = Screen::Dashboard;
            app.rotate_error = None;
        }
        AppInput::Backspace => match app.rotate_step {
            0 => {
                app.rotate_new1.pop();
            }
            _ => {
                app.rotate_new2.pop();
            }
        },
        AppInput::Char(c) => match app.rotate_step {
            0 => app.rotate_new1.push(c),
            _ => app.rotate_new2.push(c),
        },
        AppInput::Enter => {
            if app.rotate_step == 0 {
                if app.rotate_new1.is_empty() {
                    app.rotate_error = Some("Password must not be empty.".into());
                } else {
                    app.rotate_step = 1;
                    app.rotate_error = None;
                }
            } else if app.rotate_new1 != app.rotate_new2 {
                app.rotate_error = Some("Passwords do not match.".into());
                app.rotate_step = 0;
                let _ = std::mem::take(&mut app.rotate_new1);
                let _ = std::mem::take(&mut app.rotate_new2);
            } else if let (Some(profile), Some(mut vault)) =
                (app.active_profile.clone(), app.session_vault.take())
            {
                let new_pw = std::mem::take(&mut app.rotate_new1);
                let _ = std::mem::take(&mut app.rotate_new2);
                match vault.rotate(new_pw.as_bytes()) {
                    Ok(()) => {
                        AuditLog::new(&audit_log_path(&profile))
                            .append(&AuditEntry::success(&profile, "rotate", None))
                            .ok();
                        *password_store = Some(new_pw.clone());
                        app.reload_from_vault(&vault);
                        app.session_vault = Some(vault);
                        app.rotate_error = None;
                        let keyring_note =
                            match tsafe_core::keyring_store::store_password(&profile, &new_pw) {
                                Ok(()) => " OS quick unlock updated.",
                                Err(_) => {
                                    " Run `tsafe biometric enable` to refresh OS quick unlock."
                                }
                            };
                        app.set_status(format!("✓ Password rotated successfully.{keyring_note}"));
                        app.screen = Screen::Dashboard;
                    }
                    Err(e) => {
                        app.rotate_error = Some(format!("Rotate failed: {e}"));
                        app.session_vault = Some(vault);
                    }
                }
            } else {
                app.rotate_error = Some("No active vault session. Log in again.".into());
            }
        }
        _ => {}
    }
    false
}

// ── Snapshot restore picker ───────────────────────────────────────────────────

fn dispatch_snapshot_restore(
    app: &mut App,
    input: AppInput,
    password_store: &mut Option<SensitiveString>,
) -> bool {
    match input {
        AppInput::Esc | AppInput::Char('q') => {
            app.snapshot_paths.clear();
            app.screen = Screen::Dashboard;
        }
        AppInput::Down | AppInput::Char('j') => {
            let max = app.snapshot_paths.len().saturating_sub(1);
            if app.snapshot_cursor < max {
                app.snapshot_cursor += 1;
            }
        }
        AppInput::Up | AppInput::Char('k') => {
            app.snapshot_cursor = app.snapshot_cursor.saturating_sub(1);
        }
        AppInput::Enter | AppInput::Char('y') => {
            let snap = app.snapshot_paths.get(app.snapshot_cursor).cloned();
            if let (Some(snap_path), Some(profile), Some(password)) =
                (snap, app.active_profile.clone(), password_store.as_ref())
            {
                let vault_file = vault_path(&profile);
                let _ = app.session_vault.take();
                // Snapshot restore swaps the entire vault contents; any
                // pending undo would target the OLD vault state and could
                // overwrite a freshly-restored entry. Drop it unconditionally
                // before the swap. ISC-A4 protects this path.
                app.pending_undo = None;
                app.last_undo_status_secs = None;
                match snapshot::restore(&vault_file, &snap_path) {
                    Ok(_) => match Vault::open(&vault_file, password.as_bytes()) {
                        Ok(vault) => {
                            AuditLog::new(&audit_log_path(&profile))
                                .append(&AuditEntry::success(
                                    &profile,
                                    "snapshot-restore",
                                    Some(&snap_path.display().to_string()),
                                ))
                                .ok();
                            app.reload_from_vault(&vault);
                            app.session_vault = Some(vault);
                            app.set_status("✓ Vault restored from snapshot.");
                        }
                        Err(e) => {
                            app.set_status(format!("✗ Reload after restore failed: {e}"));
                        }
                    },
                    Err(e) => {
                        if let Ok(vault) = Vault::open(&vault_file, password.as_bytes()) {
                            app.reload_from_vault(&vault);
                            app.session_vault = Some(vault);
                        }
                        app.set_status(format!("✗ Restore error: {e}"));
                    }
                }
                if app.session_vault.is_none() {
                    app.clear_active_session();
                    *password_store = None;
                    app.login_error =
                        Some("Vault session dropped after snapshot restore. Log in again.".into());
                    app.login_focus = if app.profiles.is_empty() {
                        LoginFocus::Profile
                    } else {
                        LoginFocus::Password
                    };
                    app.screen = Screen::Login;
                } else {
                    app.snapshot_paths.clear();
                    app.screen = Screen::Dashboard;
                }
            }
        }
        _ => {}
    }
    false
}

// ── New-profile wizard ────────────────────────────────────────────────────────
// Step 0 = profile name, step 1 = password, step 2 = confirm password.

fn dispatch_new_profile(
    app: &mut App,
    input: AppInput,
    step: u8,
    password_store: &mut Option<SensitiveString>,
) -> bool {
    match step {
        0 => match input {
            AppInput::Esc => {
                app.screen = Screen::Login;
                app.new_profile_error = None;
                app.login_focus = if app.profiles.is_empty() {
                    LoginFocus::Profile
                } else {
                    LoginFocus::Password
                };
            }
            AppInput::Char(c) => {
                app.new_profile_name.push(c);
                app.new_profile_error = None;
            }
            AppInput::Backspace => {
                app.new_profile_name.pop();
            }
            AppInput::Enter => {
                let name = app.new_profile_name.trim().to_string();
                if name.is_empty() {
                    app.new_profile_error = Some("Profile name must not be empty.".into());
                } else if let Err(e) = validate_profile_name(&name) {
                    app.new_profile_error = Some(format!("{e}"));
                } else if app.profiles.iter().any(|p| p.eq_ignore_ascii_case(&name)) {
                    app.new_profile_error = Some(format!("Profile '{name}' already exists."));
                } else {
                    app.new_profile_name = name;
                    app.new_profile_error = None;
                    app.screen = Screen::NewProfile { step: 1 };
                }
            }
            _ => {}
        },
        1 => match input {
            AppInput::Esc => {
                let _ = std::mem::take(&mut app.new_profile_pw1);
                app.new_profile_error = None;
                app.screen = Screen::NewProfile { step: 0 };
            }
            AppInput::Char(c) => {
                app.new_profile_pw1.push(c);
                app.new_profile_error = None;
            }
            AppInput::Backspace => {
                app.new_profile_pw1.pop();
            }
            AppInput::Enter => {
                if app.new_profile_pw1.is_empty() {
                    app.new_profile_error = Some("Password must not be empty.".into());
                } else {
                    app.new_profile_error = None;
                    app.screen = Screen::NewProfile { step: 2 };
                }
            }
            _ => {}
        },
        _ => match input {
            AppInput::Esc => {
                let _ = std::mem::take(&mut app.new_profile_pw2);
                app.new_profile_error = None;
                app.screen = Screen::NewProfile { step: 1 };
            }
            AppInput::Char(c) => {
                app.new_profile_pw2.push(c);
                app.new_profile_error = None;
            }
            AppInput::Backspace => {
                app.new_profile_pw2.pop();
            }
            AppInput::Enter => {
                if app.new_profile_pw1 != app.new_profile_pw2 {
                    app.new_profile_error = Some("Passwords do not match — try again.".into());
                    let _ = std::mem::take(&mut app.new_profile_pw1);
                    let _ = std::mem::take(&mut app.new_profile_pw2);
                    app.screen = Screen::NewProfile { step: 1 };
                } else {
                    let name = app.new_profile_name.clone();
                    let pw = std::mem::take(&mut app.new_profile_pw1);
                    let _ = std::mem::take(&mut app.new_profile_pw2);
                    match Vault::create(&vault_path(&name), pw.as_bytes()) {
                        Ok(vault) => {
                            AuditLog::new(&audit_log_path(&name))
                                .append(&AuditEntry::success(&name, "create", None))
                                .ok();
                            match list_profiles() {
                                Ok(profiles) => app.profiles = profiles,
                                Err(_) => {
                                    if !app.profiles.iter().any(|p| p == &name) {
                                        app.profiles.push(name.clone());
                                    }
                                }
                            }
                            app.activate_session(name.clone(), vault);
                            *password_store = Some(pw);
                            app.set_status(format!("✓ Profile '{name}' created."));
                        }
                        Err(e) => {
                            app.new_profile_error = Some(format!("Create failed: {e}"));
                            let _ = std::mem::take(&mut app.new_profile_pw2);
                        }
                    }
                }
            }
            _ => {}
        },
    }
    false
}

// ── History viewer ────────────────────────────────────────────────────────────

fn dispatch_history(app: &mut App, input: AppInput) -> bool {
    match input {
        AppInput::Char('q') | AppInput::Esc => {
            app.history_entries.clear();
            app.screen = Screen::Dashboard;
        }
        AppInput::Down | AppInput::Char('j') => {
            if app.history_scroll + 1 < app.history_entries.len() {
                app.history_scroll += 1;
            }
        }
        AppInput::Up | AppInput::Char('k') => {
            app.history_scroll = app.history_scroll.saturating_sub(1);
        }
        _ => {}
    }
    false
}

// ── Move / rename secret modal ───────────────────────────────────────────────

fn dispatch_move_secret(
    app: &mut App,
    input: AppInput,
    source: &str,
    _password_store: &mut Option<SensitiveString>,
) -> bool {
    match input {
        AppInput::Esc => {
            app.mv_dest_buf.clear();
            app.mv_error = None;
            app.screen = Screen::Dashboard;
        }
        AppInput::Char(c) => {
            app.mv_dest_buf.push(c);
            app.mv_error = None;
        }
        AppInput::Backspace => {
            app.mv_dest_buf.pop();
            app.mv_error = None;
        }
        AppInput::Enter => {
            let dest = app.mv_dest_buf.trim().to_string();
            if dest.is_empty() {
                app.mv_error = Some("Destination key must not be empty.".into());
                return false;
            }
            if dest == source {
                app.mv_dest_buf.clear();
                app.mv_error = None;
                app.screen = Screen::Dashboard;
                return false;
            }
            if let (Some(profile), Some(mut vault)) =
                (app.active_profile.clone(), app.session_vault.take())
            {
                match vault.rename_key(source, &dest, false) {
                    Ok(()) => {
                        AuditLog::new(&audit_log_path(&profile))
                            .append(&AuditEntry::success(
                                &profile,
                                "mv",
                                Some(&format!("{source} → {dest}")),
                            ))
                            .ok();
                        app.revealed.remove(source);
                        // Both source AND destination are now mutated keys —
                        // any pending undo at either is invalidated. ISC-18b.
                        app.finalize_pending_undo_for_key(source);
                        app.finalize_pending_undo_for_key(&dest);
                        app.reload_from_vault(&vault);
                        app.session_vault = Some(vault);
                        app.mv_dest_buf.clear();
                        app.mv_error = None;
                        app.set_status(format!("✓ Renamed '{source}' → '{dest}'"));
                        app.screen = Screen::Dashboard;
                    }
                    Err(SafeError::SecretAlreadyExists { .. }) => {
                        app.mv_error = Some(format!(
                            "'{dest}' already exists — delete it first or use a different name."
                        ));
                        app.session_vault = Some(vault);
                    }
                    Err(e) => {
                        app.mv_error = Some(format!("Move failed: {e}"));
                        app.session_vault = Some(vault);
                    }
                }
            } else {
                app.mv_error = Some("No active vault session. Log in again.".into());
            }
        }
        _ => {}
    }
    false
}

// ── Copy / move whole namespace modal ─────────────────────────────────────────

fn dispatch_ns_bulk(
    app: &mut App,
    input: AppInput,
    copy: bool,
    from_ns: &str,
    _password_store: &mut Option<SensitiveString>,
) -> bool {
    match input {
        AppInput::Esc => {
            app.mv_dest_buf.clear();
            app.mv_error = None;
            app.screen = Screen::Dashboard;
        }
        AppInput::Char(c) => {
            app.mv_dest_buf.push(c);
            app.mv_error = None;
        }
        AppInput::Backspace => {
            app.mv_dest_buf.pop();
            app.mv_error = None;
        }
        AppInput::Enter => {
            let to = app.mv_dest_buf.trim().to_string();
            if to.is_empty() {
                app.mv_error = Some("Destination namespace must not be empty.".into());
                return false;
            }
            if to == from_ns {
                app.mv_error = Some("Source and destination are the same.".into());
                return false;
            }
            if let (Some(profile), Some(mut vault)) =
                (app.active_profile.clone(), app.session_vault.take())
            {
                match plan_namespace_bulk(&vault, from_ns, &to, false) {
                    Ok(pairs) if pairs.is_empty() => {
                        app.mv_error = Some(format!("No keys under namespace '{from_ns}'."));
                        app.session_vault = Some(vault);
                    }
                    Ok(pairs) => {
                        let res = if copy {
                            apply_namespace_copy(&mut vault, &pairs)
                        } else {
                            apply_namespace_move(&mut vault, &pairs, false)
                        };
                        match res {
                            Ok(()) => {
                                let op = if copy { "ns-copy" } else { "ns-move" };
                                let detail = format!("{from_ns}->{} ({} keys)", to, pairs.len());
                                let _ = AuditLog::new(&audit_log_path(&profile))
                                    .append(&AuditEntry::success(&profile, op, Some(&detail)));
                                // Bulk mutation across many keys — invalidate
                                // any pending undo whose key matches a touched
                                // source OR destination. ISC-18b extension.
                                for (src, dst) in &pairs {
                                    app.finalize_pending_undo_for_key(src);
                                    app.finalize_pending_undo_for_key(dst);
                                }
                                app.reload_from_vault(&vault);
                                app.session_vault = Some(vault);
                                app.mv_dest_buf.clear();
                                app.mv_error = None;
                                let msg = if copy {
                                    format!(
                                        "✓ Copied {} key(s) {}/ → {}/",
                                        pairs.len(),
                                        from_ns,
                                        to
                                    )
                                } else {
                                    format!("✓ Moved {} key(s) {}/ → {}/", pairs.len(), from_ns, to)
                                };
                                app.set_status(msg);
                                app.screen = Screen::Dashboard;
                            }
                            Err(e) => {
                                app.mv_error = Some(e.to_string());
                                app.session_vault = Some(vault);
                            }
                        }
                    }
                    Err(e) => {
                        app.mv_error = Some(e.to_string());
                        app.session_vault = Some(vault);
                    }
                }
            } else {
                app.mv_error = Some("No active vault session. Log in again.".into());
            }
        }
        _ => {}
    }
    false
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::sensitive_string;
    use crate::app::Theme;
    use tempfile::tempdir;

    fn make_app() -> App {
        App {
            screen: Screen::Dashboard,
            profiles: vec!["test".into()],
            profile_cursor: 0,
            active_profile: Some("test".into()),
            session_vault: None,
            secret_keys: vec!["DB_URL".into(), "API_KEY".into()],
            secret_cursor: 0,
            search_mode: false,
            search_query: String::new(),
            password_buf: empty_sensitive(),
            login_error: None,
            login_session_password: None,
            pending_keyring_master_password: None,
            login_focus: LoginFocus::Password,
            edit_key: String::new(),
            edit_value: String::new(),
            edit_focus: EditFocus::Key,
            edit_error: None,
            revealed: std::collections::HashMap::new(),
            rotate_step: 0,
            rotate_new1: empty_sensitive(),
            rotate_new2: empty_sensitive(),
            rotate_error: None,
            snapshot_paths: Vec::new(),
            snapshot_cursor: 0,
            audit_lines: Vec::new(),
            audit_scroll: 0,
            new_profile_name: String::new(),
            new_profile_pw1: empty_sensitive(),
            new_profile_pw2: empty_sensitive(),
            new_profile_error: None,
            status_message: None,
            help_visible: false,
            pinned_keys: std::collections::HashSet::new(),
            collapsed_namespaces: std::collections::HashSet::new(),
            update_available: None,
            update_rx: None,
            theme: Theme::Dark,
            history_entries: Vec::new(),
            history_scroll: 0,
            mv_dest_buf: String::new(),
            mv_error: None,
            clipboard: Box::new(crate::clipboard::SystemClipboard::new()),
            copied_at: None,
            copied_value: None,
            pending_undo: None,
            last_undo_status_secs: None,
        }
    }

    fn make_login_app() -> App {
        App {
            screen: Screen::Login,
            profiles: vec!["default".into()],
            profile_cursor: 0,
            active_profile: None,
            session_vault: None,
            secret_keys: Vec::new(),
            secret_cursor: 0,
            search_mode: false,
            search_query: String::new(),
            password_buf: empty_sensitive(),
            login_error: None,
            login_session_password: None,
            pending_keyring_master_password: None,
            login_focus: LoginFocus::Password,
            edit_key: String::new(),
            edit_value: String::new(),
            edit_focus: EditFocus::Key,
            edit_error: None,
            revealed: std::collections::HashMap::new(),
            rotate_step: 0,
            rotate_new1: empty_sensitive(),
            rotate_new2: empty_sensitive(),
            rotate_error: None,
            snapshot_paths: Vec::new(),
            snapshot_cursor: 0,
            audit_lines: Vec::new(),
            audit_scroll: 0,
            new_profile_name: String::new(),
            new_profile_pw1: empty_sensitive(),
            new_profile_pw2: empty_sensitive(),
            new_profile_error: None,
            status_message: None,
            help_visible: false,
            pinned_keys: std::collections::HashSet::new(),
            collapsed_namespaces: std::collections::HashSet::new(),
            update_available: None,
            update_rx: None,
            theme: Theme::Dark,
            history_entries: Vec::new(),
            history_scroll: 0,
            mv_dest_buf: String::new(),
            mv_error: None,
            clipboard: Box::new(crate::clipboard::SystemClipboard::new()),
            copied_at: None,
            copied_value: None,
            pending_undo: None,
            last_undo_status_secs: None,
        }
    }

    #[test]
    fn login_password_field_accepts_n_as_first_character() {
        let mut app = make_login_app();
        let mut pw: Option<SensitiveString> = None;
        assert_eq!(app.login_focus, LoginFocus::Password);
        dispatch_input(&mut app, AppInput::Char('n'), &mut pw);
        assert_eq!(app.password_buf.as_str(), "n");
        assert_eq!(app.screen, Screen::Login);
    }

    #[test]
    fn login_password_field_accepts_question_mark() {
        let mut app = make_login_app();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('?'), &mut pw);
        assert_eq!(app.password_buf.as_str(), "?");
        assert!(!app.help_visible);
    }

    #[test]
    fn login_profile_row_n_opens_new_profile_wizard() {
        let mut app = make_login_app();
        app.login_focus = LoginFocus::Profile;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('n'), &mut pw);
        assert!(matches!(app.screen, Screen::NewProfile { step: 0 }));
    }

    #[test]
    fn login_profile_row_question_opens_help() {
        let mut app = make_login_app();
        app.login_focus = LoginFocus::Profile;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('?'), &mut pw);
        assert!(app.help_visible);
    }

    #[test]
    fn login_password_field_q_is_typed_not_quit() {
        let mut app = make_login_app();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('q'), &mut pw);
        assert_eq!(app.password_buf.as_str(), "q");
    }

    #[test]
    fn ctrl_c_always_quits() {
        let mut app = make_app();
        let mut pw: Option<SensitiveString> = None;
        assert!(dispatch_input(&mut app, AppInput::CtrlC, &mut pw));
    }

    #[test]
    fn q_quits_on_dashboard() {
        let mut app = make_app();
        let mut pw: Option<SensitiveString> = None;
        assert!(dispatch_input(&mut app, AppInput::Char('q'), &mut pw));
    }

    #[test]
    fn navigate_dashboard_down_wraps() {
        let mut app = make_app();
        let mut pw: Option<SensitiveString> = None;
        let len = app.visible_entries().len();
        // Move up from 0 → wraps to last entry
        dispatch_input(&mut app, AppInput::Up, &mut pw);
        assert_eq!(app.secret_cursor, len - 1);
    }

    #[test]
    fn search_mode_entered_on_slash() {
        let mut app = make_app();
        let mut pw: Option<SensitiveString> = None;
        assert!(!app.search_mode);
        dispatch_input(&mut app, AppInput::Char('/'), &mut pw);
        assert!(app.search_mode);
    }

    #[test]
    fn help_overlay_toggled_by_question_mark() {
        let mut app = make_app();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('?'), &mut pw);
        assert!(app.help_visible);
        // '?' dismisses when overlay is visible
        dispatch_input(&mut app, AppInput::Char('?'), &mut pw);
        assert!(!app.help_visible);
    }

    #[test]
    fn theme_cycles_on_capital_t() {
        let mut app = make_app();
        let mut pw: Option<SensitiveString> = None;
        assert_eq!(app.theme, Theme::Dark);
        dispatch_input(&mut app, AppInput::Char('T'), &mut pw);
        assert_eq!(app.theme, Theme::Light);
        dispatch_input(&mut app, AppInput::Char('T'), &mut pw);
        assert_eq!(app.theme, Theme::HighContrast);
        dispatch_input(&mut app, AppInput::Char('T'), &mut pw);
        assert_eq!(app.theme, Theme::Dark);
    }

    #[test]
    fn audit_log_navigation_and_back() {
        let mut app = make_app();
        app.screen = Screen::AuditLog;
        app.audit_lines = vec!["line1".into(), "line2".into(), "line3".into()];
        app.audit_scroll = 0;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Down, &mut pw);
        assert_eq!(app.audit_scroll, 1);
        dispatch_input(&mut app, AppInput::Up, &mut pw);
        assert_eq!(app.audit_scroll, 0);
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
    }

    #[test]
    fn history_navigation_and_back() {
        let mut app = make_app();
        let key = "MY_KEY".to_string();
        app.screen = Screen::History { key: key.clone() };
        let now = chrono::Utc::now();
        app.history_entries = vec![(0, now), (1, now)];
        app.history_scroll = 0;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Down, &mut pw);
        assert_eq!(app.history_scroll, 1);
        // Can't scroll past end
        dispatch_input(&mut app, AppInput::Down, &mut pw);
        assert_eq!(app.history_scroll, 1);
        dispatch_input(&mut app, AppInput::Char('q'), &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
        assert!(app.history_entries.is_empty());
    }

    #[test]
    fn dashboard_n_opens_add_secret_modal() {
        let mut app = make_app();
        let mut pw = Some(sensitive_string("test-unlock-password"));
        dispatch_input(&mut app, AppInput::Char('n'), &mut pw);
        assert!(matches!(app.screen, Screen::EditSecret { is_new: true }));
        assert!(app.edit_key.is_empty());
        assert!(app.edit_value.is_empty());
        assert_eq!(app.edit_focus, EditFocus::Key);
    }

    #[test]
    fn edit_modal_esc_returns_to_dashboard() {
        let mut app = make_app();
        app.screen = Screen::EditSecret { is_new: true };
        app.edit_key = "K".into();
        app.edit_value = "secret-value".into();
        let mut pw = Some(sensitive_string("x"));
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
        assert!(app.edit_value.is_empty());
    }

    #[test]
    fn edit_modal_enter_whitespace_key_shows_error() {
        let mut app = make_app();
        app.screen = Screen::EditSecret { is_new: true };
        app.edit_key = "   ".into();
        app.edit_value.clear();
        let mut pw = Some(sensitive_string("x"));
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(matches!(app.screen, Screen::EditSecret { .. }));
        assert_eq!(app.edit_error.as_deref(), Some("KEY must not be empty."));
    }

    #[test]
    fn confirm_delete_esc_returns_to_dashboard() {
        let mut app = make_app();
        app.screen = Screen::ConfirmDelete;
        let mut pw = Some(sensitive_string("x"));
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
    }

    #[test]
    fn help_overlay_esc_dismisses() {
        let mut app = make_app();
        app.help_visible = true;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert!(!app.help_visible);
    }

    #[test]
    fn search_mode_slash_then_esc_exits_search() {
        let mut app = make_app();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('/'), &mut pw);
        assert!(app.search_mode);
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert!(!app.search_mode);
        assert!(app.search_query.is_empty());
    }

    #[test]
    fn profile_switch_clears_active_session() {
        let dir = tempdir().unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
            let profile = "test";
            let vault_file = vault_path(profile);
            let vault = Vault::create(&vault_file, b"pw").unwrap();

            let mut app = make_app();
            app.active_profile = Some(profile.into());
            app.session_vault = Some(vault);
            app.secret_keys = vec!["SECRET".into()];
            app.revealed.insert(
                "SECRET".into(),
                Reveal {
                    value: sensitive_string("value"),
                    revealed_at: std::time::Instant::now(),
                },
            );
            app.search_mode = false;
            app.search_query = "sec".into();
            let mut pw = Some(sensitive_string("pw"));

            dispatch_input(&mut app, AppInput::Char('p'), &mut pw);

            assert_eq!(app.screen, Screen::Login);
            assert!(app.active_profile.is_none());
            assert!(app.session_vault.is_none());
            assert!(app.secret_keys.is_empty());
            assert!(app.revealed.is_empty());
            assert!(!app.search_mode);
            assert!(app.search_query.is_empty());
            assert!(pw.is_none());
        });
    }

    #[test]
    fn edit_save_uses_active_session_vault_without_password_store() {
        let dir = tempdir().unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
            let profile = "test";
            let vault_file = vault_path(profile);
            let vault = Vault::create(&vault_file, b"pw").unwrap();

            let mut app = make_app();
            app.active_profile = Some(profile.into());
            app.session_vault = Some(vault);
            app.screen = Screen::EditSecret { is_new: true };
            app.edit_key = "SECRET".into();
            app.edit_value = "value".into();
            let mut pw = None;

            dispatch_input(&mut app, AppInput::Enter, &mut pw);

            assert_eq!(app.screen, Screen::Dashboard);
            assert!(app.edit_error.is_none());
            assert_eq!(
                &*app.session_vault.as_ref().unwrap().get("SECRET").unwrap(),
                "value"
            );
            assert!(
                pw.is_none(),
                "save should not require reopening via password"
            );
        });
    }

    #[test]
    fn dashboard_c_on_namespace_header_opens_copy_namespace_modal() {
        let mut app = make_app();
        app.secret_keys = vec!["prod/A".into()];
        app.collapsed_namespaces.clear();
        app.secret_cursor = 0;
        let mut pw = None;
        dispatch_input(&mut app, AppInput::Char('c'), &mut pw);
        match &app.screen {
            Screen::NsBulk { copy, from } => {
                assert!(*copy);
                assert_eq!(from, "prod");
            }
            _ => panic!("expected NsBulk copy"),
        }
    }

    #[test]
    fn dashboard_m_on_key_row_opens_single_move_modal() {
        let mut app = make_app();
        app.secret_keys = vec!["prod/A".into()];
        app.collapsed_namespaces.clear();
        app.secret_cursor = 1;
        let mut pw = None;
        dispatch_input(&mut app, AppInput::Char('m'), &mut pw);
        match &app.screen {
            Screen::MoveSecret { source } => assert_eq!(source, "prod/A"),
            _ => panic!("expected MoveSecret"),
        }
    }

    #[test]
    fn dashboard_m_on_namespace_header_opens_move_namespace_modal() {
        let mut app = make_app();
        app.secret_keys = vec!["prod/A".into()];
        app.collapsed_namespaces.clear();
        app.secret_cursor = 0;
        let mut pw = None;
        dispatch_input(&mut app, AppInput::Char('m'), &mut pw);
        match &app.screen {
            Screen::NsBulk { copy, from } => {
                assert!(!*copy);
                assert_eq!(from, "prod");
            }
            _ => panic!("expected NsBulk move"),
        }
    }

    // ── Rotate password modal ─────────────────────────────────────────────────

    #[test]
    fn rotate_password_esc_returns_to_dashboard() {
        let mut app = make_app();
        app.screen = Screen::RotatePassword;
        let mut pw = Some(sensitive_string("x"));
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
        assert!(app.rotate_error.is_none());
    }

    #[test]
    fn rotate_password_step0_empty_shows_error() {
        let mut app = make_app();
        app.screen = Screen::RotatePassword;
        app.rotate_step = 0;
        let mut pw = Some(sensitive_string("x"));
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(matches!(app.screen, Screen::RotatePassword));
        assert_eq!(
            app.rotate_error.as_deref(),
            Some("Password must not be empty.")
        );
    }

    #[test]
    fn rotate_password_step0_nonempty_advances_to_step1() {
        let mut app = make_app();
        app.screen = Screen::RotatePassword;
        app.rotate_step = 0;
        let mut pw = Some(sensitive_string("x"));
        dispatch_input(&mut app, AppInput::Char('s'), &mut pw);
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert_eq!(app.rotate_step, 1);
        assert!(app.rotate_error.is_none());
    }

    #[test]
    fn rotate_password_step1_mismatch_shows_error_and_resets() {
        let mut app = make_app();
        app.screen = Screen::RotatePassword;
        app.rotate_step = 1;
        app.rotate_new1 = sensitive_string("abc");
        app.rotate_new2 = sensitive_string("xyz");
        let mut pw = Some(sensitive_string("x"));
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert_eq!(app.rotate_step, 0);
        assert_eq!(app.rotate_error.as_deref(), Some("Passwords do not match."));
        assert!(app.rotate_new1.is_empty());
        assert!(app.rotate_new2.is_empty());
    }

    #[test]
    fn rotate_password_step1_no_vault_shows_error() {
        let mut app = make_app();
        app.screen = Screen::RotatePassword;
        app.rotate_step = 1;
        app.rotate_new1 = sensitive_string("newpw");
        app.rotate_new2 = sensitive_string("newpw");
        app.session_vault = None;
        app.active_profile = None;
        let mut pw = Some(sensitive_string("x"));
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(app.rotate_error.is_some());
    }

    // ── Snapshot restore picker ───────────────────────────────────────────────

    #[test]
    fn snapshot_restore_esc_clears_paths_and_returns_dashboard() {
        let mut app = make_app();
        app.screen = Screen::SnapshotRestore;
        app.snapshot_paths = vec![std::path::PathBuf::from("/tmp/test.snap.json")];
        let mut pw = Some(sensitive_string("x"));
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
        assert!(app.snapshot_paths.is_empty());
    }

    #[test]
    fn snapshot_restore_down_clamped_at_last() {
        let mut app = make_app();
        app.screen = Screen::SnapshotRestore;
        app.snapshot_paths = vec![
            std::path::PathBuf::from("/a"),
            std::path::PathBuf::from("/b"),
        ];
        app.snapshot_cursor = 0;
        let mut pw = Some(sensitive_string("x"));
        dispatch_input(&mut app, AppInput::Down, &mut pw);
        assert_eq!(app.snapshot_cursor, 1);
        // Should not exceed last index.
        dispatch_input(&mut app, AppInput::Down, &mut pw);
        assert_eq!(app.snapshot_cursor, 1);
    }

    #[test]
    fn snapshot_restore_up_stays_at_zero() {
        let mut app = make_app();
        app.screen = Screen::SnapshotRestore;
        app.snapshot_paths = vec![std::path::PathBuf::from("/a")];
        app.snapshot_cursor = 0;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Up, &mut pw);
        assert_eq!(app.snapshot_cursor, 0);
    }

    #[test]
    fn snapshot_restore_enter_without_password_is_noop() {
        let mut app = make_app();
        app.screen = Screen::SnapshotRestore;
        app.snapshot_paths = vec![std::path::PathBuf::from("/tmp/snap.json")];
        app.snapshot_cursor = 0;
        let mut pw: Option<SensitiveString> = None;
        // No password_store → Enter is a no-op (the handler checks all three conditions)
        let quit = dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(!quit);
    }

    // ── New-profile wizard ────────────────────────────────────────────────────

    #[test]
    fn new_profile_step0_esc_returns_to_login() {
        let mut app = make_login_app();
        app.screen = Screen::NewProfile { step: 0 };
        app.profiles = vec!["existing".into()];
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert_eq!(app.screen, Screen::Login);
        assert!(app.new_profile_error.is_none());
    }

    #[test]
    fn new_profile_step0_empty_name_shows_error() {
        let mut app = make_login_app();
        app.screen = Screen::NewProfile { step: 0 };
        app.new_profile_name.clear();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(matches!(app.screen, Screen::NewProfile { step: 0 }));
        assert_eq!(
            app.new_profile_error.as_deref(),
            Some("Profile name must not be empty.")
        );
    }

    #[test]
    fn new_profile_step0_valid_name_advances_to_step1() {
        let mut app = make_login_app();
        app.screen = Screen::NewProfile { step: 0 };
        app.profiles = vec![];
        let mut pw: Option<SensitiveString> = None;
        // Type "myapp" then Enter
        for c in "myapp".chars() {
            dispatch_input(&mut app, AppInput::Char(c), &mut pw);
        }
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(matches!(app.screen, Screen::NewProfile { step: 1 }));
        assert!(app.new_profile_error.is_none());
    }

    #[test]
    fn new_profile_step1_esc_goes_to_step0() {
        let mut app = make_login_app();
        app.screen = Screen::NewProfile { step: 1 };
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert!(matches!(app.screen, Screen::NewProfile { step: 0 }));
        assert!(app.new_profile_pw1.is_empty());
    }

    #[test]
    fn new_profile_step1_empty_password_shows_error() {
        let mut app = make_login_app();
        app.screen = Screen::NewProfile { step: 1 };
        app.new_profile_pw1 = empty_sensitive();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(matches!(app.screen, Screen::NewProfile { step: 1 }));
        assert_eq!(
            app.new_profile_error.as_deref(),
            Some("Password must not be empty.")
        );
    }

    #[test]
    fn new_profile_step1_nonempty_advances_to_step2() {
        let mut app = make_login_app();
        app.screen = Screen::NewProfile { step: 1 };
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('p'), &mut pw);
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(matches!(app.screen, Screen::NewProfile { step: 2 }));
    }

    #[test]
    fn new_profile_step2_esc_goes_to_step1() {
        let mut app = make_login_app();
        app.screen = Screen::NewProfile { step: 2 };
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert!(matches!(app.screen, Screen::NewProfile { step: 1 }));
        assert!(app.new_profile_pw2.is_empty());
    }

    #[test]
    fn new_profile_step2_mismatch_shows_error_and_resets_to_step1() {
        let mut app = make_login_app();
        app.screen = Screen::NewProfile { step: 2 };
        app.new_profile_pw1 = sensitive_string("abc");
        app.new_profile_pw2 = sensitive_string("xyz");
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(matches!(app.screen, Screen::NewProfile { step: 1 }));
        assert_eq!(
            app.new_profile_error.as_deref(),
            Some("Passwords do not match — try again.")
        );
    }

    #[test]
    fn new_profile_step2_match_creates_vault_and_transitions_to_dashboard() {
        let dir = tempdir().unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
            let mut app = make_login_app();
            app.screen = Screen::NewProfile { step: 2 };
            app.new_profile_name = "myvault".into();
            app.profiles = vec![];
            app.new_profile_pw1 = sensitive_string("secret123");
            app.new_profile_pw2 = sensitive_string("secret123");
            let mut pw: Option<SensitiveString> = None;
            dispatch_input(&mut app, AppInput::Enter, &mut pw);
            assert_eq!(app.screen, Screen::Dashboard);
            assert!(app.active_profile.as_deref() == Some("myvault"));
            assert!(app.session_vault.is_some());
        });
    }

    // ── Move secret modal ─────────────────────────────────────────────────────

    #[test]
    fn move_secret_esc_returns_to_dashboard() {
        let mut app = make_app();
        app.screen = Screen::MoveSecret {
            source: "prod/A".into(),
        };
        app.mv_dest_buf = "staging/A".into();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
        assert!(app.mv_dest_buf.is_empty());
    }

    #[test]
    fn move_secret_typing_fills_buffer() {
        let mut app = make_app();
        app.screen = Screen::MoveSecret { source: "A".into() };
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('x'), &mut pw);
        dispatch_input(&mut app, AppInput::Char('y'), &mut pw);
        assert_eq!(app.mv_dest_buf, "xy");
    }

    #[test]
    fn move_secret_backspace_removes_char() {
        let mut app = make_app();
        app.screen = Screen::MoveSecret { source: "A".into() };
        app.mv_dest_buf = "abc".into();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Backspace, &mut pw);
        assert_eq!(app.mv_dest_buf, "ab");
    }

    #[test]
    fn move_secret_enter_empty_shows_error() {
        let mut app = make_app();
        app.screen = Screen::MoveSecret { source: "A".into() };
        app.mv_dest_buf.clear();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(matches!(app.screen, Screen::MoveSecret { .. }));
        assert_eq!(
            app.mv_error.as_deref(),
            Some("Destination key must not be empty.")
        );
    }

    #[test]
    fn move_secret_enter_same_as_source_returns_to_dashboard() {
        let mut app = make_app();
        app.screen = Screen::MoveSecret {
            source: "prod/A".into(),
        };
        app.mv_dest_buf = "prod/A".into();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
        assert!(app.mv_error.is_none());
    }

    #[test]
    fn move_secret_enter_no_vault_shows_error() {
        let mut app = make_app();
        app.screen = Screen::MoveSecret { source: "A".into() };
        app.mv_dest_buf = "B".into();
        app.session_vault = None;
        app.active_profile = None;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(app.mv_error.is_some());
    }

    // ── Namespace bulk copy/move modal ────────────────────────────────────────

    #[test]
    fn ns_bulk_esc_returns_to_dashboard() {
        let mut app = make_app();
        app.screen = Screen::NsBulk {
            copy: true,
            from: "prod".into(),
        };
        app.mv_dest_buf = "staging".into();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
        assert!(app.mv_dest_buf.is_empty());
    }

    #[test]
    fn ns_bulk_enter_empty_destination_shows_error() {
        let mut app = make_app();
        app.screen = Screen::NsBulk {
            copy: true,
            from: "prod".into(),
        };
        app.mv_dest_buf.clear();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert_eq!(
            app.mv_error.as_deref(),
            Some("Destination namespace must not be empty.")
        );
    }

    #[test]
    fn ns_bulk_enter_same_as_source_shows_error() {
        let mut app = make_app();
        app.screen = Screen::NsBulk {
            copy: false,
            from: "prod".into(),
        };
        app.mv_dest_buf = "prod".into();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert_eq!(
            app.mv_error.as_deref(),
            Some("Source and destination are the same.")
        );
    }

    #[test]
    fn ns_bulk_enter_no_vault_shows_error() {
        let mut app = make_app();
        app.screen = Screen::NsBulk {
            copy: true,
            from: "prod".into(),
        };
        app.mv_dest_buf = "staging".into();
        app.session_vault = None;
        app.active_profile = None;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Enter, &mut pw);
        assert!(app.mv_error.is_some());
    }

    // ── Dashboard miscellaneous ───────────────────────────────────────────────

    #[test]
    fn dashboard_d_on_selected_key_opens_confirm_delete() {
        let mut app = make_app();
        // secret_keys = ["DB_URL", "API_KEY"]; cursor=0 → DB_URL is a root key
        app.secret_keys = vec!["DB_URL".into()];
        app.secret_cursor = 0;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('d'), &mut pw);
        assert_eq!(app.screen, Screen::ConfirmDelete);
    }

    #[test]
    fn dashboard_capital_r_with_vault_opens_rotate_modal() {
        let dir = tempdir().unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
            let vault =
                tsafe_core::vault::Vault::create(&tsafe_core::profile::vault_path("test"), b"pw")
                    .unwrap();
            let mut app = make_app();
            app.session_vault = Some(vault);
            let mut pw: Option<SensitiveString> = None;
            dispatch_input(&mut app, AppInput::Char('R'), &mut pw);
            assert_eq!(app.screen, Screen::RotatePassword);
            assert_eq!(app.rotate_step, 0);
        });
    }

    #[test]
    fn dashboard_capital_r_without_vault_is_noop() {
        let mut app = make_app();
        app.session_vault = None;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('R'), &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
    }

    // ── Login screen ──────────────────────────────────────────────────────────

    #[test]
    fn login_tab_switches_focus_between_profile_and_password() {
        let mut app = make_login_app();
        app.login_focus = LoginFocus::Password;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Tab, &mut pw);
        assert_eq!(app.login_focus, LoginFocus::Profile);
        dispatch_input(&mut app, AppInput::Tab, &mut pw);
        assert_eq!(app.login_focus, LoginFocus::Password);
    }

    #[test]
    fn login_down_on_profile_focus_cycles_cursor() {
        let mut app = make_login_app();
        app.profiles = vec!["alpha".into(), "beta".into()];
        app.profile_cursor = 0;
        app.login_focus = LoginFocus::Profile;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Down, &mut pw);
        assert_eq!(app.profile_cursor, 1);
        // Wrap from last → first
        dispatch_input(&mut app, AppInput::Down, &mut pw);
        assert_eq!(app.profile_cursor, 0);
    }

    #[test]
    fn login_up_on_profile_focus_cycles_cursor() {
        let mut app = make_login_app();
        app.profiles = vec!["alpha".into(), "beta".into()];
        app.profile_cursor = 0;
        app.login_focus = LoginFocus::Profile;
        let mut pw: Option<SensitiveString> = None;
        // Up from first → last
        dispatch_input(&mut app, AppInput::Up, &mut pw);
        assert_eq!(app.profile_cursor, 1);
    }

    #[test]
    fn login_backspace_removes_last_char_from_password_buf() {
        let mut app = make_login_app();
        app.login_focus = LoginFocus::Password;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('a'), &mut pw);
        dispatch_input(&mut app, AppInput::Char('b'), &mut pw);
        dispatch_input(&mut app, AppInput::Backspace, &mut pw);
        assert_eq!(app.password_buf.as_str(), "a");
    }

    #[test]
    fn login_esc_clears_password_buffer() {
        let mut app = make_login_app();
        app.login_focus = LoginFocus::Password;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('s'), &mut pw);
        dispatch_input(&mut app, AppInput::Char('e'), &mut pw);
        dispatch_input(&mut app, AppInput::Esc, &mut pw);
        assert!(app.password_buf.is_empty());
    }

    #[test]
    fn login_profile_row_q_quits_when_password_empty() {
        let mut app = make_login_app();
        app.login_focus = LoginFocus::Profile;
        let mut pw: Option<SensitiveString> = None;
        let quit = dispatch_input(&mut app, AppInput::Char('q'), &mut pw);
        assert!(quit);
    }

    // ── Search mode ───────────────────────────────────────────────────────────

    #[test]
    fn search_typing_appends_to_query() {
        let mut app = make_app();
        app.search_mode = true;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('a'), &mut pw);
        dispatch_input(&mut app, AppInput::Char('p'), &mut pw);
        dispatch_input(&mut app, AppInput::Char('i'), &mut pw);
        assert_eq!(app.search_query, "api");
    }

    #[test]
    fn search_backspace_removes_char_from_query() {
        let mut app = make_app();
        app.search_mode = true;
        app.search_query = "api".into();
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Backspace, &mut pw);
        assert_eq!(app.search_query, "ap");
    }

    // ── Confirm delete ────────────────────────────────────────────────────────

    #[test]
    fn confirm_delete_n_returns_to_dashboard() {
        let mut app = make_app();
        app.screen = Screen::ConfirmDelete;
        let mut pw: Option<SensitiveString> = None;
        dispatch_input(&mut app, AppInput::Char('n'), &mut pw);
        assert_eq!(app.screen, Screen::Dashboard);
    }

    #[test]
    fn confirm_delete_y_with_vault_deletes_secret_and_returns_to_dashboard() {
        // Exact repro of bug: brigi-test profile, 3 keys, select last, press d, press y.
        // The bug: screen stayed on ConfirmDelete with no delete.
        let dir = tempdir().unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
            let mut vault = tsafe_core::vault::Vault::create(
                &tsafe_core::profile::vault_path("brigi-test"),
                b"pw",
            )
            .unwrap();
            vault
                .set("DEMO_API_KEY", "v1", std::collections::HashMap::new())
                .unwrap();
            vault
                .set("DEMO_API_KEY2", "v2", std::collections::HashMap::new())
                .unwrap();
            vault
                .set("DEMO_API_KEY3", "v3", std::collections::HashMap::new())
                .unwrap();

            let mut app = make_app();
            app.active_profile = Some("brigi-test".to_string());
            app.reload_from_vault(&vault);
            app.session_vault = Some(vault);
            app.screen = Screen::Dashboard;

            // Select DEMO_API_KEY3 (last in sorted list)
            app.secret_cursor = 2;
            assert_eq!(
                app.selected_key().as_deref(),
                Some("DEMO_API_KEY3"),
                "cursor should point at DEMO_API_KEY3"
            );

            // Press 'd' → ConfirmDelete
            let mut pw: Option<SensitiveString> = None;
            dispatch_input(&mut app, AppInput::Char('d'), &mut pw);
            assert_eq!(
                app.screen,
                Screen::ConfirmDelete,
                "should be on ConfirmDelete after 'd'"
            );

            // Press 'y' → should delete and return to Dashboard
            dispatch_input(&mut app, AppInput::Char('y'), &mut pw);
            assert_eq!(
                app.screen,
                Screen::Dashboard,
                "BUG: screen stayed on ConfirmDelete after 'y'"
            );

            // Key should be gone
            let remaining = app.secret_keys.clone();
            assert!(
                !remaining.contains(&"DEMO_API_KEY3".to_string()),
                "DEMO_API_KEY3 should have been deleted; remaining: {remaining:?}"
            );
            assert_eq!(remaining.len(), 2, "should have 2 keys left");
        });
    }

    #[test]
    fn confirm_delete_capital_y_also_deletes() {
        // Regression: 'Y' (Shift+y / CapsLock) used to be silently ignored,
        // leaving user stranded on ConfirmDelete with no feedback.
        let dir = tempdir().unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
            let mut vault =
                tsafe_core::vault::Vault::create(&tsafe_core::profile::vault_path("t"), b"pw")
                    .unwrap();
            vault
                .set("TARGET", "v", std::collections::HashMap::new())
                .unwrap();

            let mut app = make_app();
            app.active_profile = Some("t".to_string());
            app.reload_from_vault(&vault);
            app.session_vault = Some(vault);
            app.screen = Screen::ConfirmDelete;
            app.secret_cursor = 0;

            let mut pw: Option<SensitiveString> = None;
            dispatch_input(&mut app, AppInput::Char('Y'), &mut pw);

            assert_eq!(
                app.screen,
                Screen::Dashboard,
                "BUG: capital Y did not confirm delete"
            );
            assert!(app.secret_keys.is_empty(), "TARGET should be deleted");
        });
    }

    #[test]
    fn confirm_delete_enter_also_confirms() {
        let dir = tempdir().unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path().as_os_str()), || {
            let mut vault =
                tsafe_core::vault::Vault::create(&tsafe_core::profile::vault_path("t"), b"pw")
                    .unwrap();
            vault
                .set("TARGET", "v", std::collections::HashMap::new())
                .unwrap();

            let mut app = make_app();
            app.active_profile = Some("t".to_string());
            app.reload_from_vault(&vault);
            app.session_vault = Some(vault);
            app.screen = Screen::ConfirmDelete;
            app.secret_cursor = 0;

            let mut pw: Option<SensitiveString> = None;
            dispatch_input(&mut app, AppInput::Enter, &mut pw);

            assert_eq!(app.screen, Screen::Dashboard, "Enter should confirm delete");
            assert!(app.secret_keys.is_empty(), "TARGET should be deleted");
        });
    }

    // ── Screen::footer_hints ──────────────────────────────────────────────────

    #[test]
    fn footer_hints_returns_nonempty_slices_for_all_screens() {
        let screens = vec![
            Screen::Login,
            Screen::Dashboard,
            Screen::EditSecret { is_new: true },
            Screen::EditSecret { is_new: false },
            Screen::ConfirmDelete,
            Screen::RotatePassword,
            Screen::SnapshotRestore,
            Screen::AuditLog,
            Screen::NewProfile { step: 0 },
            Screen::History { key: "K".into() },
            Screen::MoveSecret { source: "K".into() },
            Screen::NsBulk {
                copy: true,
                from: "ns".into(),
            },
        ];
        for screen in &screens {
            assert!(
                !screen.footer_hints().is_empty(),
                "footer_hints empty for {screen:?}"
            );
        }
        // Quitting is the only screen allowed to have an empty footer.
        assert!(Screen::Quitting.footer_hints().is_empty());
    }
}
