use std::collections::HashMap;
use std::path::PathBuf;

use ratatui::widgets::ListState;

use crate::history::ConnectionHistory;
use crate::ssh_config::model::SshConfigFile;
use crate::ssh_keys::SshKeyInfo;

/// Case-insensitive substring check without allocation.
/// Uses a byte-window approach for ASCII strings (the common case for SSH
/// hostnames and aliases). Falls back to a char-based scan when either
/// string contains non-ASCII bytes to avoid false matches across UTF-8
/// character boundaries.
pub(super) fn contains_ci(haystack: &str, needle: &str) -> bool {
    if needle.is_empty() {
        return true;
    }
    if haystack.is_ascii() && needle.is_ascii() {
        return haystack
            .as_bytes()
            .windows(needle.len())
            .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()));
    }
    // Non-ASCII fallback: compare char-by-char (case fold ASCII only)
    let needle_lower: Vec<char> = needle.chars().map(|c| c.to_ascii_lowercase()).collect();
    let haystack_chars: Vec<char> = haystack.chars().collect();
    haystack_chars.windows(needle_lower.len()).any(|window| {
        window
            .iter()
            .zip(needle_lower.iter())
            .all(|(h, n)| h.to_ascii_lowercase() == *n)
    })
}

/// Case-insensitive equality check without allocation.
pub(super) fn eq_ci(a: &str, b: &str) -> bool {
    a.eq_ignore_ascii_case(b)
}

mod baselines;
mod container_state;
mod display_list;
mod form_state;
mod forms;
mod groups;
mod host_state;
mod hosts;
pub(crate) mod jump;
pub(crate) mod ping;
mod provider_state;
mod reload_state;
mod screen;
mod search;
mod selection;
mod snippet_state;
mod status_state;
mod tag_state;
mod tunnel_state;
mod ui_state;
mod update;
mod vault;

pub use baselines::{FormBaseline, ProviderFormBaseline, SnippetFormBaseline, TunnelFormBaseline};
pub use container_state::ContainerState;
pub use form_state::FormState;
pub(crate) use forms::char_to_byte_pos;
pub use forms::{
    FormField, HostForm, ProviderFormField, ProviderFormFields, SnippetForm, SnippetFormField,
    SnippetHostOutput, SnippetOutputState, SnippetParamFormState, TunnelForm, TunnelFormField,
};
pub use host_state::{
    DeletedHost, GroupBy, HostListItem, HostState, ProxyJumpCandidate, SortMode, ViewMode,
    health_summary_spans, health_summary_spans_for,
};
pub use ping::{
    PingState, PingStatus, classify_ping, ping_sort_key, propagate_ping_to_dependents, status_glyph,
};
pub use provider_state::{
    LabelMigrationField, PendingLabelMigration, ProviderRow, ProviderState, SyncRecord,
};
pub use reload_state::{ConflictState, ReloadState};
pub use screen::{Screen, TopPage, WhatsNewState};
pub use search::SearchState;
pub use snippet_state::SnippetState;
pub use status_state::{MessageClass, StatusCenter, StatusMessage};
pub use tag_state::{
    BulkTagAction, BulkTagApplyResult, BulkTagEditorState, BulkTagRow, TagState,
    select_display_tags,
};
pub use tunnel_state::{TunnelSortMode, TunnelState};
pub use ui_state::UiSelection;
pub use update::UpdateState;
pub use vault::VaultState;

/// Kill active tunnel processes when App is dropped (e.g. on panic).
impl Drop for App {
    fn drop(&mut self) {
        for (alias, mut tunnel) in self.tunnels.active.drain() {
            if let Err(e) = tunnel.child.kill() {
                log::debug!("[external] Failed to kill tunnel for {alias} on shutdown: {e}");
            }
            let _ = tunnel.child.wait();
        }
        // Cancel and join any in-flight Vault SSH bulk-sign worker so it
        // cannot keep writing to ~/.purple/certs/ after teardown (panic
        // unwind, normal exit, etc.).
        if let Some(ref cancel) = self.vault.signing_cancel {
            cancel.store(true, std::sync::atomic::Ordering::Relaxed);
        }
        if let Some(handle) = self.vault.sign_thread.take() {
            let _ = handle.join();
        }
    }
}

/// Main application state.
pub struct App {
    // Core
    pub screen: Screen,
    /// Top-level page (Hosts vs Tunnels). Selected by Tab/Shift+Tab in
    /// the navigation bar. Independent of `screen`, which tracks overlays.
    pub top_page: TopPage,
    pub running: bool,
    pub hosts_state: HostState,
    pub pending_connect: Option<(String, Option<String>)>,

    // Sub-structs
    pub status_center: StatusCenter,
    pub ui: UiSelection,
    pub search: SearchState,
    pub reload: ReloadState,
    pub conflict: ConflictState,

    // Keys
    pub keys: Vec<SshKeyInfo>,

    // Tags
    pub tags: TagState,

    // Host form + bulk tag editor
    pub forms: FormState,

    // History + preferences
    pub history: ConnectionHistory,

    /// Signal for animation layer: detail panel toggle requested.
    /// Set by handler, consumed by AnimationState.detect_transitions().
    pub detail_toggle_pending: bool,

    // Providers
    pub providers: ProviderState,

    // Ping / health-check
    pub ping: PingState,

    // Vault SSH certificate and signing state
    pub vault: VaultState,

    // Tunnels
    pub tunnels: TunnelState,

    // Snippets
    pub snippets: SnippetState,

    // Update
    pub update: UpdateState,

    // Bitwarden session
    pub bw_session: Option<String>,

    // File browser
    pub file_browser: Option<crate::file_browser::FileBrowserState>,
    pub file_browser_paths: HashMap<String, (PathBuf, String)>,

    // Containers
    pub container_state: Option<ContainerState>,
    pub container_cache: HashMap<String, crate::containers::ContainerCacheEntry>,

    // First-run hints
    pub known_hosts_count: usize,
    pub welcome_opened: Option<std::time::Instant>,

    /// Demo mode: all mutations are in-memory only, no disk writes.
    pub demo_mode: bool,

    /// Deferred config write from VaultSignAllDone (guarded while forms are open).
    pub pending_vault_config_write: bool,

    /// Jump state. Some when the jump bar is open.
    pub jump: Option<JumpState>,

    /// One-shot session flag: set the first time Esc is pressed on the host
    /// list with no filter and no selection, so the toast that nudges users
    /// toward `q` is shown exactly once per process.
    pub esc_quit_hint_shown: bool,
}

impl App {
    pub fn new(config: SshConfigFile) -> Self {
        let hosts = config.host_entries();
        let patterns = config.pattern_entries();
        let display_list = Self::build_display_list_from(&config, &hosts, &patterns);

        let initial_selection = display_list.iter().position(|item| {
            matches!(
                item,
                HostListItem::Host { .. } | HostListItem::Pattern { .. }
            )
        });

        let reload = ReloadState::from_config(&config);
        let hosts_state = HostState::from_config(config, hosts, patterns, display_list);

        Self {
            screen: Screen::HostList,
            top_page: TopPage::default(),
            running: true,
            hosts_state,
            pending_connect: None,
            status_center: StatusCenter::default(),
            ui: UiSelection::new_with_initial_selection(initial_selection),
            search: SearchState::default(),
            reload,
            conflict: ConflictState::default(),
            keys: Vec::new(),
            tags: TagState::default(),
            forms: FormState::default(),
            history: ConnectionHistory::load(),
            detail_toggle_pending: false,
            providers: ProviderState::load(),
            ping: PingState::from_preferences(),
            vault: VaultState::default(),
            tunnels: TunnelState::default(),
            snippets: SnippetState::with_store_loaded(),
            update: UpdateState::with_current_hint(),
            bw_session: None,
            file_browser: None,
            file_browser_paths: HashMap::new(),
            container_state: None,
            container_cache: crate::containers::load_container_cache(),
            known_hosts_count: 0,
            welcome_opened: None,
            demo_mode: false,
            pending_vault_config_write: false,
            jump: None,
            esc_quit_hint_shown: false,
        }
    }

    /// Reload hosts from config.
    pub fn reload_hosts(&mut self) {
        let had_pending_vault_write = self.pending_vault_config_write;
        // Synchronously flush any deferred vault config write before reloading,
        // so on-disk state matches in-memory state (no TOCTOU with auto-reload).
        // Skip when a form is open (flush handler would bail anyway) and do not
        // call flush_pending_vault_write() itself to avoid recursion.
        let mut flushed_vault_write = false;
        if self.pending_vault_config_write && !self.is_form_open() {
            match self.hosts_state.ssh_config.write() {
                Ok(()) => flushed_vault_write = true,
                Err(e) => self.notify_error(crate::messages::vault_config_write_after_sign(&e)),
            }
        }
        // Always clear the flag: either we flushed, or the form-submit path
        // has already written the full config.
        self.pending_vault_config_write = false;
        log::debug!(
            "[config] reload_hosts: pending_vault_write={had_pending_vault_write} flushed={flushed_vault_write}"
        );
        let had_search = self.search.query.take();
        let selected_alias = self
            .selected_host()
            .map(|h| h.alias.clone())
            .or_else(|| self.selected_pattern().map(|p| p.pattern.clone()));

        self.tunnels.summaries_cache.clear();
        self.hosts_state.render_cache.invalidate();
        self.hosts_state.list = self.hosts_state.ssh_config.host_entries();
        self.hosts_state.patterns = self.hosts_state.ssh_config.pattern_entries();
        // Prune cert status cache and in-flight set: retain only entries whose
        // host alias still exists after the reload.
        let valid_for_certs: std::collections::HashSet<&str> = self
            .hosts_state
            .list
            .iter()
            .map(|h| h.alias.as_str())
            .collect();
        self.vault
            .cert_cache
            .retain(|alias, _| valid_for_certs.contains(alias.as_str()));
        self.vault
            .cert_checks_in_flight
            .retain(|alias| valid_for_certs.contains(alias.as_str()));
        if self.hosts_state.sort_mode == SortMode::Original
            && matches!(self.hosts_state.group_by, GroupBy::None)
        {
            self.hosts_state.display_list = Self::build_display_list_from(
                &self.hosts_state.ssh_config,
                &self.hosts_state.list,
                &self.hosts_state.patterns,
            );
        } else {
            self.apply_sort();
        }

        // Close tag pickers if open — tags.list is stale after reload
        if matches!(self.screen, Screen::TagPicker | Screen::BulkTagEditor) {
            self.screen = Screen::HostList;
            self.forms.bulk_tag_editor = BulkTagEditorState::default();
        }

        // Multi-select stores indices into hosts; clear to avoid stale refs
        self.hosts_state.multi_select.clear();

        // Prune ping status for hosts that no longer exist
        let valid_aliases: std::collections::HashSet<&str> = self
            .hosts_state
            .list
            .iter()
            .map(|h| h.alias.as_str())
            .collect();
        let pre_status = self.ping.status.len();
        let pre_checked = self.ping.last_checked.len();
        self.ping
            .status
            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
        self.ping
            .last_checked
            .retain(|alias, _| valid_aliases.contains(alias.as_str()));
        let dropped = pre_status.saturating_sub(self.ping.status.len())
            + pre_checked.saturating_sub(self.ping.last_checked.len());
        if dropped > 0 {
            log::debug!(
                "[purple] reload_hosts: pruned {} orphan ping entrie(s); {} aliases remain",
                dropped,
                valid_aliases.len()
            );
        }

        // Restore search if it was active, otherwise reset
        if let Some(query) = had_search {
            self.search.query = Some(query);
            self.apply_filter();
        } else {
            self.search.query = None;
            self.search.filtered_indices.clear();
            self.search.filtered_pattern_indices.clear();
            // Fix selection for display list mode
            if self.hosts_state.list.is_empty() && self.hosts_state.patterns.is_empty() {
                self.ui.list_state.select(None);
            } else if let Some(pos) = self.hosts_state.display_list.iter().position(|item| {
                matches!(
                    item,
                    HostListItem::Host { .. } | HostListItem::Pattern { .. }
                )
            }) {
                let current = self.ui.list_state.selected().unwrap_or(0);
                if current >= self.hosts_state.display_list.len()
                    || !matches!(
                        self.hosts_state.display_list.get(current),
                        Some(HostListItem::Host { .. } | HostListItem::Pattern { .. })
                    )
                {
                    self.ui.list_state.select(Some(pos));
                }
            } else {
                self.ui.list_state.select(None);
            }
        }

        // Restore selection by alias (e.g. after SSH connect changed sort order)
        if let Some(alias) = selected_alias {
            self.select_host_by_alias(&alias);
        }

        log::debug!(
            "[config] reload_hosts: hosts={} patterns={} display_items={}",
            self.hosts_state.list.len(),
            self.hosts_state.patterns.len(),
            self.hosts_state.display_list.len(),
        );
    }

    /// Synchronously re-check a host's Vault SSH certificate and update
    /// `vault.cert_cache` with fresh status + on-disk mtime.
    ///
    /// Every sign path (V-key bulk sign, host form submit, connect-time
    /// `ensure_vault_ssh_if_needed`, CLI) funnels through this helper so the
    /// detail panel never lies about cert state after a successful sign.
    ///
    /// No-op in demo mode. If the host is missing, has no resolvable vault
    /// role, or the cert path cannot be resolved, any stale entry for the
    /// alias is removed to avoid showing ghost status.
    pub fn refresh_cert_cache(&mut self, alias: &str) {
        if crate::demo_flag::is_demo() {
            return;
        }
        let Some(host) = self.hosts_state.list.iter().find(|h| h.alias == alias) else {
            self.vault.cert_cache.remove(alias);
            return;
        };
        let role_some = crate::vault_ssh::resolve_vault_role(
            host.vault_ssh.as_deref(),
            host.provider.as_deref(),
            host.provider_label.as_deref(),
            &self.providers.config,
        )
        .is_some();
        if !role_some {
            self.vault.cert_cache.remove(alias);
            return;
        }
        let cert_path = match crate::vault_ssh::resolve_cert_path(alias, &host.certificate_file) {
            Ok(p) => p,
            Err(_) => {
                self.vault.cert_cache.remove(alias);
                return;
            }
        };
        let status = crate::vault_ssh::check_cert_validity(&cert_path);
        let mtime = std::fs::metadata(&cert_path)
            .ok()
            .and_then(|m| m.modified().ok());
        self.vault.cert_cache.insert(
            alias.to_string(),
            (std::time::Instant::now(), status, mtime),
        );
    }

    // --- Search methods ---

    /// Shim. Routes to `ProviderState::sorted_names`.
    /// Test-only: production code uses `provider_list_rows()` for the
    /// tree-style list, so this wrapper exists to keep older test fixtures
    /// concise.
    #[cfg(test)]
    pub fn sorted_provider_names(&self) -> Vec<String> {
        self.providers.sorted_names()
    }

    /// Check whether a form screen is currently open (host or provider forms).
    pub fn is_form_open(&self) -> bool {
        matches!(
            self.screen,
            Screen::AddHost | Screen::EditHost { .. } | Screen::ProviderForm { .. }
        )
    }

    /// Open the unified jump in the given mode. Loads recents
    /// from disk and seeds the empty-query view. Recomputes hits.
    pub fn open_jump(&mut self, mode: JumpMode) {
        log::debug!("jump: open mode={:?}", mode);
        let mut state = JumpState::for_mode(mode);
        let recents_file = jump::load_recents();
        state.recents = self.resolve_recents(&recents_file);
        self.jump = Some(state);
        self.recompute_jump_hits();
    }

    /// Translate the on-disk recents log into live `JumpHit`s, dropping
    /// dangling references silently.
    fn resolve_recents(&self, file: &RecentsFile) -> Vec<JumpHit> {
        let mode = self
            .jump
            .as_ref()
            .map(|p| p.mode)
            .unwrap_or(JumpMode::Hosts);
        let mut out = Vec::with_capacity(file.entries.len());
        for entry in &file.entries {
            if let Some(hit) = self.resolve_recent_ref(&entry.target, mode) {
                out.push(hit);
            }
        }
        out
    }

    /// Test seam: exposes `resolve_recent_ref` as `pub(crate)` so the unit
    /// tests in `app::tests` can drive each `SourceKind` branch without
    /// going through `open_jump`.
    #[cfg(test)]
    pub(crate) fn resolve_recent_ref_for_test(
        &self,
        r: &RecentRef,
        mode: JumpMode,
    ) -> Option<JumpHit> {
        self.resolve_recent_ref(r, mode)
    }

    fn resolve_recent_ref(&self, r: &RecentRef, mode: JumpMode) -> Option<JumpHit> {
        match r.kind {
            SourceKind::Action => {
                let key_char = r.key.chars().next()?;
                let actions = JumpAction::for_mode(mode);
                actions
                    .iter()
                    .find(|a| a.key == key_char)
                    .copied()
                    .map(JumpHit::Action)
            }
            SourceKind::Host => {
                let host = self.hosts_state.list.iter().find(|h| h.alias == r.key)?;
                Some(JumpHit::Host(HostHit {
                    alias: host.alias.clone(),
                    hostname: host.hostname.clone(),
                    tags: host.tags.clone(),
                    provider: host.provider.clone(),
                    user: host.user.clone(),
                    identity_file: host.identity_file.clone(),
                    proxy_jump: host.proxy_jump.clone(),
                    vault_ssh: host.vault_ssh.clone(),
                }))
            }
            SourceKind::Tunnel => {
                let (alias, port_str) = r.key.split_once(':')?;
                let port: u16 = port_str.parse().ok()?;
                let rules = self.hosts_state.ssh_config.find_tunnel_directives(alias);
                let rule = rules.iter().find(|r| r.bind_port == port)?;
                Some(JumpHit::Tunnel(TunnelHit {
                    alias: alias.to_string(),
                    bind_port: rule.bind_port,
                    bind_port_str: rule.bind_port.to_string(),
                    destination: rule.display(),
                    active: self.tunnels.active.contains_key(alias),
                }))
            }
            SourceKind::Container => {
                let (alias, name) = r.key.split_once('/')?;
                let entry = self.container_cache.get(alias)?;
                let info = entry.containers.iter().find(|c| c.names == name)?;
                Some(JumpHit::Container(ContainerHit {
                    alias: alias.to_string(),
                    container_name: info.names.clone(),
                    container_id: info.id.clone(),
                    state: info.state.clone(),
                }))
            }
            SourceKind::Snippet => {
                let snippet = self.snippets.store.get(&r.key)?;
                Some(JumpHit::Snippet(SnippetHit {
                    name: snippet.name.clone(),
                    command_preview: preview(&snippet.command, 40),
                }))
            }
        }
    }

    /// Recompute the jump bar hit list against the current query. Pulls
    /// candidates from every live source and ranks them with nucleo-matcher.
    /// Preserves the previously-selected hit's identity across the
    /// recompute so mid-typing arrow-key navigation does not jump back to
    /// row 0.
    pub fn recompute_jump_hits(&mut self) {
        let Some(mut state) = self.jump.take() else {
            return;
        };
        // Identity of the row the user was on before the recompute. We
        // re-resolve it after rebuilding `hits` to keep selection stable
        // when the user types and the matched row is still in the list.
        let prior_identity = state
            .visible_hits()
            .get(state.selected)
            .map(|h| h.identity());

        let candidates = self.collect_jump_candidates(state.mode);
        if state.query.is_empty() {
            state.hits = candidates;
            state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
            self.jump = Some(state);
            return;
        }

        // Field-prefix syntax: `user:eric` scopes to one field. Empty
        // remainder after the prefix is treated as no query (empty
        // scope-search). Mode is held in `query_scope` for the row
        // renderer to surface a "via <field>" hint.
        let (scope, effective_query) = parse_query_scope(&state.query);

        use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
        use nucleo_matcher::{Config, Matcher, Utf32Str};
        let matcher_state = state
            .matcher
            .get_or_insert_with(|| Matcher::new(Config::DEFAULT));
        let pattern = Pattern::parse(effective_query, CaseMatching::Smart, Normalization::Smart);
        let mut buf: Vec<char> = Vec::new();
        let mut scored: Vec<(JumpHit, u32)> = Vec::with_capacity(candidates.len());
        for hit in candidates {
            let mut best: u32 = 0;
            // Score over the right haystack set: scoped queries narrow to
            // a single field; unscoped queries score over everything the
            // hit advertises.
            let scoped_haystacks = scoped_haystacks_for(&hit, scope);
            let haystacks: Vec<&str> = if let Some(hs) = scoped_haystacks {
                hs
            } else {
                hit.haystacks()
            };
            for haystack in haystacks {
                buf.clear();
                let chars = Utf32Str::new(haystack, &mut buf);
                if let Some(score) = pattern.score(chars, matcher_state) {
                    best = best.max(score);
                }
            }
            // Boost: a single-char query that exactly matches an action's
            // hotkey letter (case-insensitive) lands the action at the top.
            // When two actions share the same hotkey (e.g. 'a' for `Hosts:
            // Add host` and `Tunnels: Add tunnel`), the one whose target
            // matches the current mode wins, so muscle memory survives.
            if let JumpHit::Action(a) = &hit {
                let single = effective_query.chars().next();
                if effective_query.chars().count() == 1
                    && single
                        .map(|c| c.eq_ignore_ascii_case(&a.key))
                        .unwrap_or(false)
                {
                    let mode_match = matches!(
                        (state.mode, a.target),
                        (JumpMode::Hosts, JumpActionTarget::Hosts)
                            | (JumpMode::Tunnels, JumpActionTarget::Tunnels)
                    );
                    let bump = if mode_match { 20_000 } else { 10_000 };
                    best = best.saturating_add(bump);
                }
            }
            // Score floor: actions need to clear a higher bar than data
            // rows. Stops query 'eric' from dragging in 'Containers: List
            // containers' on stray e/r/i/c char overlap.
            let floor = match &hit {
                JumpHit::Action(_) => PALETTE_ACTION_FLOOR,
                _ => 1,
            };
            if best >= floor {
                scored.push((hit, best));
            }
        }
        // Stable sort: higher score first, ties broken by render-order kind so
        // hosts come before actions when scores tie.
        scored.sort_by(|a, b| {
            b.1.cmp(&a.1)
                .then_with(|| kind_rank(a.0.kind()).cmp(&kind_rank(b.0.kind())))
        });
        // Cap per-section using a fixed-size array so a broad query (one
        // char that matches everything) cannot blow the visible list.
        let mut per_kind: [usize; 5] = [0; 5];
        let mut filtered: Vec<JumpHit> = Vec::with_capacity(scored.len().min(160));
        for (hit, _) in scored {
            let slot = kind_rank(hit.kind()) as usize;
            if per_kind[slot] < PALETTE_PER_SECTION_CAP {
                per_kind[slot] += 1;
                filtered.push(hit);
            }
        }
        state.hits = filtered;
        state.selected = restore_selection(&state.visible_hits(), prior_identity.as_ref(), 0);
        self.jump = Some(state);
    }

    fn collect_jump_candidates(&self, mode: JumpMode) -> Vec<JumpHit> {
        let mut out: Vec<JumpHit> = Vec::new();
        // Hosts
        for h in &self.hosts_state.list {
            out.push(JumpHit::Host(HostHit {
                alias: h.alias.clone(),
                hostname: h.hostname.clone(),
                tags: h.tags.clone(),
                provider: h.provider.clone(),
                user: h.user.clone(),
                identity_file: h.identity_file.clone(),
                proxy_jump: h.proxy_jump.clone(),
                vault_ssh: h.vault_ssh.clone(),
            }));
        }
        // Tunnels: every configured rule from every host with a directive.
        for h in &self.hosts_state.list {
            let rules = self.hosts_state.ssh_config.find_tunnel_directives(&h.alias);
            for rule in rules {
                out.push(JumpHit::Tunnel(TunnelHit {
                    alias: h.alias.clone(),
                    bind_port: rule.bind_port,
                    bind_port_str: rule.bind_port.to_string(),
                    destination: rule.display(),
                    active: self.tunnels.active.contains_key(&h.alias),
                }));
            }
        }
        // Containers: cached only. Triggering an SSH fetch on jump bar open
        // would be unbounded latency.
        for (alias, entry) in &self.container_cache {
            for info in &entry.containers {
                out.push(JumpHit::Container(ContainerHit {
                    alias: alias.clone(),
                    container_name: info.names.clone(),
                    container_id: info.id.clone(),
                    state: info.state.clone(),
                }));
            }
        }
        // Snippets
        for snippet in &self.snippets.store.snippets {
            out.push(JumpHit::Snippet(SnippetHit {
                name: snippet.name.clone(),
                command_preview: preview(&snippet.command, 40),
            }));
        }
        // Actions last
        for a in JumpAction::for_mode(mode) {
            out.push(JumpHit::Action(*a));
        }
        out
    }

    /// Persist a jump dispatch to the on-disk MRU log. Best-effort; a
    /// write error logs and is otherwise swallowed so user navigation is
    /// never blocked by a recents-file failure. Takes `&mut self` so the
    /// type system reflects that this performs I/O and mutates persistent
    /// state, even though `jump::save_recents` only needs `&File`.
    pub fn record_jump_hit(&mut self, hit: &JumpHit) {
        if self.demo_mode {
            log::debug!("jump: record skipped (demo mode)");
            return;
        }
        let mut file = jump::load_recents();
        jump::touch_recent(&mut file, hit.identity());
        if let Err(e) = jump::save_recents(&file) {
            log::warn!("[purple] failed to save recents: {e}");
        }
    }

    /// Flush a deferred vault config write if one is pending and no form is open.
    /// Returns true if a write was performed.
    pub fn flush_pending_vault_write(&mut self) -> bool {
        if !self.pending_vault_config_write || self.is_form_open() {
            return false;
        }
        // reload_hosts() performs the write and clears the flag.
        self.reload_hosts();
        true
    }

    /// Shim. Routes to `StatusCenter::set_status`. 174+ call-sites via
    /// `notify_*` wrappers depend on this signature.
    #[deprecated(note = "use notify() / notify_error() instead")]
    #[allow(deprecated)]
    pub fn set_status(&mut self, text: impl Into<String>, is_error: bool) {
        self.status_center.set_status(text, is_error);
    }

    /// Run once after App::new: queue the upgrade toast if the user just
    /// upgraded past their last-seen version, otherwise seed the preference
    /// so the next launch is silent.
    pub fn post_init(&mut self) {
        let outcome = crate::onboarding::evaluate();
        if let Some(text) = outcome.upgrade_toast {
            self.enqueue_sticky_toast(text);
        }
    }

    fn enqueue_sticky_toast(&mut self, text: String) {
        log::debug!("[purple] enqueue sticky toast: {}", text);
        let msg = StatusMessage {
            text,
            class: MessageClass::Success,
            tick_count: 0,
            sticky: true,
            created_at: std::time::Instant::now(),
        };
        self.status_center.toast = Some(msg);
    }

    /// Shim. Routes to `StatusCenter::set_info_status`.
    #[deprecated(note = "use notify_info() instead")]
    #[allow(deprecated)]
    pub fn set_info_status(&mut self, text: impl Into<String>) {
        self.status_center.set_info_status(text);
    }

    /// Shim. Routes to `StatusCenter::set_background_status`.
    #[deprecated(note = "use notify_background() / notify_background_error() instead")]
    #[allow(deprecated)]
    pub fn set_background_status(&mut self, text: impl Into<String>, is_error: bool) {
        self.status_center.set_background_status(text, is_error);
    }

    /// Shim. Routes to `StatusCenter::set_sticky_status`.
    #[deprecated(note = "use notify_progress() / notify_sticky_error() instead")]
    #[allow(deprecated)]
    pub fn set_sticky_status(&mut self, text: impl Into<String>, is_error: bool) {
        self.status_center.set_sticky_status(text, is_error);
    }

    /// User action feedback → Success toast (length-proportional timeout,
    /// last-write-wins). For: copy, sort, delete, save, demo mode messages.
    #[allow(deprecated)]
    pub fn notify(&mut self, text: impl Into<String>) {
        self.set_status(text, false);
    }

    /// User action error → Error toast (sticky by default, queued).
    /// Errors require user acknowledgement; they do not auto-expire.
    #[allow(deprecated)]
    pub fn notify_error(&mut self, text: impl Into<String>) {
        self.set_status(text, true);
    }

    /// Background event → Info footer (length-proportional timeout,
    /// suppressed if sticky active). For: ping expiry, sync progress,
    /// tunnel exit.
    #[allow(deprecated)]
    pub fn notify_background(&mut self, text: impl Into<String>) {
        self.set_background_status(text, false);
    }

    /// Background error → Error toast (sticky, queued, bypasses sticky
    /// suppression). Same semantics as `notify_error` but for events that
    /// arise from background workers rather than direct user actions.
    #[allow(deprecated)]
    pub fn notify_background_error(&mut self, text: impl Into<String>) {
        self.set_background_status(text, true);
    }

    /// Caution / degraded state → Warning toast (length-proportional
    /// timeout, queued). For: precondition violations ("Nothing to undo."),
    /// validation hints ("Project ID can't be empty."), empty-state
    /// notices ("No stale hosts."), stale-host warnings, deprecated
    /// config detected, partial sync results. Warnings are NOT sticky;
    /// the user acknowledges them by continuing to interact.
    ///
    /// Use `notify_error` only for system-level failures (I/O, network,
    /// subprocess) that require explicit acknowledgement. Use
    /// `notify_warning` for everything that is "this can't happen given
    /// current state" or "you forgot something".
    pub fn notify_warning(&mut self, text: impl Into<String>) {
        let msg = StatusMessage {
            text: text.into(),
            class: MessageClass::Warning,
            tick_count: 0,
            sticky: false,
            created_at: std::time::Instant::now(),
        };
        log::debug!("toast <- Warning: {}", msg.text);
        self.status_center.push_toast(msg);
    }

    /// Long-running progress → footer sticky (never expires).
    /// For: Vault SSH signing, multi-step operations.
    #[allow(deprecated)]
    pub fn notify_progress(&mut self, text: impl Into<String>) {
        self.set_sticky_status(text, false);
    }

    /// Sticky error → footer sticky.
    #[allow(deprecated)]
    pub fn notify_sticky_error(&mut self, text: impl Into<String>) {
        self.set_sticky_status(text, true);
    }

    /// Explicit info → footer (4s, not suppressed).
    /// For: config reload, sync complete.
    #[allow(deprecated)]
    pub fn notify_info(&mut self, text: impl Into<String>) {
        self.set_info_status(text);
    }

    /// Tick the footer status message timer. Uses wall-clock time.
    /// Sticky/Progress messages never expire automatically.
    ///
    /// Stays on `App` (not moved to `StatusCenter`) because expiry is
    /// suppressed while any provider sync is in flight, which requires
    /// reading `self.providers.syncing`.
    pub fn tick_status(&mut self) {
        // Don't expire status while providers are still syncing
        if !self.providers.syncing.is_empty() {
            return;
        }
        if let Some(ref status) = self.status_center.status {
            if status.sticky {
                return;
            }
            let timeout_ms = status.timeout_ms();
            if timeout_ms != u64::MAX && status.created_at.elapsed().as_millis() as u64 > timeout_ms
            {
                log::debug!("footer status expired: {}", status.text);
                self.status_center.status = None;
            }
        }
    }

    /// Shim. Routes to `StatusCenter::tick_toast`.
    pub fn tick_toast(&mut self) {
        self.status_center.tick_toast();
    }

    /// Check if config or any Include file has changed externally and reload if so.
    /// Skips reload when the user is in a form (AddHost/EditHost) to avoid
    /// overwriting in-memory config while the user is editing.
    pub fn check_config_changed(&mut self) {
        if matches!(
            self.screen,
            Screen::AddHost
                | Screen::EditHost { .. }
                | Screen::ProviderForm { .. }
                | Screen::TunnelList { .. }
                | Screen::TunnelForm { .. }
                | Screen::HostDetail { .. }
                | Screen::SnippetPicker { .. }
                | Screen::SnippetForm { .. }
                | Screen::SnippetOutput { .. }
                | Screen::SnippetParamForm { .. }
                | Screen::FileBrowser { .. }
                | Screen::Containers { .. }
                | Screen::ConfirmDelete { .. }
                | Screen::ConfirmHostKeyReset { .. }
                | Screen::ConfirmPurgeStale { .. }
                | Screen::ConfirmImport { .. }
                | Screen::ConfirmVaultSign { .. }
                | Screen::TagPicker
                | Screen::BulkTagEditor
                | Screen::ThemePicker
                | Screen::WhatsNew(_)
        ) || self.tags.input.is_some()
        {
            return;
        }
        let current_mtime = reload_state::get_mtime(&self.reload.config_path);
        let changed = current_mtime != self.reload.last_modified
            || self
                .reload
                .include_mtimes
                .iter()
                .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
            || self
                .reload
                .include_dir_mtimes
                .iter()
                .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime);
        if changed {
            if let Ok(new_config) = SshConfigFile::parse(&self.reload.config_path) {
                self.hosts_state.ssh_config = new_config;
                // Invalidate undo state — config structure may have changed externally
                self.hosts_state.undo_stack.clear();
                // Clear stale ping status — hosts may have changed
                log::debug!(
                    "[config] external config change: clearing {} ping result(s) + timestamps",
                    self.ping.status.len()
                );
                self.ping.status.clear();
                self.ping.last_checked.clear();
                self.ping.filter_down_only = false;
                self.ping.checked_at = None;
                self.reload_hosts();
                self.reload.last_modified = current_mtime;
                self.reload.include_mtimes =
                    reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
                self.reload.include_dir_mtimes =
                    reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
                let count = self.hosts_state.list.len();
                self.notify_background(crate::messages::config_reloaded(count));
            }
        }
    }

    /// Non-mutating check: has the on-disk config (or any tracked Include)
    /// been modified since `self.reload.last_modified` was captured? Used by
    /// async write paths (e.g. the Vault SSH bulk-sign completion handler)
    /// to refuse writing when an external editor changed the file underneath
    /// us — overwriting those edits would silently discard user work. The
    /// backup-on-write mechanism in `SshConfigFile::write()` would still
    /// recover them, but detecting the conflict BEFORE writing is strictly
    /// better than after.
    pub fn external_config_changed(&self) -> bool {
        let current_mtime = reload_state::get_mtime(&self.reload.config_path);
        current_mtime != self.reload.last_modified
            || self
                .reload
                .include_mtimes
                .iter()
                .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
            || self
                .reload
                .include_dir_mtimes
                .iter()
                .any(|(path, old_mtime)| reload_state::get_mtime(path) != *old_mtime)
    }

    /// Update the last_modified timestamp (call after writing config).
    pub fn update_last_modified(&mut self) {
        self.reload.last_modified = reload_state::get_mtime(&self.reload.config_path);
        self.reload.include_mtimes =
            reload_state::snapshot_include_mtimes(&self.hosts_state.ssh_config);
        self.reload.include_dir_mtimes =
            reload_state::snapshot_include_dir_mtimes(&self.hosts_state.ssh_config);
    }

    /// Returns true if any host or provider has a vault role configured.
    pub fn has_any_vault_role(&self) -> bool {
        for host in &self.hosts_state.list {
            if host.vault_ssh.is_some() {
                return true;
            }
        }
        for section in &self.providers.config.sections {
            if !section.vault_role.is_empty() {
                return true;
            }
        }
        false
    }

    /// Poll active tunnels for exit. Returns (alias, message, is_error) tuples.
    pub fn poll_tunnels(&mut self) -> Vec<(String, String, bool)> {
        self.tunnels.poll()
    }

    /// Recompute the lsof poller's bind-port list from the current
    /// `active` map plus each host's directives in the SSH config.
    /// Called after every tunnel start/stop. The poller picks up the
    /// new list on its next iteration.
    pub fn refresh_tunnel_bind_ports(&mut self) {
        let mut ports: Vec<(String, u16, u32)> = Vec::new();
        for (alias, tunnel) in &self.tunnels.active {
            let pid = tunnel.child.id();
            for rule in self.hosts_state.ssh_config.find_tunnel_directives(alias) {
                ports.push((alias.clone(), rule.bind_port, pid));
            }
        }
        self.tunnels.set_lsof_ports(ports);
    }
}

/// Cycle list selection forward or backward with wraparound.
pub(crate) fn cycle_selection(state: &mut ListState, len: usize, forward: bool) {
    if len == 0 {
        return;
    }
    let i = match state.selected() {
        Some(i) => {
            if forward {
                if i >= len - 1 { 0 } else { i + 1 }
            } else if i == 0 {
                len - 1
            } else {
                i - 1
            }
        }
        None => 0,
    };
    state.select(Some(i));
}

/// Jump forward by page_size items, clamping at the end (no wrap).
pub(crate) fn page_down(state: &mut ListState, len: usize, page_size: usize) {
    if len == 0 {
        return;
    }
    let current = state.selected().unwrap_or(0);
    let next = (current + page_size).min(len - 1);
    state.select(Some(next));
}

/// Jump backward by page_size items, clamping at 0 (no wrap).
pub(crate) fn page_up(state: &mut ListState, len: usize, page_size: usize) {
    if len == 0 {
        return;
    }
    let current = state.selected().unwrap_or(0);
    let prev = current.saturating_sub(page_size);
    state.select(Some(prev));
}

// Re-export the jump bar types so call sites keep referring to them via
// `crate::app::JumpHit` / `crate::app::JumpAction` without caring
// which submodule they live in.
pub use jump::{
    ContainerHit, HostHit, JumpAction, JumpActionTarget, JumpHit, RecentRef, RecentsFile,
    SnippetHit, SourceKind, TunnelHit,
};

/// Backwards-compatible alias for the old `PaletteCommand` (now `JumpAction`) name. The
/// renamed type is `JumpAction`. Test-only — there is no production
/// caller.
#[cfg(test)]
pub type PaletteCommand = JumpAction;

/// Unified action set. Every action declares its `target` so dispatch
/// switches `top_page` first, then synthesises the hotkey for the right
/// handler. The jump bar shows this same list regardless of which
/// top-page was active when it opened — so the overlay size is
/// consistent and `Tunnels: Add tunnel` is reachable from the Hosts
/// tab and vice versa.
static ALL_JUMP_ACTIONS: &[JumpAction] = &[
    JumpAction {
        key: 'a',
        key_str: "a",
        label: "Hosts: Add host",
        aliases: &["new", "create"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'A',
        key_str: "A",
        label: "Hosts: Add pattern",
        aliases: &["new pattern", "wildcard"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'e',
        key_str: "e",
        label: "Hosts: Edit host",
        aliases: &["modify", "change"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'd',
        key_str: "d",
        label: "Hosts: Delete host",
        aliases: &["remove", "rm"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'c',
        key_str: "c",
        label: "Hosts: Clone host",
        aliases: &["duplicate", "copy"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'u',
        key_str: "u",
        label: "Hosts: Undo delete",
        aliases: &["restore"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 't',
        key_str: "t",
        label: "Hosts: Tag host",
        aliases: &["label", "category"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'i',
        key_str: "i",
        label: "Hosts: Show all directives",
        aliases: &["raw", "config", "settings"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'y',
        key_str: "y",
        label: "Clipboard: Copy SSH command",
        aliases: &["yank"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'x',
        key_str: "x",
        label: "Clipboard: Copy config block",
        aliases: &["yank config"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'X',
        key_str: "X",
        label: "Hosts: Purge stale hosts",
        aliases: &["clean", "cleanup"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'F',
        key_str: "F",
        label: "Files: Browse remote files",
        aliases: &[
            "browse",
            "filesystem",
            "scp",
            "sftp",
            "transfer",
            "explorer",
            "open",
        ],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'C',
        key_str: "C",
        label: "Containers: List containers",
        aliases: &["docker", "podman", "ps", "open"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'K',
        key_str: "K",
        label: "Keys: Manage SSH keys",
        aliases: &["identity", "id_rsa", "id_ed25519", "private key", "open"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'S',
        key_str: "S",
        label: "Providers: Manage cloud sync",
        aliases: &["cloud", "aws", "gcp", "azure", "hetzner", "sync", "open"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'V',
        key_str: "V",
        label: "Vault: Sign certificate",
        aliases: &["hashicorp", "ssh cert", "vault ssh"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'I',
        key_str: "I",
        label: "Hosts: Import from known_hosts",
        aliases: &["known", "import"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'm',
        key_str: "m",
        label: "Settings: Switch theme",
        aliases: &["color", "appearance", "dark", "light"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'n',
        key_str: "n",
        label: "Help: What's new",
        aliases: &["changelog", "news", "release notes"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'r',
        key_str: "r",
        label: "Snippets: Run snippet",
        aliases: &["execute", "command"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'R',
        key_str: "R",
        label: "Snippets: Run on all visible",
        aliases: &["batch", "execute all"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'p',
        key_str: "p",
        label: "Hosts: Ping host",
        aliases: &["health", "check"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'P',
        key_str: "P",
        label: "Hosts: Ping all hosts",
        aliases: &["health all"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: '!',
        key_str: "!",
        label: "Hosts: Show down only",
        aliases: &["filter offline", "down only"],
        target: JumpActionTarget::Hosts,
    },
    // Tunnel-tab actions. Disambiguated by label so they coexist with
    // hosts-tab hotkey letters in the same list. Dispatch switches to
    // Tunnels top-page before synthesising the keypress.
    JumpAction {
        key: 'T',
        key_str: "T",
        label: "Tunnels: Manage tunnels",
        aliases: &["forward", "port forward", "ssh -L", "ssh -R", "open"],
        target: JumpActionTarget::Hosts,
    },
    JumpAction {
        key: 'a',
        key_str: "a",
        label: "Tunnels: Add tunnel",
        aliases: &["new tunnel", "create tunnel", "forward"],
        target: JumpActionTarget::Tunnels,
    },
    JumpAction {
        key: 'e',
        key_str: "e",
        label: "Tunnels: Edit tunnel",
        aliases: &["modify tunnel"],
        target: JumpActionTarget::Tunnels,
    },
    JumpAction {
        key: 'd',
        key_str: "d",
        label: "Tunnels: Delete tunnel",
        aliases: &["remove tunnel"],
        target: JumpActionTarget::Tunnels,
    },
    JumpAction {
        key: 's',
        key_str: "s",
        label: "Tunnels: Sort",
        aliases: &["order tunnels"],
        target: JumpActionTarget::Tunnels,
    },
];

/// Which command set the jump bar displays. Determined by the screen that
/// opened the jump bar so the action list matches what the underlying
/// handler can dispatch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JumpMode {
    #[default]
    Hosts,
    Tunnels,
}

/// Cap on hits rendered per section. Broad queries (e.g. one character)
/// match thousands of candidates; capping keeps the jump bar legible without
/// virtualizing the render. The selected hit always falls within the cap
/// because results are sorted by score before truncation.
pub const PALETTE_PER_SECTION_CAP: usize = 32;

/// On the empty-query state we show only the top-N actions to keep the
/// first impression a short menu rather than a wall. The full list is
/// one keystroke away. Lives in the data layer so `visible_hits()`,
/// `empty_state_groups()` and the Down handler all agree on the bound.
pub const JUMP_EMPTY_STATE_ACTIONS_CAP: usize = 6;

/// Display order for action categories on the empty state. The
/// round-robin walks these buckets in this order, NOT in static-table
/// order, so the first impression always shows the most-used categories
/// regardless of how the static action list happens to be sorted.
/// Categories not listed here fall through to a stable last-seen order.
const CATEGORY_PRIORITY: &[&str] = &[
    "Hosts",
    "Tunnels",
    "Containers",
    "Files",
    "Vault",
    "Keys",
    "Providers",
    "Snippets",
    "Clipboard",
    "Settings",
    "Help",
];

/// Minimum nucleo score for actions. Below this the action is dropped from
/// results — kills "Containers: List containers" matching `eric` because
/// `e/r/i/c` happen to scatter across the label without semantic intent.
const PALETTE_ACTION_FLOOR: u32 = 30;

/// Field-prefix parser: `user:eric` → (`Some(QueryScope::User)`, "eric").
/// Returns `(None, query)` for queries without a recognised scope.
pub fn parse_query_scope(query: &str) -> (Option<QueryScope>, &str) {
    if let Some((prefix, rest)) = query.split_once(':') {
        let scope = match prefix.trim() {
            "user" => Some(QueryScope::User),
            "host" => Some(QueryScope::Hostname),
            "proxy" => Some(QueryScope::ProxyJump),
            "vault" => Some(QueryScope::VaultSsh),
            "tag" => Some(QueryScope::Tag),
            _ => None,
        };
        if scope.is_some() {
            return (scope, rest.trim_start());
        }
    }
    (None, query)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryScope {
    User,
    Hostname,
    ProxyJump,
    VaultSsh,
    Tag,
}

/// Truncate a string to `max` characters, appending "..." if cut.
fn preview(s: &str, max: usize) -> String {
    let s = s.replace('\n', " ");
    let chars: Vec<char> = s.chars().collect();
    if chars.len() <= max {
        s
    } else {
        let mut out: String = chars.iter().take(max.saturating_sub(3)).collect();
        out.push_str("...");
        out
    }
}

/// Restrict scoring to a single field when the user prefixes the query
/// with `user:` / `host:` / `proxy:` / `vault:` / `tag:`. Returns `None`
/// when no scope is set OR when the scope does not apply to the hit
/// (e.g. `vault:` on a snippet) — caller falls back to the full set.
fn scoped_haystacks_for(hit: &JumpHit, scope: Option<QueryScope>) -> Option<Vec<&str>> {
    let scope = scope?;
    match (hit, scope) {
        (JumpHit::Host(h), QueryScope::User) if !h.user.is_empty() => Some(vec![&h.user]),
        (JumpHit::Host(h), QueryScope::Hostname) if !h.hostname.is_empty() => {
            Some(vec![&h.hostname])
        }
        (JumpHit::Host(h), QueryScope::ProxyJump) if !h.proxy_jump.is_empty() => {
            Some(vec![&h.proxy_jump])
        }
        (JumpHit::Host(h), QueryScope::VaultSsh) => h.vault_ssh.as_deref().map(|s| vec![s]),
        (JumpHit::Host(h), QueryScope::Tag) => Some(h.tags.iter().map(|t| t.as_str()).collect()),
        // Scoped queries do not match other kinds.
        _ => None,
    }
}

/// Determine which field caused the host hit to match. The renderer uses
/// this to append a `via user`, `via proxy`, `vault: <role>` hint to the
/// row when the matched field is not part of the visible columns. Returns
/// `None` if the alias/hostname (already visible) matched.
pub fn match_source_for_host(host: &HostHit, query: &str) -> Option<MatchSource> {
    if query.is_empty() {
        return None;
    }
    let q = query.to_lowercase();
    let alias_hit = host.alias.to_lowercase().contains(&q);
    let hostname_hit = host.hostname.to_lowercase().contains(&q);
    if alias_hit || hostname_hit {
        return None;
    }
    if !host.user.is_empty() && host.user.to_lowercase().contains(&q) {
        return Some(MatchSource::User);
    }
    if !host.proxy_jump.is_empty() && host.proxy_jump.to_lowercase().contains(&q) {
        return Some(MatchSource::ProxyJump);
    }
    if let Some(role) = &host.vault_ssh {
        if role.to_lowercase().contains(&q) {
            return Some(MatchSource::VaultSsh);
        }
    }
    if !host.identity_file.is_empty() && host.identity_file.to_lowercase().contains(&q) {
        return Some(MatchSource::IdentityFile);
    }
    None
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchSource {
    User,
    ProxyJump,
    VaultSsh,
    IdentityFile,
}

/// Reorder actions so the first N show one per category, the next N
/// show the second action of each category, etc. Preserves within-bucket
/// order so muscle memory survives. Buckets are visited in
/// `CATEGORY_PRIORITY` order — declarative, decoupled from static-table
/// row order — so the empty-state top-N always leads with the most
/// important categories. Categories not in the priority list fall to
/// the end in stable encounter order.
fn round_robin_actions_by_category(actions: impl Iterator<Item = JumpAction>) -> Vec<JumpHit> {
    let mut buckets: Vec<(String, Vec<JumpAction>)> = Vec::new();
    for action in actions {
        let category = action
            .label
            .split_once(':')
            .map(|(c, _)| c.trim().to_string())
            .unwrap_or_else(|| "Other".to_string());
        if let Some(slot) = buckets.iter_mut().find(|(c, _)| c == &category) {
            slot.1.push(action);
        } else {
            buckets.push((category, vec![action]));
        }
    }
    let priority_index = |cat: &str| -> usize {
        CATEGORY_PRIORITY
            .iter()
            .position(|p| *p == cat)
            .unwrap_or(usize::MAX)
    };
    buckets.sort_by_key(|(c, _)| priority_index(c));
    let mut out: Vec<JumpHit> = Vec::new();
    let mut depth = 0usize;
    let max_depth = buckets.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
    while depth < max_depth {
        for (_, bucket) in &buckets {
            if let Some(action) = bucket.get(depth) {
                out.push(JumpHit::Action(*action));
            }
        }
        depth += 1;
    }
    out
}

fn kind_rank(k: SourceKind) -> u8 {
    match k {
        SourceKind::Host => 0,
        SourceKind::Tunnel => 1,
        SourceKind::Container => 2,
        SourceKind::Snippet => 3,
        SourceKind::Action => 4,
    }
}

/// Find `prior` in `hits` and return its index, or `fallback` if the prior
/// hit is gone (e.g. the typed query no longer matches it). Used by
/// `recompute_jump_hits` so mid-typing arrow navigation does not lose
/// the user's place.
fn restore_selection(hits: &[JumpHit], prior: Option<&RecentRef>, fallback: usize) -> usize {
    if let Some(target) = prior {
        if let Some(idx) = hits.iter().position(|h| &h.identity() == target) {
            return idx;
        }
    }
    fallback.min(hits.len().saturating_sub(1))
}

impl JumpAction {
    #[cfg(test)]
    pub fn all() -> &'static [JumpAction] {
        ALL_JUMP_ACTIONS
    }

    /// The jump bar surfaces the same action set regardless of mode now.
    /// `mode` is preserved on the API so the dispatcher and test helpers
    /// can still pass through, but it no longer narrows the visible list.
    pub fn for_mode(_mode: JumpMode) -> &'static [JumpAction] {
        ALL_JUMP_ACTIONS
    }
}

#[derive(Debug, Default)]
pub struct JumpState {
    pub query: String,
    pub selected: usize,
    pub mode: JumpMode,
    /// Computed result list, recomputed on every query change. Empty until
    /// `App::recompute_jump_hits` runs.
    pub hits: Vec<JumpHit>,
    /// MRU snapshot loaded on jump bar open, used by the empty-query state.
    pub recents: Vec<JumpHit>,
    /// True once the user has navigated (Down/Up/Tab) at least once. The
    /// renderer keeps the selection invisible on the empty state until
    /// this flips, so the eye stays on the input field on first open.
    /// Also makes the FIRST Down keystroke land on row 0 instead of
    /// skipping to row 1.
    pub cursor_revealed: bool,
    /// Reused matcher with growable scratch buffers. Populated lazily on
    /// the first scoring pass and kept across keystrokes so nucleo's
    /// internal vectors do not reallocate every recompute.
    pub matcher: Option<nucleo_matcher::Matcher>,
}

// Manual `Clone` because `nucleo_matcher::Matcher` is not `Clone`. State
// clones (e.g. in tests) drop the cached matcher and let the next
// recompute build a fresh one — correct behavior, just slightly slower
// for the next keystroke after a clone.
impl Clone for JumpState {
    fn clone(&self) -> Self {
        Self {
            query: self.query.clone(),
            selected: self.selected,
            mode: self.mode,
            hits: self.hits.clone(),
            recents: self.recents.clone(),
            cursor_revealed: self.cursor_revealed,
            matcher: None,
        }
    }
}

impl JumpState {
    pub fn for_mode(mode: JumpMode) -> Self {
        Self {
            mode,
            ..Self::default()
        }
    }

    pub fn push_query(&mut self, c: char) {
        if self.query.len() < 64 {
            self.query.push(c);
        }
        // Selection is handled by `App::recompute_jump_hits` which
        // tries to keep the previously-selected hit's identity. We do
        // NOT reset to 0 here because that would defeat mid-typing
        // navigation: typing a char must not jump the cursor.
    }

    pub fn pop_query(&mut self) {
        self.query.pop();
    }

    /// Return the hit list to render. With an empty query this is the
    /// composed empty-state view (recents + the round-robin top-N
    /// actions); otherwise it is the live computed `hits`. The cap on
    /// the empty state is applied HERE (data layer) so the Down/Up
    /// handlers, `visible_hits().len()`, and the renderer all agree on
    /// the same bound — without this, scrolling past the rendered cap
    /// would silently advance `selected` into invisible rows and the
    /// highlight would appear to jump back to row 0.
    pub fn visible_hits(&self) -> Vec<JumpHit> {
        if self.query.is_empty() {
            let mut out: Vec<JumpHit> = self.recents.clone();
            let recent_keys: std::collections::HashSet<RecentRef> =
                self.recents.iter().map(|h| h.identity()).collect();
            let actions = round_robin_actions_by_category(
                JumpAction::for_mode(self.mode)
                    .iter()
                    .filter(|a| {
                        let id = RecentRef::new(SourceKind::Action, a.key.to_string());
                        !recent_keys.contains(&id)
                    })
                    .copied(),
            );
            for hit in actions.into_iter().take(JUMP_EMPTY_STATE_ACTIONS_CAP) {
                out.push(hit);
            }
            out
        } else {
            self.hits.clone()
        }
    }

    /// Number of actions available for the empty-state ACTIONS section
    /// BEFORE the cap. Used by the renderer to render `Actions  6 of 29`
    /// when the cap is applied.
    pub fn empty_state_actions_total(&self) -> usize {
        let recent_keys: std::collections::HashSet<RecentRef> =
            self.recents.iter().map(|h| h.identity()).collect();
        JumpAction::for_mode(self.mode)
            .iter()
            .filter(|a| {
                let id = RecentRef::new(SourceKind::Action, a.key.to_string());
                !recent_keys.contains(&id)
            })
            .count()
    }

    /// Group `visible_hits()` for the query view: by `SourceKind` in render
    /// order. Empty sections are omitted. Only meaningful when a query is
    /// active; the empty-state view uses `empty_state_groups` instead.
    pub fn grouped_hits(&self) -> Vec<(SourceKind, Vec<JumpHit>)> {
        let visible = self.visible_hits();
        let mut out = Vec::with_capacity(SourceKind::render_order().len());
        for kind in SourceKind::render_order() {
            let group: Vec<JumpHit> = visible
                .iter()
                .filter(|h| h.kind() == kind)
                .cloned()
                .collect();
            if !group.is_empty() {
                out.push((kind, group));
            }
        }
        out
    }

    /// Empty-state grouping: a single `RECENT` group (everything that came
    /// from the MRU log, of any kind) followed by an `ACTIONS` group.
    /// Returns `(label, hits)` rather than `(kind, hits)` so the renderer
    /// can distinguish "RECENT" from a per-kind label.
    pub fn empty_state_groups(&self) -> Vec<(&'static str, Vec<JumpHit>)> {
        let mut out: Vec<(&'static str, Vec<JumpHit>)> = Vec::new();
        if !self.recents.is_empty() {
            out.push(("RECENT", self.recents.clone()));
        }
        let recent_keys: std::collections::HashSet<RecentRef> =
            self.recents.iter().map(|h| h.identity()).collect();
        // Round-robin by `Category:` prefix so the empty-state top-N
        // shows one action per category before showing a second from the
        // same category. Without this, the first six all start with
        // `Hosts:` (because that's the static-list order) and the user
        // believes the bar is a host CRUD menu.
        let actions: Vec<JumpHit> = round_robin_actions_by_category(
            JumpAction::for_mode(self.mode)
                .iter()
                .filter(|a| {
                    let id = RecentRef::new(SourceKind::Action, a.key.to_string());
                    !recent_keys.contains(&id)
                })
                .copied(),
        )
        .into_iter()
        .take(JUMP_EMPTY_STATE_ACTIONS_CAP)
        .collect();
        if !actions.is_empty() {
            out.push(("ACTIONS", actions));
        }
        out
    }

    /// Map `selected` index (into `visible_hits()`) to a `SourceKind` so the
    /// renderer knows which section header is currently active.
    pub fn selected_section(&self) -> Option<SourceKind> {
        self.visible_hits().get(self.selected).map(|h| h.kind())
    }

    /// Return actions whose label substring-matches the current query.
    /// Test-only shim for tests that predate the unified jump bar.
    /// Production code iterates `visible_hits()` instead.
    #[cfg(test)]
    pub fn filtered_commands(&self) -> Vec<JumpAction> {
        let all = JumpAction::for_mode(self.mode);
        if self.query.is_empty() {
            return all.to_vec();
        }
        let q = self.query.to_lowercase();
        all.iter()
            .filter(|cmd| {
                cmd.label.to_lowercase().contains(&q)
                    || cmd.aliases.iter().any(|a| a.to_lowercase().contains(&q))
            })
            .copied()
            .collect()
    }

    /// Move selection to the first hit in the next non-empty section. Wraps.
    pub fn jump_next_section(&mut self) {
        let visible = self.visible_hits();
        if visible.is_empty() {
            return;
        }
        if self.query.is_empty() {
            // Empty-state has up to two groups: RECENT (length =
            // recents.len()) and ACTIONS (the rest). Tab toggles between
            // their first rows. Skip the toggle if there is no second
            // group to jump to (e.g. no recents, or no actions after
            // recents). The two `if` branches inside this block both fire
            // in real cases: from RECENT row n we jump to actions; from
            // an action row we wrap back to the first recent.
            let n_recent = self.recents.len();
            if n_recent == 0 || n_recent >= visible.len() {
                return;
            }
            if self.selected < n_recent {
                self.selected = n_recent; // RECENT -> ACTIONS
            } else {
                self.selected = 0; // ACTIONS -> first RECENT
            }
            return;
        }
        let groups = self.grouped_hits();
        if groups.len() < 2 {
            return;
        }
        let cur_kind = match self.selected_section() {
            Some(k) => k,
            None => {
                self.selected = 0;
                return;
            }
        };
        let cur_idx = groups.iter().position(|(k, _)| *k == cur_kind).unwrap_or(0);
        let next_idx = (cur_idx + 1) % groups.len();
        let next_kind = groups[next_idx].0;
        if let Some(pos) = visible.iter().position(|h| h.kind() == next_kind) {
            self.selected = pos;
        }
    }
}

#[cfg(test)]
mod tests;
