use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::{Duration, Instant};
use tsafe_core::errors::SafeError;
use tsafe_core::profile::{get_default_profile, list_profiles, vault_path};
use tsafe_core::vault::Vault;
use zeroize::Zeroizing;

use crate::clipboard::{ClipboardBackend, ClipboardError, ClipboardOutcome, SystemClipboard};
use tsafe_core::audit::{AuditClipboardContext, AuditContext, AuditEntry, AuditLog};
use tsafe_core::profile::audit_log_path;

/// Re-exported alias for the zeroize-on-drop string used throughout the TUI.
/// Public so integration tests + sibling crates can reference the type by
/// name (the alias was previously `pub(crate)`, which forced a
/// `sensitive_string_for_test` shim — kept for backward-compat).
pub type SensitiveString = Zeroizing<String>;

pub(crate) fn empty_sensitive() -> SensitiveString {
    Zeroizing::new(String::new())
}

pub(crate) fn sensitive_string(value: impl Into<String>) -> SensitiveString {
    Zeroizing::new(value.into())
}

/// Test entry point — historical shim from when `SensitiveString` was
/// `pub(crate)`. Kept for backward-compat with existing tests; new tests
/// can use `SensitiveString::new` (or the alias) directly.
pub fn sensitive_string_for_test(value: impl Into<String>) -> SensitiveString {
    sensitive_string(value)
}

/// How long a yanked secret stays on the system clipboard before
/// [`App::maybe_expire_clipboard`] attempts to clear it. Compared against
/// [`std::time::Instant::elapsed`] each tick.
pub const CLIPBOARD_TTL: Duration = Duration::from_secs(30);

/// How long a revealed secret stays unredacted on the screen before
/// [`App::maybe_expire_reveals`] auto-conceals it. Compared against each
/// [`Reveal::revealed_at`] every tick. The TTL runs from the `r` press
/// and is **not** extended on user activity — predictable beats
/// accommodating; press `r` again to extend.
///
/// Hardcoded to 60s in v1; config-file tuning (`reveal_ttl_secs` alongside
/// the existing `tui.theme` key) is a planned follow-up.
pub const REVEAL_TTL: Duration = Duration::from_secs(60);

/// One revealed secret in the TUI's reveal map. Holds the plaintext value
/// in a [`SensitiveString`] (zeroizes on drop) and the [`Instant`] of the
/// `r` press for TTL comparison. The pair lives in a single map so the
/// state cannot drift out of sync.
///
/// **Intentionally no `Debug` derive** — `SensitiveString` would render its
/// inner `String`. The `App`-level manual `Debug` impl redacts the whole
/// `revealed` field; do not add a derive here that would bypass that
/// protection.
pub struct Reveal {
    pub value: SensitiveString,
    pub revealed_at: Instant,
}

/// How long after a confirmed delete the user can press `u` to restore the
/// entry. Compared against [`PendingUndo::deleted_at`] each tick.
pub const UNDO_TTL: Duration = Duration::from_secs(5);

/// In-memory snapshot of a just-deleted secret. Held for [`UNDO_TTL`] after
/// the user confirms a delete so they can press `u` to restore via
/// `vault.set`. The plaintext lives in a [`SensitiveString`] (zeroizes on
/// drop), and the manual [`std::fmt::Debug`] impl on [`App`] redacts the
/// whole field.
///
/// Single-pending-undo model: any subsequent vault mutation (delete, set,
/// edit, move) auto-finalizes (drops) this snapshot before proceeding, so
/// `u` cannot silently overwrite a fresh entry at the same key. A queue
/// of undoable deletes was rejected as confusing UX.
///
/// The on-disk delete is durable. If the process dies during the 5s
/// window the snapshot is lost — the entry is gone from disk with no
/// recourse. Acceptable trade-off for crash-safety of the delete itself.
///
/// **Intentionally no `Debug` derive** — `SensitiveString` would render its
/// inner `String`. The `App`-level manual `Debug` impl redacts the whole
/// `pending_undo` field; do not add a derive here that would bypass it.
pub struct PendingUndo {
    pub key: String,
    pub value: SensitiveString,
    pub tags: HashMap<String, String>,
    pub deleted_at: Instant,
}

/// Visual colour theme for the TUI.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Theme {
    #[default]
    Dark,
    Light,
    HighContrast,
}

impl FromStr for Theme {
    type Err = std::convert::Infallible;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(match s {
            "light" => Theme::Light,
            "high-contrast" => Theme::HighContrast,
            _ => Theme::Dark,
        })
    }
}

impl Theme {
    pub fn as_str(self) -> &'static str {
        match self {
            Theme::Dark => "dark",
            Theme::Light => "light",
            Theme::HighContrast => "high-contrast",
        }
    }
    pub fn cycle(self) -> Self {
        match self {
            Theme::Dark => Theme::Light,
            Theme::Light => Theme::HighContrast,
            Theme::HighContrast => Theme::Dark,
        }
    }
}

/// A single visible row in the secret list.
#[derive(Debug, Clone)]
pub enum ListEntry {
    /// Collapsible namespace group header.
    Namespace {
        name: String,
        count: usize,
        collapsed: bool,
    },
    /// A concrete secret key (full key name stored, e.g. `cds-adf/APP_PW`).
    Key(String),
}

/// Which screen is currently shown.
#[derive(Debug, Clone, PartialEq)]
pub enum Screen {
    /// Full-screen password prompt (shown on startup and on profile switch).
    Login,
    /// Main dashboard: profile list left, secret list right.
    Dashboard,
    /// Modal: add or edit a secret (KEY + VALUE fields).
    EditSecret { is_new: bool },
    /// Modal: confirm deletion of a secret.
    ConfirmDelete,
    /// Modal: rotate the master password (two-step new-password entry).
    RotatePassword,
    /// Modal: pick a snapshot to restore from a list.
    SnapshotRestore,
    /// Full-screen audit log viewer.
    AuditLog,
    /// New-profile creation wizard: step 0 = name, 1 = password, 2 = confirm.
    NewProfile { step: u8 },
    /// Application about to quit.
    Quitting,
    /// Version history for a single key.
    History { key: String },
    /// Modal: move/rename a secret (typing a destination key).
    MoveSecret { source: String },
    /// Modal: copy or move every key under a namespace prefix (`from/` → `to/`).
    NsBulk { copy: bool, from: String },
}

impl Screen {
    /// Key-hint pairs shown in the TUI header for this screen.
    pub fn footer_hints(&self) -> &'static [(&'static str, &'static str)] {
        match self {
            Screen::Login => &[
                ("Tab", "profile ⇄ password"),
                ("\u{2191}\u{2193}", "profile row"),
                ("Enter", "unlock"),
                ("Ctrl-C", "quit"),
            ],
            Screen::Dashboard => &[
                ("n", "new"),
                ("e/Enter", "edit"),
                ("d", "del"),
                ("r", "reveal"),
                ("y", "copy"),
                ("m", "move"),
                ("h", "history"),
                ("R", "rotate pw"),
                ("S", "snapshot"),
                ("/", "search"),
                ("a", "audit"),
                ("T", "theme"),
                ("p", "switch"),
                ("q", "quit"),
                ("?", "help"),
            ],
            Screen::EditSecret { .. } => &[
                ("Tab", "switch field"),
                ("Enter", "save"),
                ("Esc", "cancel"),
            ],
            Screen::ConfirmDelete => &[("y/Enter", "confirm"), ("n/Esc", "cancel")],
            Screen::RotatePassword => &[("Enter", "confirm"), ("Esc", "cancel")],
            Screen::SnapshotRestore => &[
                ("\u{2191}\u{2193}", "navigate"),
                ("Enter", "restore"),
                ("Esc", "cancel"),
            ],
            Screen::AuditLog => &[("\u{2191}\u{2193}/j/k", "scroll"), ("q/Esc", "back")],
            Screen::NewProfile { .. } => &[("Enter", "next"), ("Esc", "back")],
            Screen::History { .. } => &[("\u{2191}\u{2193}/j/k", "scroll"), ("q/Esc", "back")],
            Screen::MoveSecret { .. } => &[("Enter", "confirm"), ("Esc", "cancel")],
            Screen::NsBulk { .. } => &[("Enter", "confirm"), ("Esc", "cancel")],
            Screen::Quitting => &[],
        }
    }
}

/// Which input field is focused inside the EditSecret modal.
#[derive(Debug, Clone, PartialEq)]
pub enum EditFocus {
    Key,
    Value,
}

/// Which login row receives profile shortcuts (`n`, `?`, `q`) vs master-password keystrokes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LoginFocus {
    /// Profile list: ↑↓ change profile; `n` / `?` / `q` (when password empty) are commands.
    Profile,
    /// Master password: all printable characters go into the password buffer.
    #[default]
    Password,
}

/// Top-level application state.
pub struct App {
    // ── Navigation ────────────────────────────────────────────────────────────
    pub screen: Screen,
    pub profiles: Vec<String>,
    pub profile_cursor: usize,
    pub active_profile: Option<String>,
    pub(crate) session_vault: Option<Vault>,

    // ── Secret list ───────────────────────────────────────────────────────────
    pub secret_keys: Vec<String>,
    pub secret_cursor: usize,
    pub search_mode: bool,
    pub search_query: String,

    // ── Login ─────────────────────────────────────────────────────────────────
    pub password_buf: SensitiveString,
    pub login_error: Option<String>,
    /// On successful unlock, holds the session password (typed or from OS keyring) for `run_loop` to store.
    pub(crate) login_session_password: Option<SensitiveString>,
    /// Filled by `run_loop` after keychain read with TUI suspended (Touch ID / system auth cannot run in raw + alt screen).
    pub(crate) pending_keyring_master_password: Option<SensitiveString>,
    pub login_focus: LoginFocus,

    // ── Edit modal ────────────────────────────────────────────────────────────
    pub edit_key: String,
    pub edit_value: String,
    pub edit_focus: EditFocus,
    pub edit_error: Option<String>,

    // ── Reveal ────────────────────────────────────────────────────────────────
    /// Map of key → [`Reveal`] (only populated after explicit `r` press).
    /// Auto-concealed by [`Self::maybe_expire_reveals`] after [`REVEAL_TTL`].
    /// Single map (rather than parallel value+timestamp maps) so state
    /// cannot drift out of sync; values are [`SensitiveString`] so removal
    /// zeros the plaintext.
    pub revealed: HashMap<String, Reveal>,

    // ── Rotate password ────────────────────────────────────────────────────────
    /// Step 0 = enter new password, step 1 = confirm new password.
    pub rotate_step: u8,
    pub rotate_new1: SensitiveString,
    pub rotate_new2: SensitiveString,
    pub rotate_error: Option<String>,

    // ── Snapshot restore ──────────────────────────────────────────────────────
    /// Snapshot file paths for the active profile (sorted oldest-first).
    pub snapshot_paths: Vec<PathBuf>,
    pub snapshot_cursor: usize,

    // ── Audit log ─────────────────────────────────────────────────────────────
    pub audit_lines: Vec<String>,
    pub audit_scroll: usize,

    // ── New-profile wizard ────────────────────────────────────────────────────
    pub new_profile_name: String,
    pub new_profile_pw1: SensitiveString,
    pub new_profile_pw2: SensitiveString,
    pub new_profile_error: Option<String>,

    // ── Status bar ────────────────────────────────────────────────────────────
    pub status_message: Option<String>,

    // ── Help overlay ───────────────────────────────────────────────────────
    /// Whether the ? keyboard-reference overlay is currently visible.
    pub help_visible: bool,

    // ── Pinned keys ───────────────────────────────────────────────────────────
    /// Keys that have the `pinned=true` tag; shown at the top of lists.
    pub pinned_keys: std::collections::HashSet<String>,

    // ── Namespace grouping ────────────────────────────────────────────────────
    /// Namespace prefixes that are currently collapsed in the secret list.
    pub collapsed_namespaces: std::collections::HashSet<String>,

    // ── Update check ─────────────────────────────────────────────────────────
    /// Set to `Some(version_string)` when a newer version is available on ProGet.
    pub update_available: Option<String>,
    /// Channel receiver from the background update-check thread. `None` once consumed.
    pub update_rx: Option<std::sync::mpsc::Receiver<Option<String>>>,

    // ── Theme ──────────────────────────────────────────────────────────────────
    pub theme: Theme,

    // ── History viewer ─────────────────────────────────────────────────────────
    /// `(version_number, updated_at)` rows for the key currently shown in Screen::History.
    pub history_entries: Vec<(usize, DateTime<Utc>)>,
    pub history_scroll: usize,

    // ── Move secret ────────────────────────────────────────────────────────────
    /// Destination key name being typed in the MoveSecret modal.
    pub mv_dest_buf: String,
    pub mv_error: Option<String>,

    // ── Clipboard yank (`y`) ──────────────────────────────────────────────────
    /// System-clipboard backend. Lazily initialised on first `y` press so
    /// headless detection happens at-press, not at-startup. Tests inject a
    /// fake via [`Self::set_clipboard_backend`].
    pub(crate) clipboard: Box<dyn ClipboardBackend + Send>,
    /// Wall-clock instant the most recent yank was placed on the clipboard.
    /// `None` when no yank is currently outstanding (initial, post-expiry,
    /// post-clear). Compared against [`CLIPBOARD_TTL`] each tick. `pub` so
    /// integration tests can rewind it to assert expiry behaviour without
    /// sleeping for 30 real seconds.
    pub copied_at: Option<Instant>,
    /// Plaintext snapshot of the most recent yank, used by
    /// [`Self::maybe_expire_clipboard`] to compare-then-clear so we never
    /// clobber a value the user copied after the secret. Zeroizes on drop.
    /// Redacted by the manual [`std::fmt::Debug`] impl below.
    ///
    /// Known limitation: same-value re-copy from another tool (e.g. `op`,
    /// `bw`) presents identical bytes, so compare-then-clear will wipe it.
    /// arboard does not expose NSPasteboard `changeCount` or X11 selection
    /// sequence numbers, so byte-equality is the best signal available.
    pub copied_value: Option<SensitiveString>,

    // ── Pending undo (delete grace window) ────────────────────────────────────
    /// In-memory snapshot of a just-deleted secret, held for [`UNDO_TTL`].
    /// `None` when no delete is in the undo grace window. See [`PendingUndo`].
    /// `pub` so integration tests can rewind `deleted_at` for determinism.
    pub pending_undo: Option<PendingUndo>,
    /// The integer-second remaining value last shown in the undo-countdown
    /// status. Used by [`Self::maybe_expire_undo`] to skip re-setting the
    /// status on ticks where the countdown digit didn't change — drops
    /// per-window allocations from ~50 (every tick) to ~5 (every second)
    /// AND stops clobbering transient status messages from other handlers
    /// between integer-second boundaries.
    pub(crate) last_undo_status_secs: Option<u64>,
}

impl App {
    fn initial_profile_cursor(profiles: &[String]) -> usize {
        if profiles.is_empty() {
            return 0;
        }
        let default_profile = get_default_profile();
        profiles
            .iter()
            .position(|p| p.eq_ignore_ascii_case(&default_profile))
            .unwrap_or(0)
    }

    /// Construct a minimal App with caller-supplied profiles and screen, and no
    /// background update-check thread.  Intended for tests and tooling.
    pub fn new_for_test(profiles: Vec<String>, screen: Screen) -> Self {
        let profile_cursor = Self::initial_profile_cursor(&profiles);
        let login_focus = if profiles.is_empty() {
            LoginFocus::Profile
        } else {
            LoginFocus::Password
        };
        App {
            screen,
            profiles,
            profile_cursor,
            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,
            edit_key: String::new(),
            edit_value: String::new(),
            edit_focus: EditFocus::Key,
            edit_error: None,
            revealed: 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(SystemClipboard::new()),
            copied_at: None,
            copied_value: None,
            pending_undo: None,
            last_undo_status_secs: None,
        }
    }

    pub fn new() -> Self {
        let profiles = list_profiles().unwrap_or_default();
        let profile_cursor = Self::initial_profile_cursor(&profiles);
        let login_focus = if profiles.is_empty() {
            LoginFocus::Profile
        } else {
            LoginFocus::Password
        };
        App {
            screen: Screen::Login,
            profiles,
            profile_cursor,
            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,
            edit_key: String::new(),
            edit_value: String::new(),
            edit_focus: EditFocus::Key,
            edit_error: None,
            revealed: 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: {
                let (tx, rx) = std::sync::mpsc::channel();
                std::thread::spawn(move || {
                    let _ = tx.send(tsafe_core::update::check_for_update());
                });
                Some(rx)
            },
            theme: App::load_theme(),
            history_entries: Vec::new(),
            history_scroll: 0,
            mv_dest_buf: String::new(),
            mv_error: None,
            clipboard: Box::new(SystemClipboard::new()),
            copied_at: None,
            copied_value: None,
            pending_undo: None,
            last_undo_status_secs: None,
        }
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    /// Non-blocking poll for the background update-check result.
    /// Call once per event-loop tick; sets `update_available` when the
    /// thread reports a newer version.
    pub fn poll_update_check(&mut self) {
        if let Some(rx) = &self.update_rx {
            if let Ok(result) = rx.try_recv() {
                self.update_available = result;
                self.update_rx = None;
            }
        }
    }

    /// Name of the currently-highlighted profile (cursor may be beyond list if list is empty).
    pub fn current_profile_name(&self) -> Option<&str> {
        self.profiles.get(self.profile_cursor).map(|s| s.as_str())
    }

    /// Attempt to open the highlighted profile with the typed password, or — if the field is empty —
    /// the password from the OS keyring (biometric / Touch ID prompt on macOS when configured).
    /// On success transitions to Dashboard and sets `Self::login_session_password` for the main loop.
    pub fn try_login(&mut self) {
        let profile = match self.current_profile_name() {
            Some(p) => p.to_owned(),
            None => {
                crate::tui_debug::log("try_login: no profile (empty list)");
                self.login_error = Some("No profiles found — press 'n' to create one.".into());
                let _ = std::mem::take(&mut self.password_buf);
                return;
            }
        };
        crate::tui_debug::log(format!("try_login: profile={profile:?}"));
        let had_typed_password = !self.password_buf.is_empty();
        let mut password = std::mem::take(&mut self.password_buf);
        let mut unlock_via_os_store = false;
        if password.is_empty() {
            if let Some(p) = self.pending_keyring_master_password.take() {
                if p.is_empty() {
                    crate::tui_debug::log("try_login: OS store returned empty secret after trim");
                    self.login_error = Some(
                        "OS credential store returned an empty password. \
                         Run: tsafe biometric disable && tsafe biometric enable"
                            .into(),
                    );
                    return;
                }
                password = p;
                unlock_via_os_store = true;
            } else {
                crate::tui_debug::log(
                    "try_login: empty field but pending_keyring is None (did run_loop skip OS retrieve?)",
                );
                // OS unlock is primed in `run_loop` (TUI suspended) — never call `retrieve_password`
                // here while raw mode + alternate screen are active or Touch ID will not run.
                self.login_error = Some(
                    "Enter your master password. \
                     Tip: run `tsafe biometric enable`, leave the field empty, press Enter for Touch ID / OS unlock."
                        .into(),
                );
                return;
            }
        }

        // Keychain / paste sometimes includes a trailing newline — that breaks KDF-derived keys.
        if unlock_via_os_store {
            password = sensitive_string(password.trim_end_matches(['\n', '\r']).to_string());
        }

        let vault_file_path = vault_path(&profile);
        if Vault::is_team_vault(&vault_file_path) {
            crate::tui_debug::log(format!(
                "try_login: team vault at {} — TUI password path not used",
                vault_file_path.display()
            ));
            self.login_error = Some(
                "This profile is a team (age) vault. The TUI only supports password-based vaults — use the CLI (e.g. tsafe list) or set TSAFE_PASSWORD."
                    .into(),
            );
            if had_typed_password {
                self.password_buf = password;
            }
            return;
        }

        crate::tui_debug::log(format!(
            "try_login: Vault::open path={} unlock_via_os_store={unlock_via_os_store} master_password_byte_len={}",
            vault_file_path.display(),
            password.len()
        ));

        match Vault::open(&vault_file_path, password.as_bytes()) {
            Ok(vault) => {
                crate::tui_debug::log("try_login: Vault::open OK -> Dashboard");
                self.login_session_password = Some(password);
                self.activate_session(profile, vault);
            }
            Err(e) => {
                crate::tui_debug::log(format!("try_login: Vault::open ERR {e}"));
                self.login_session_password = None;
                self.session_vault = None;
                let msg = if unlock_via_os_store && matches!(e, SafeError::DecryptionFailed) {
                    "Login failed: decryption failed — the password in OS unlock does not match this vault \
                     (common after `tsafe rotate`). Refresh stored credential: \
                     tsafe biometric disable && tsafe biometric enable"
                        .to_string()
                } else {
                    format!("Login failed: {e}")
                };
                self.login_error = Some(msg);
                if had_typed_password {
                    self.password_buf = password;
                }
            }
        }
    }

    pub fn activate_session(&mut self, profile: String, vault: Vault) {
        self.reload_from_vault(&vault);
        self.active_profile = Some(profile);
        self.session_vault = Some(vault);
        self.secret_cursor = 0;
        self.revealed.clear();
        self.search_mode = false;
        self.search_query.clear();
        self.login_error = None;
        self.screen = Screen::Dashboard;
    }

    pub fn clear_active_session(&mut self) {
        self.active_profile = None;
        self.session_vault = None;
        self.secret_keys.clear();
        self.secret_cursor = 0;
        self.revealed.clear();
        // Drop pending_undo on session change — the old vault is gone, the
        // restore would be either nonsensical (different vault) or fail.
        // SensitiveString in PendingUndo zeros on drop. ISC-19.
        self.pending_undo = None;
        self.last_undo_status_secs = None;
        self.search_mode = false;
        self.search_query.clear();
        self.snapshot_paths.clear();
        self.snapshot_cursor = 0;
        self.history_entries.clear();
        self.history_scroll = 0;
        self.pinned_keys.clear();
    }

    /// Reload secret list from disk (called after every write operation).
    /// Uses an already-open vault to avoid a second password prompt.
    pub fn reload_from_vault(&mut self, vault: &Vault) {
        self.pinned_keys = vault
            .file()
            .secrets
            .iter()
            .filter(|(_, e)| e.tags.get("pinned").map(|v| v == "true").unwrap_or(false))
            .map(|(k, _)| k.clone())
            .collect();
        let mut keys: Vec<String> = vault.list().iter().map(|s| s.to_string()).collect();
        keys.sort_by(|a, b| {
            let a_pin = self.pinned_keys.contains(a);
            let b_pin = self.pinned_keys.contains(b);
            b_pin.cmp(&a_pin).then(a.cmp(b))
        });
        self.revealed.retain(|k, _| keys.contains(k));
        self.secret_keys = keys;
        self.collapse_all_namespaces();
        if self.secret_cursor >= self.secret_keys.len() {
            self.secret_cursor = self.secret_keys.len().saturating_sub(1);
        }
    }

    /// Keys after applying nucleo fuzzy-match, ordered by descending match score.
    pub fn filtered_keys(&self) -> Vec<&str> {
        if self.search_query.is_empty() {
            return self.secret_keys.iter().map(|s| s.as_str()).collect();
        }
        use nucleo::pattern::{CaseMatching, Normalization, Pattern};
        use nucleo::{Config, Matcher, Utf32Str};
        let mut matcher = Matcher::new(Config::DEFAULT);
        let pattern = Pattern::parse(
            &self.search_query,
            CaseMatching::Ignore,
            Normalization::Smart,
        );
        let mut scored: Vec<(usize, u32)> = self
            .secret_keys
            .iter()
            .enumerate()
            .filter_map(|(i, k)| {
                let mut buf: Vec<char> = Vec::new();
                let haystack = Utf32Str::new(k.as_str(), &mut buf);
                pattern.score(haystack, &mut matcher).map(|s| (i, s))
            })
            .collect();
        // Highest score first; use original index as tiebreaker for stability.
        scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
        scored
            .iter()
            .map(|(i, _)| self.secret_keys[*i].as_str())
            .collect()
    }

    /// The visible rows in the secret list, grouping namespaced keys under
    /// collapsible headers. Root keys (no `/`) appear first ungrouped.
    /// When a search query is active all namespaces are forced expanded.
    pub fn visible_entries(&self) -> Vec<ListEntry> {
        let filtered = self.filtered_keys();
        let searching = !self.search_query.is_empty();
        let mut entries: Vec<ListEntry> = Vec::new();

        // 1. Root keys (no namespace separator)
        for k in filtered.iter().filter(|k| !k.contains('/')) {
            entries.push(ListEntry::Key(k.to_string()));
        }

        // 2. Namespaced keys grouped — preserve order from filtered_keys (sorted alpha)
        let mut ns_groups: Vec<(String, Vec<String>)> = Vec::new();
        for k in filtered.iter().filter(|k| k.contains('/')) {
            let ns = k.split('/').next().unwrap_or("").to_string();
            if let Some(group) = ns_groups.iter_mut().find(|(n, _)| n == &ns) {
                group.1.push(k.to_string());
            } else {
                ns_groups.push((ns, vec![k.to_string()]));
            }
        }

        for (ns, keys) in ns_groups {
            let count = keys.len();
            // Only respect collapsed state when not searching
            let collapsed = !searching && self.collapsed_namespaces.contains(&ns);
            entries.push(ListEntry::Namespace {
                name: ns,
                count,
                collapsed,
            });
            if !collapsed {
                for k in keys {
                    entries.push(ListEntry::Key(k));
                }
            }
        }

        entries
    }

    /// The key under the secret cursor (accounting for search filter and grouping).
    /// Returns `None` when the cursor is on a Namespace header row.
    pub fn selected_key(&self) -> Option<String> {
        match self.visible_entries().get(self.secret_cursor) {
            Some(ListEntry::Key(k)) => Some(k.clone()),
            _ => None,
        }
    }

    /// Namespace name when the cursor is on a collapsible group header.
    pub fn selected_namespace(&self) -> Option<String> {
        match self.visible_entries().get(self.secret_cursor) {
            Some(ListEntry::Namespace { name, .. }) => Some(name.clone()),
            _ => None,
        }
    }

    /// Collapse every namespace prefix present in `secret_keys`.
    pub fn collapse_all_namespaces(&mut self) {
        self.collapsed_namespaces = self
            .secret_keys
            .iter()
            .filter_map(|k| k.split('/').next().filter(|_| k.contains('/')))
            .map(|ns| ns.to_string())
            .collect();
    }

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

    pub fn clear_status(&mut self) {
        self.status_message = None;
    }

    /// Load the persisted theme from config.json; falls back to Dark.
    pub fn load_theme() -> Theme {
        let path = tsafe_core::profile::config_path();
        if let Ok(contents) = std::fs::read_to_string(&path) {
            if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&contents) {
                if let Some(s) = cfg.get("tui.theme").and_then(|v| v.as_str()) {
                    return s.parse().unwrap_or_default();
                }
            }
        }
        Theme::default()
    }

    /// Persist the current theme to config.json (atomic write).
    pub fn save_theme(&self) {
        let path = tsafe_core::profile::config_path();
        let mut cfg: serde_json::Map<String, serde_json::Value> = std::fs::read_to_string(&path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default();
        cfg.insert(
            "tui.theme".to_string(),
            serde_json::Value::String(self.theme.as_str().to_string()),
        );
        if let Ok(json) = serde_json::to_string_pretty(&serde_json::Value::Object(cfg)) {
            let tmp = path.with_extension("json.tmp");
            let _ = std::fs::write(&tmp, &json);
            let _ = std::fs::rename(&tmp, &path);
        }
    }

    // ── Clipboard yank ────────────────────────────────────────────────────────

    /// Replace the clipboard backend (used by tests to inject a fake).
    pub fn set_clipboard_backend(&mut self, backend: Box<dyn ClipboardBackend + Send>) {
        self.clipboard = backend;
    }

    /// Place `value` on the system clipboard with concealment hint, arming
    /// auto-clear after [`CLIPBOARD_TTL`]. Returns the [`ClipboardOutcome`]
    /// from the backend on success (so the caller can populate the audit
    /// log's `excluded_from_history` field) or the [`ClipboardError`] on
    /// failure (caller is responsible for status / audit).
    ///
    /// `value` is moved in by [`SensitiveString`] so it zeroizes on drop;
    /// the cleartext only crosses the FFI boundary into the platform
    /// clipboard buffer once (inside `set_secret_text`).
    pub fn copy_to_clipboard(
        &mut self,
        key: &str,
        value: SensitiveString,
    ) -> Result<ClipboardOutcome, ClipboardError> {
        let outcome = self.clipboard.set_secret_text(value.as_str())?;
        self.copied_at = Some(Instant::now());
        self.copied_value = Some(value);
        self.set_status(format!(
            "Copied '{key}' (clears in {}s)",
            CLIPBOARD_TTL.as_secs()
        ));
        Ok(outcome)
    }

    /// Tick-loop hook: if a yank is outstanding and [`CLIPBOARD_TTL`] has
    /// elapsed, attempt a content-comparing clear.
    ///
    /// **Compare-then-clear**: we only `set_text("")` when the current
    /// clipboard contents byte-equal what we placed there. If the user
    /// copied something else in the interim we leave the slot alone. The
    /// known false-positive (same-value re-copy from another tool) is
    /// documented on [`Self::copied_value`].
    ///
    /// **X11/Wayland note**: `clipboard.get_text()` makes a synchronous
    /// `XConvertSelection` (or Wayland equivalent) round-trip on Linux; that
    /// can stall the tick loop for tens of milliseconds when expiry fires.
    /// Acceptable for v1 since it only happens once per yank, but a future
    /// improvement could move the compare-clear onto a worker thread.
    pub fn maybe_expire_clipboard(&mut self) {
        let Some(at) = self.copied_at else {
            return;
        };
        if at.elapsed() < CLIPBOARD_TTL {
            return;
        }
        // Take both up-front so the secret is dropped (and zeroized) even if
        // the backend errors before we reach the end of this function.
        let original = self.copied_value.take();
        self.copied_at = None;
        let Some(original) = original else {
            return;
        };
        let profile = self.active_profile.clone();
        match self.clipboard.get_text() {
            Ok(current) if current == *original => {
                let clear_result = self.clipboard.set_text("");
                if matches!(self.status_message.as_deref(), Some(m) if m.starts_with("Copied ")) {
                    self.set_status("Clipboard cleared.");
                }
                emit_clipboard_audit(
                    profile.as_deref(),
                    "clipboard.cleared",
                    None,
                    clear_result.as_ref().err().map(|e| e.to_string()),
                    Some(true),
                );
            }
            Ok(_) => {
                // User copied something else — leave it. Honest audit entry
                // distinguishes this from "we cleared".
                emit_clipboard_audit(
                    profile.as_deref(),
                    "clipboard.preserved",
                    None,
                    None,
                    Some(false),
                );
            }
            Err(e) => {
                // Backend went away mid-window — emit a failure entry so the
                // log doesn't claim we cleared when we couldn't even read.
                emit_clipboard_audit(
                    profile.as_deref(),
                    "clipboard.preserved",
                    None,
                    Some(e.to_string()),
                    None,
                );
            }
        }
    }

    /// Tick-loop hook: remove every revealed entry whose age exceeds
    /// [`REVEAL_TTL`] and emit a `secret.reveal_expired` audit entry per
    /// removal. The [`SensitiveString`] in each removed [`Reveal`] zeroes
    /// on drop, so the plaintext doesn't sit in freed-heap memory.
    ///
    /// Hot-path discipline: the empty-map and armed-but-no-expiry paths
    /// allocate nothing — only the rare positive case clones the profile
    /// and formats a status message.
    pub fn maybe_expire_reveals(&mut self) {
        if self.revealed.is_empty() {
            return;
        }
        let now = Instant::now();
        if !self
            .revealed
            .values()
            .any(|r| now.duration_since(r.revealed_at) >= REVEAL_TTL)
        {
            return;
        }
        let profile = self.active_profile.clone();
        let mut count: usize = 0;
        self.revealed.retain(|key, reveal| {
            if now.duration_since(reveal.revealed_at) >= REVEAL_TTL {
                emit_reveal_audit(profile.as_deref(), "secret.reveal_expired", Some(key));
                count += 1;
                false
            } else {
                true
            }
        });
        if count > 0 {
            self.set_status(format!("{count} reveal(s) auto-concealed"));
        }
    }

    /// Tick-loop hook for the delete-undo grace window. Two responsibilities:
    ///
    /// 1. **Sticky countdown status** — while a `pending_undo` is alive,
    ///    re-set the status bar each tick to `"Deleted '<key>' — press u to
    ///    undo (Ns)"`. Other code paths (cursor moves, search, etc.) call
    ///    `set_status` and would otherwise clobber the affordance.
    /// 2. **Expiry** — when [`UNDO_TTL`] elapses, drop the snapshot (which
    ///    zeros the `SensitiveString`) and emit a
    ///    `secret.delete_finalized` audit entry so consumers know the
    ///    delete is now permanent.
    ///
    /// Hot-path discipline: when `pending_undo` is `None` (the 99% case),
    /// the cost is one `Option::is_none` check + return. No allocation.
    pub fn maybe_expire_undo(&mut self) {
        let Some(undo) = self.pending_undo.as_ref() else {
            return;
        };
        let elapsed = undo.deleted_at.elapsed();
        let key = undo.key.clone();
        if elapsed >= UNDO_TTL {
            let profile = self.active_profile.clone();
            // Drop snapshot first (zeros plaintext) before audit + status.
            self.pending_undo = None;
            self.last_undo_status_secs = None;
            emit_delete_audit(
                profile.as_deref(),
                "secret.delete_finalized",
                Some(&key),
                None,
            );
            self.set_status(format!("Delete of '{key}' is now permanent."));
            return;
        }
        // Countdown — only re-set status when the integer second changes.
        // Without this gate the status would be re-allocated on every tick
        // (~50 times per window) AND would clobber any transient message
        // from another handler between second boundaries.
        let remaining = UNDO_TTL.saturating_sub(elapsed).as_secs() + 1;
        if self.last_undo_status_secs == Some(remaining) {
            return;
        }
        self.last_undo_status_secs = Some(remaining);
        self.set_status(format!("Deleted '{key}' — press u to undo ({remaining}s)"));
    }

    /// Drop any pending undo whose key matches `key`. Called by code paths
    /// that mutate the same key (set / edit / move / another delete) so a
    /// later `u` cannot silently overwrite a fresh entry. Idempotent.
    pub fn finalize_pending_undo_for_key(&mut self, key: &str) {
        if matches!(&self.pending_undo, Some(u) if u.key == key) {
            let profile = self.active_profile.clone();
            self.pending_undo = None;
            self.last_undo_status_secs = None;
            emit_delete_audit(
                profile.as_deref(),
                "secret.delete_finalized",
                Some(key),
                None,
            );
        }
    }
}

/// Append a `clipboard.*` audit entry. Best-effort — failures are silently
/// dropped because we're already on a background-tick path with no user to
/// surface to. Callers pass:
/// - `profile`: active profile name; `None` skips the write entirely (no
///   profile means no destination log)
/// - `op`: the operation string (`clipboard.cleared` / `clipboard.preserved`)
/// - `key`: the secret path; pass `None` from the expiry path because we no
///   longer remember which key was involved (App.copied_value is type-erased)
/// - `reason`: backend error message, if any
/// - `cleared_verified`: tri-state per
///   [`AuditClipboardContext::cleared_verified`]
fn emit_clipboard_audit(
    profile: Option<&str>,
    op: &'static str,
    key: Option<&str>,
    reason: Option<String>,
    cleared_verified: Option<bool>,
) {
    let Some(profile) = profile else {
        return;
    };
    let entry = AuditEntry::success(profile, op, key).with_context(AuditContext::from_clipboard(
        AuditClipboardContext {
            ttl_secs: CLIPBOARD_TTL.as_secs(),
            reason,
            excluded_from_history: None,
            cleared_verified,
        },
    ));
    AuditLog::new(&audit_log_path(profile)).append(&entry).ok();
}

/// Append a `secret.reveal_*` audit entry. Best-effort — failures are
/// silently dropped (called from both event-handler paths and the tick
/// loop). `profile = None` skips entirely.
pub(crate) fn emit_reveal_audit(profile: Option<&str>, op: &'static str, key: Option<&str>) {
    let Some(profile) = profile else {
        return;
    };
    let entry = AuditEntry::success(profile, op, key).with_context(AuditContext::from_reveal(
        tsafe_core::audit::AuditRevealContext {
            ttl_secs: REVEAL_TTL.as_secs(),
        },
    ));
    AuditLog::new(&audit_log_path(profile)).append(&entry).ok();
}

/// Append a `secret.delete_*` audit entry. Best-effort. `profile = None`
/// skips entirely. `reason` populates the `message` field on failure ops.
/// The existing legacy `"delete"` op continues to be emitted at the
/// y-press by `dispatch_confirm_delete` for backward compat — this helper
/// covers only the new dotted-namespace ops (`secret.delete_finalized`,
/// `secret.delete_undone`, `secret.delete_undo_failed`).
pub(crate) fn emit_delete_audit(
    profile: Option<&str>,
    op: &'static str,
    key: Option<&str>,
    reason: Option<String>,
) {
    let Some(profile) = profile else {
        return;
    };
    let entry = match reason {
        None => AuditEntry::success(profile, op, key),
        Some(msg) => AuditEntry::failure(profile, op, key, &msg),
    };
    AuditLog::new(&audit_log_path(profile)).append(&entry).ok();
}

impl Drop for App {
    /// Best-effort clipboard clear on normal exit / Ctrl-C. Mirrors the
    /// compare-then-clear logic of [`App::maybe_expire_clipboard`] but runs
    /// regardless of TTL so the secret does not outlive the process when
    /// the user quits inside the 30-second window. Does not handle
    /// `kill -9` (process is gone before Drop runs) — that gap is
    /// documented in the handover.
    fn drop(&mut self) {
        let Some(original) = self.copied_value.take() else {
            return;
        };
        if let Ok(current) = self.clipboard.get_text() {
            if current == *original {
                let _ = self.clipboard.set_text("");
            }
        }
    }
}

// Manual `Debug` so plaintext fields (`copied_value`, `revealed`) can never
// be rendered through `{:?}` even if a future caller adds a
// `tracing::debug!("{app:?}")` somewhere. The struct otherwise has no
// `Debug` derive (to match the pre-existing crate style) — this impl
// exists purely as a defensive guard for plaintext-bearing fields.
impl std::fmt::Debug for App {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("App")
            .field("screen", &self.screen)
            .field("active_profile", &self.active_profile)
            .field("secret_cursor", &self.secret_cursor)
            .field("status_message", &self.status_message)
            .field("copied_at", &self.copied_at)
            .field("copied_value", &"<redacted>")
            .field(
                "revealed",
                &format_args!("<{} entries redacted>", self.revealed.len()),
            )
            .field(
                "pending_undo",
                match &self.pending_undo {
                    Some(_) => &"<pending — value redacted>",
                    None => &"None",
                },
            )
            .finish_non_exhaustive()
    }
}

impl Default for App {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    use super::*;
    use serial_test::serial;
    use tsafe_core::vault::Vault;

    /// Serialize tests that set `TSAFE_VAULT_DIR`.
    static VAULT_ENV_LOCK: Mutex<()> = Mutex::new(());

    /// Build an App with pre-populated secret keys (no real vault needed).
    fn app_with_keys(keys: &[&str]) -> App {
        let mut app = App {
            screen: Screen::Dashboard,
            profiles: vec!["test".into()],
            profile_cursor: 0,
            active_profile: Some("test".into()),
            session_vault: None,
            secret_keys: keys.iter().map(|s| s.to_string()).collect(),
            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: 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(SystemClipboard::new()),
            copied_at: None,
            copied_value: None,
            pending_undo: None,
            last_undo_status_secs: None,
        };
        app.secret_cursor = 0;
        app
    }

    #[test]
    fn filtered_keys_returns_all_when_no_query() {
        let app = app_with_keys(&["AAA", "BBB", "CCC"]);
        let keys = app.filtered_keys();
        assert_eq!(keys, vec!["AAA", "BBB", "CCC"]);
    }

    #[test]
    fn filtered_keys_fuzzy_matches() {
        let mut app = app_with_keys(&["DB_PASSWORD", "API_KEY", "DB_HOST"]);
        app.search_query = "db".into();
        let keys = app.filtered_keys();
        assert!(
            keys.contains(&"DB_PASSWORD"),
            "expected DB_PASSWORD in results"
        );
        assert!(keys.contains(&"DB_HOST"), "expected DB_HOST in results");
        assert!(!keys.contains(&"API_KEY"), "API_KEY should not match 'db'");
        assert_eq!(keys.len(), 2);
    }

    #[test]
    fn filtered_keys_returns_empty_on_no_match() {
        let mut app = app_with_keys(&["DB_PASSWORD", "API_KEY"]);
        app.search_query = "zzz".into();
        assert!(app.filtered_keys().is_empty());
    }

    #[test]
    fn selected_key_in_bounds() {
        let mut app = app_with_keys(&["A", "B", "C"]);
        app.secret_cursor = 1;
        assert_eq!(app.selected_key(), Some("B".into()));
    }

    #[test]
    fn selected_key_empty_list() {
        let app = app_with_keys(&[]);
        assert_eq!(app.selected_key(), None);
    }

    #[test]
    fn selected_key_out_of_bounds_returns_none() {
        let mut app = app_with_keys(&["A", "B"]);
        app.secret_cursor = 99;
        assert_eq!(app.selected_key(), None);
    }

    #[test]
    fn set_and_clear_status() {
        let mut app = app_with_keys(&[]);
        app.set_status("hello");
        assert_eq!(app.status_message.as_deref(), Some("hello"));
        app.clear_status();
        assert!(app.status_message.is_none());
    }

    #[test]
    #[serial]
    fn stale_os_unlock_password_mentions_biometric_refresh() {
        let _g = VAULT_ENV_LOCK.lock().unwrap();
        let dir = tempfile::TempDir::new().unwrap();
        let vaults = dir.path().join("vaults");
        std::fs::create_dir_all(&vaults).unwrap();
        let vault_file = vaults.join("ptest.vault");
        Vault::create(&vault_file, b"actual-master-secret").unwrap();

        let mut app = App {
            screen: Screen::Login,
            profiles: vec!["ptest".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: Some(sensitive_string("wrong-keychain-password")),
            login_focus: LoginFocus::Password,
            edit_key: String::new(),
            edit_value: String::new(),
            edit_focus: EditFocus::Key,
            edit_error: None,
            revealed: 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(SystemClipboard::new()),
            copied_at: None,
            copied_value: None,
            pending_undo: None,
            last_undo_status_secs: None,
        };
        temp_env::with_var("TSAFE_VAULT_DIR", Some(vaults.as_os_str()), || {
            app.try_login();
        });

        let err = app
            .login_error
            .as_ref()
            .expect("expected login error when OS-stored password mismatches vault");
        assert!(
            err.contains("biometric disable") && err.contains("biometric enable"),
            "unexpected message: {err}"
        );
        assert_eq!(app.screen, Screen::Login);
    }

    #[test]
    #[serial]
    fn os_store_password_trims_trailing_newline() {
        let _g = VAULT_ENV_LOCK.lock().unwrap();
        let dir = tempfile::TempDir::new().unwrap();
        let vaults = dir.path().join("vaults");
        std::fs::create_dir_all(&vaults).unwrap();
        let vault_file = vaults.join("ntrim.vault");
        Vault::create(&vault_file, b"correct-pw").unwrap();

        let mut app = App {
            screen: Screen::Login,
            profiles: vec!["ntrim".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: Some(sensitive_string("correct-pw\n")),
            login_focus: LoginFocus::Password,
            edit_key: String::new(),
            edit_value: String::new(),
            edit_focus: EditFocus::Key,
            edit_error: None,
            revealed: 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(SystemClipboard::new()),
            copied_at: None,
            copied_value: None,
            pending_undo: None,
            last_undo_status_secs: None,
        };
        temp_env::with_var("TSAFE_VAULT_DIR", Some(vaults.as_os_str()), || {
            app.try_login();
        });

        assert_eq!(app.screen, Screen::Dashboard, "{:?}", app.login_error);
        assert!(app.session_vault.is_some());
    }

    #[test]
    #[serial]
    fn initial_profile_cursor_prefers_configured_default_profile() {
        let _g = VAULT_ENV_LOCK.lock().unwrap();
        let dir = tempfile::TempDir::new().unwrap();
        let profiles = vec!["alpha".to_string(), "work".to_string()];

        temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path()), || {
            tsafe_core::profile::set_default_profile("work").unwrap();
            assert_eq!(App::initial_profile_cursor(&profiles), 1);
        });
    }

    #[test]
    #[serial]
    fn initial_profile_cursor_falls_back_to_first_when_default_missing() {
        let _g = VAULT_ENV_LOCK.lock().unwrap();
        let dir = tempfile::TempDir::new().unwrap();
        let profiles = vec!["alpha".to_string(), "work".to_string()];

        temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.path()), || {
            tsafe_core::profile::set_default_profile("prod").unwrap();
            assert_eq!(App::initial_profile_cursor(&profiles), 0);
        });
    }
}
