use super::*;
use crate::app::{App, FormField, ProviderFormField, ProviderFormFields, Screen};
use crate::providers::config::{ProviderConfig, ProviderSection};
use crate::ssh_config::model::SshConfigFile;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::mpsc;

fn test_provider_config() -> ProviderConfig {
    // Unique tempdir per call so parallel tests do not race on the same
    // provider-config file when `path_override` gets written.
    let dir = tempfile::tempdir().expect("tempdir").keep();
    ProviderConfig {
        path_override: Some(dir.join("providers")),
        ..Default::default()
    }
}

fn make_app(content: &str) -> App {
    // Unique tempdir per call — parallel `cargo test` threads must not
    // share a config path when `app.hosts_state.ssh_config.write()` or preferences-write
    // runs.
    let scratch = tempfile::tempdir().expect("tempdir").keep();
    // Set preferences override BEFORE App::new so PingState::from_preferences
    // reads the per-test path, never the real ~/.purple/preferences.
    crate::preferences::set_path_override(scratch.join("preferences"));
    let config = SshConfigFile {
        elements: SshConfigFile::parse_content(content),
        path: scratch.join("test_config"),
        crlf: false,
        bom: false,
    };
    let mut app = App::new(config);
    app.providers.config = test_provider_config();
    app
}

fn key(code: KeyCode) -> KeyEvent {
    KeyEvent::new(code, KeyModifiers::NONE)
}

/// App met een geconfigureerde DigitalOcean (auto_sync=true) en een nieuw Proxmox.
fn make_providers_app_with_do() -> App {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    app.providers.config.set_section(ProviderSection {
        id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
        token: "tok".to_string(),
        alias_prefix: "do".to_string(),
        user: "root".to_string(),
        identity_file: String::new(),
        url: String::new(),
        verify_tls: true,
        auto_sync: true,
        profile: String::new(),
        regions: String::new(),
        project: String::new(),
        compartment: String::new(),
        vault_role: String::new(),
        vault_addr: String::new(),
    });
    app
}

fn make_providers_app_with_proxmox() -> App {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    app.providers.config.set_section(ProviderSection {
        id: crate::providers::config::ProviderConfigId::bare("proxmox"),
        token: "user@pam!t=secret".to_string(),
        alias_prefix: "pve".to_string(),
        user: "root".to_string(),
        identity_file: String::new(),
        url: "https://pve.local:8006".to_string(),
        verify_tls: true,
        auto_sync: false,
        profile: String::new(),
        regions: String::new(),
        project: String::new(),
        compartment: String::new(),
        vault_role: String::new(),
        vault_addr: String::new(),
    });
    app
}

/// Positioneer de cursor op een bepaalde provider in de lijst en stuur Enter.
fn open_provider_form(app: &mut App, provider_name: &str) {
    let sorted = app.sorted_provider_names();
    let idx = sorted.iter().position(|n| n == provider_name).unwrap();
    app.ui.provider_list_state.select(Some(idx));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(app, key(KeyCode::Enter), &tx);
}

// --- Form initialisatie ---

#[test]
fn test_provider_form_init_existing_do_preserves_auto_sync_true() {
    let mut app = make_providers_app_with_do();
    open_provider_form(&mut app, "digitalocean");
    assert!(
        app.providers.form.auto_sync,
        "Bestaande DO provider (auto_sync=true) moet true blijven in het form"
    );
}

#[test]
fn test_provider_form_init_existing_proxmox_preserves_auto_sync_false() {
    let mut app = make_providers_app_with_proxmox();
    open_provider_form(&mut app, "proxmox");
    assert!(
        !app.providers.form.auto_sync,
        "Bestaande Proxmox provider (auto_sync=false) moet false blijven in het form"
    );
}

#[test]
fn test_provider_form_init_existing_do_explicit_false_preserved() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    // DO met auto_sync=false (gebruiker heeft het handmatig uitgezet)
    app.providers.config.set_section(ProviderSection {
        id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
        token: "tok".to_string(),
        alias_prefix: "do".to_string(),
        user: "root".to_string(),
        identity_file: String::new(),
        url: String::new(),
        verify_tls: true,
        auto_sync: false,
        profile: String::new(),
        regions: String::new(),
        project: String::new(),
        compartment: String::new(),
        vault_role: String::new(),
        vault_addr: String::new(),
    });
    open_provider_form(&mut app, "digitalocean");
    assert!(
        !app.providers.form.auto_sync,
        "DO met auto_sync=false moet false blijven"
    );
}

#[test]
fn test_provider_form_init_new_proxmox_defaults_to_false() {
    // Proxmox zonder bestaande config: default auto_sync=false
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config(); // geen config voor proxmox
    open_provider_form(&mut app, "proxmox");
    assert!(
        !app.providers.form.auto_sync,
        "Nieuw Proxmox form moet auto_sync=false als default tonen"
    );
}

#[test]
fn test_provider_form_init_new_digitalocean_defaults_to_true() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    open_provider_form(&mut app, "digitalocean");
    assert!(
        app.providers.form.auto_sync,
        "Nieuw DigitalOcean form moet auto_sync=true als default tonen"
    );
}

// --- Space toggle ---

fn make_form_app_focused_on(provider: &str, field: ProviderFormField) -> App {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare(provider),
    };
    app.providers.form = ProviderFormFields {
        url: String::new(),
        token: "tok".to_string(),
        profile: String::new(),
        project: String::new(),
        compartment: String::new(),
        regions: String::new(),
        alias_prefix: "do".to_string(),
        user: "root".to_string(),
        identity_file: String::new(),
        verify_tls: true,
        auto_sync: true,
        vault_role: String::new(),
        vault_addr: String::new(),
        focused_field: field,
        cursor_pos: 0,
        expanded: true, // Tests assume all fields visible
    };
    app
}

/// Submit provider form with fresh mtime capture to minimize race window.
fn submit_form(app: &mut App) {
    app.capture_provider_form_mtime();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(app, key(KeyCode::Enter), &tx);
}

/// Assert that the status message contains the expected validation error.
/// Tolerates the conflict-detection race: if another parallel test wrote
/// to ~/.purple/providers between mtime capture and submit, the conflict
/// check fires before validation and the test is inconclusive (not a bug).
fn assert_status_contains(app: &App, expected: &str) {
    // Check both footer status and toast (messages route to different destinations)
    let status_text = app.status_center.status.as_ref().map(|s| s.text.as_str());
    let toast_text = app.status_center.toast.as_ref().map(|t| t.text.as_str());
    let msg = status_text
        .or(toast_text)
        .expect("status or toast should be set");
    if msg.contains("changed externally") {
        return; // inconclusive due to race
    }
    assert!(
        msg.contains(expected),
        "Expected status/toast to contain '{}', got: '{}'",
        expected,
        msg
    );
}

fn assert_status_not_contains(app: &App, not_expected: &str) {
    let status_msg = app
        .status_center
        .status
        .as_ref()
        .map(|s| s.text.as_str())
        .unwrap_or("");
    let toast_msg = app
        .status_center
        .toast
        .as_ref()
        .map(|t| t.text.as_str())
        .unwrap_or("");
    if status_msg.contains("changed externally") || toast_msg.contains("changed externally") {
        return; // inconclusive due to race
    }
    assert!(
        !status_msg.contains(not_expected) && !toast_msg.contains(not_expected),
        "Status/toast should NOT contain '{}', got status: '{}', toast: '{}'",
        not_expected,
        status_msg,
        toast_msg
    );
}

#[test]
fn test_space_toggles_auto_sync_true_to_false() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::AutoSync);
    assert!(app.providers.form.auto_sync);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(!app.providers.form.auto_sync);
}

#[test]
fn test_space_toggles_auto_sync_false_to_true() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::AutoSync);
    app.providers.form.auto_sync = false;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(app.providers.form.auto_sync);
}

#[test]
fn test_space_on_other_field_does_not_affect_auto_sync() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.auto_sync = true;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    // Space op Token voegt spatie toe aan tekstveld; auto_sync ongewijzigd
    assert!(app.providers.form.auto_sync);
}

// --- Char/Backspace blokkering op AutoSync ---

#[test]
fn test_char_input_blocked_when_auto_sync_focused() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::AutoSync);
    let original_token = app.providers.form.token.clone();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('x')), &tx);
    // Geen enkel tekstveld mag gewijzigd zijn
    assert_eq!(app.providers.form.token, original_token);
    assert_eq!(app.providers.form.alias_prefix, "do");
}

#[test]
fn test_backspace_blocked_when_auto_sync_focused() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::AutoSync);
    let original_token = app.providers.form.token.clone();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert_eq!(app.providers.form.token, original_token);
}

// --- Submit persisteert auto_sync ---

#[test]
fn test_submit_provider_form_persists_auto_sync_false() {
    // Submit met auto_sync=false moet de sectie opslaan met auto_sync=false.
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
    };
    app.providers.config = test_provider_config();
    app.providers.form = ProviderFormFields {
        url: String::new(),
        token: "tok".to_string(),
        profile: String::new(),
        project: String::new(),
        compartment: String::new(),
        regions: String::new(),
        alias_prefix: "do".to_string(),
        user: "root".to_string(),
        identity_file: String::new(),
        verify_tls: true,
        auto_sync: false,
        vault_role: String::new(),
        vault_addr: String::new(),
        focused_field: ProviderFormField::Token,
        cursor_pos: 0,
        expanded: false,
    };

    let (tx, _rx) = mpsc::channel();
    // Enter triggert submit; save() kan falen zonder ~/.purple dir, maar de
    // in-memory sectie wordt altijd bijgewerkt vóór de save.
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);

    // Ongeacht of save() slaagde: de sectie in provider_config is bijgewerkt.
    if let Some(section) = app.providers.config.section("digitalocean") {
        assert!(
            !section.auto_sync,
            "Opgeslagen sectie moet auto_sync=false hebben"
        );
    }
    // Als het form is gesloten (save geslaagd), controleert de screen-state
    // dat de toggle correct is doorgegeven.
}

#[test]
fn test_submit_provider_form_persists_auto_sync_true() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
    };
    app.providers.config = test_provider_config();
    app.providers.form = ProviderFormFields {
        url: String::new(),
        token: "tok".to_string(),
        profile: String::new(),
        project: String::new(),
        compartment: String::new(),
        regions: String::new(),
        alias_prefix: "do".to_string(),
        user: "root".to_string(),
        identity_file: String::new(),
        verify_tls: true,
        auto_sync: true,
        vault_role: String::new(),
        vault_addr: String::new(),
        focused_field: ProviderFormField::Token,
        cursor_pos: 0,
        expanded: false,
    };

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);

    if let Some(section) = app.providers.config.section("digitalocean") {
        assert!(
            section.auto_sync,
            "Opgeslagen sectie moet auto_sync=true hebben"
        );
    }
}

#[test]
fn test_submit_provider_form_persists_vault_role() {
    // Submit met een vault_role moet de in-memory sectie bijwerken met
    // dezelfde role. save() naar disk kan falen in een test-omgeving zonder
    // ~/.purple dir; we vertrouwen alleen op de in-memory mutatie hier,
    // identiek aan test_submit_provider_form_persists_auto_sync_*.
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
    };
    app.providers.config = test_provider_config();
    app.providers.form = ProviderFormFields {
        url: String::new(),
        token: "tok".to_string(),
        profile: String::new(),
        project: String::new(),
        compartment: String::new(),
        regions: String::new(),
        alias_prefix: "do".to_string(),
        user: "root".to_string(),
        identity_file: String::new(),
        verify_tls: true,
        auto_sync: true,
        vault_role: "ssh-client-signer/sign/engineer".to_string(),
        vault_addr: String::new(),
        focused_field: ProviderFormField::Token,
        cursor_pos: 0,
        expanded: true,
    };

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);

    if let Some(section) = app.providers.config.section("digitalocean") {
        assert_eq!(
            section.vault_role, "ssh-client-signer/sign/engineer",
            "vault_role moet round-trippen via provider form submit"
        );
    }
}

#[test]
fn test_provider_config_parse_vault_role_present() {
    // Direct: parse INI met vault_role en verifieer dat de waarde wordt
    // overgenomen. Aanvulling op de form-submit test, onafhankelijk van
    // filesystem en form state.
    let input = "[digitalocean]\ntoken=abc\nvault_role=ssh-client-signer/sign/engineer\n";
    let cfg = crate::providers::config::ProviderConfig::parse(input);
    let section = cfg.section("digitalocean").expect("section");
    assert_eq!(section.vault_role, "ssh-client-signer/sign/engineer");
}

// =========================================================================
// Provider form validation tests
// =========================================================================

#[test]
fn test_submit_provider_form_rejects_control_chars_in_token() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.token = "tok\x01en".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "control characters");
}

#[test]
fn test_submit_provider_form_rejects_control_chars_in_alias_prefix() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.alias_prefix = "do\x00".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "control characters");
}

#[test]
fn test_submit_provider_form_rejects_control_chars_in_url() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::Url);
    app.providers.form.url = "https://pve\x0a.local:8006".to_string();
    app.providers.form.token = "user@pam!t=secret".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "control characters");
}

#[test]
fn test_submit_provider_form_rejects_control_chars_in_user() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.user = "ro\tot".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "control characters");
}

#[test]
fn test_submit_provider_form_rejects_control_chars_in_identity_file() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.identity_file = "~/.ssh/id\x1b_rsa".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "control characters");
}

#[test]
fn test_submit_proxmox_rejects_empty_url() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::Url);
    app.providers.form.url = "".to_string();
    app.providers.form.token = "user@pam!t=secret".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "URL is required");
}

#[test]
fn test_submit_proxmox_rejects_http_url() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::Url);
    app.providers.form.url = "http://pve.local:8006".to_string();
    app.providers.form.token = "user@pam!t=secret".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "https://");
}

#[test]
fn test_submit_proxmox_accepts_https_url() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::Url);
    app.providers.form.url = "https://pve.local:8006".to_string();
    app.providers.form.token = "user@pam!t=secret".to_string();
    submit_form(&mut app);
    assert_status_not_contains(&app, "URL is required");
    assert_status_not_contains(&app, "https://");
}

#[test]
fn test_submit_proxmox_rejects_bare_hostname_url() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::Url);
    app.providers.form.url = "pve.local:8006".to_string();
    app.providers.form.token = "user@pam!t=secret".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "https://");
}

#[test]
fn test_submit_provider_form_rejects_empty_token() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.token = "".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "Token");
}

#[test]
fn test_submit_provider_form_rejects_whitespace_only_token() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.token = "   ".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "Token");
}

#[test]
fn test_submit_provider_form_rejects_pattern_alias_prefix() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.alias_prefix = "do*".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "pattern");
}

#[test]
fn test_submit_provider_form_rejects_question_mark_alias() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.alias_prefix = "do?".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "pattern");
}

#[test]
fn test_submit_provider_form_rejects_negation_alias() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.alias_prefix = "!do".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "pattern");
}

#[test]
fn test_submit_provider_form_rejects_whitespace_in_user() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.user = "my user".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "whitespace");
}

// =========================================================================
// GCP-specific form validation
// =========================================================================

fn make_gcp_form_app() -> App {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("gcp"),
    };
    app.providers.form = ProviderFormFields {
        url: String::new(),
        token: "/path/to/sa.json".to_string(),
        profile: String::new(),
        project: "my-project".to_string(),
        compartment: String::new(),
        regions: String::new(),
        alias_prefix: "gcp".to_string(),
        user: "root".to_string(),
        identity_file: String::new(),
        verify_tls: true,
        auto_sync: true,
        vault_role: String::new(),
        vault_addr: String::new(),
        focused_field: ProviderFormField::Token,
        cursor_pos: 0,
        expanded: false,
    };
    app
}

#[test]
fn test_submit_gcp_rejects_empty_project() {
    let mut app = make_gcp_form_app();
    app.providers.form.project = "".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "Project ID");
}

#[test]
fn test_submit_gcp_rejects_whitespace_only_project() {
    let mut app = make_gcp_form_app();
    app.providers.form.project = "   ".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "Project ID");
}

#[test]
fn test_submit_gcp_rejects_empty_token() {
    let mut app = make_gcp_form_app();
    app.providers.form.token = "".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "Token");
}

#[test]
fn test_submit_gcp_empty_token_shows_gcp_specific_hint() {
    let mut app = make_gcp_form_app();
    app.providers.form.token = "".to_string();
    submit_form(&mut app);
    assert_status_contains(&app, "service account");
}

#[test]
fn test_gcp_form_has_project_field() {
    let fields = ProviderFormField::fields_for("gcp");
    assert!(fields.contains(&ProviderFormField::Project));
}

#[test]
fn test_gcp_form_tab_cycles_through_project() {
    let mut app = make_gcp_form_app();
    app.providers.form.focused_field = ProviderFormField::Token;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(app.providers.form.focused_field, ProviderFormField::Project);
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(app.providers.form.focused_field, ProviderFormField::Regions);
}

#[test]
fn test_provider_form_init_new_gcp_defaults() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    open_provider_form(&mut app, "gcp");
    assert!(app.providers.form.project.is_empty());
    assert!(app.providers.form.auto_sync);
}

// =========================================================================
// Azure-specific form validation
// =========================================================================

fn make_azure_form_app() -> App {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("azure"),
    };
    app.providers.config = test_provider_config();
    app.providers.form = ProviderFormFields {
        url: String::new(),
        token: "fake-token".to_string(),
        profile: String::new(),
        project: String::new(),
        compartment: String::new(),
        regions: "12345678-1234-1234-1234-123456789012".to_string(),
        alias_prefix: "az".to_string(),
        user: "azureuser".to_string(),
        identity_file: String::new(),
        verify_tls: true,
        auto_sync: true,
        vault_role: String::new(),
        vault_addr: String::new(),
        focused_field: ProviderFormField::Token,
        cursor_pos: 0,
        expanded: false,
    };
    app
}

#[test]
fn test_submit_azure_rejects_empty_subscriptions() {
    let mut app = make_azure_form_app();
    app.providers.form.regions = "".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "subscription");
}

#[test]
fn test_submit_azure_rejects_whitespace_only_subscriptions() {
    let mut app = make_azure_form_app();
    app.providers.form.regions = "   ".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "subscription");
}

#[test]
fn test_azure_form_has_regions_field() {
    let fields = ProviderFormField::fields_for("azure");
    assert!(fields.contains(&ProviderFormField::Regions));
    assert!(!fields.contains(&ProviderFormField::Project));
    assert!(!fields.contains(&ProviderFormField::Url));
    assert!(!fields.contains(&ProviderFormField::Profile));
}

#[test]
fn test_azure_form_tab_cycles_through_regions() {
    let mut app = make_azure_form_app();
    app.providers.form.focused_field = ProviderFormField::Token;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(app.providers.form.focused_field, ProviderFormField::Regions);
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::AliasPrefix
    );
}

#[test]
fn test_azure_regions_field_accepts_typing() {
    let mut app = make_azure_form_app();
    app.providers.form.focused_field = ProviderFormField::Regions;
    app.providers.form.regions = String::new();
    app.providers.form.cursor_pos = 0;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('a')), &tx);
    assert_eq!(app.providers.form.regions, "a");
}

fn make_ovh_form_app() -> App {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("ovh"),
    };
    app.providers.form = ProviderFormFields {
        url: String::new(),
        token: "ak:as:ck".to_string(),
        profile: String::new(),
        project: "proj-123".to_string(),
        compartment: String::new(),
        regions: String::new(),
        alias_prefix: "ovh".to_string(),
        user: "ubuntu".to_string(),
        identity_file: String::new(),
        verify_tls: true,
        auto_sync: true,
        vault_role: String::new(),
        vault_addr: String::new(),
        focused_field: ProviderFormField::Token,
        cursor_pos: 0,
        expanded: false,
    };
    app
}

#[test]
fn test_ovh_space_on_regions_opens_picker() {
    // Pickers open on Space, never on Enter. Enter always submits.
    let mut app = make_ovh_form_app();
    app.providers.form.focused_field = ProviderFormField::Regions;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(
        app.ui.region_picker.open,
        "Space on OVH Regions should open picker"
    );
    assert_eq!(app.ui.region_picker.cursor, 0);
}

#[test]
fn test_ovh_picker_select_eu() {
    let mut app = make_ovh_form_app();
    app.providers.form.focused_field = ProviderFormField::Regions;
    app.ui.region_picker.open = true;
    app.ui.region_picker.cursor = 0;

    // Cursor starts on group header "API Endpoint" (row 0).
    // Row 1 = "eu", Row 2 = "ca", Row 3 = "us"
    // Move down to "eu" (row 1)
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    assert_eq!(app.ui.region_picker.cursor, 1);

    // Press Space to select "eu"
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert_eq!(app.providers.form.regions, "eu");

    // Press Enter to confirm
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.region_picker.open);
    assert_eq!(app.providers.form.regions, "eu");
}

#[test]
fn test_ovh_picker_select_us() {
    let mut app = make_ovh_form_app();
    app.ui.region_picker.open = true;
    app.ui.region_picker.cursor = 0;
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("ovh"),
    };

    // Move to "us" (row 3: header=0, eu=1, ca=2, us=3)
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    assert_eq!(app.ui.region_picker.cursor, 3);

    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert_eq!(app.providers.form.regions, "us");

    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.region_picker.open);
    assert_eq!(app.providers.form.regions, "us");
}

#[test]
fn test_ovh_picker_space_on_header_toggles_all() {
    let mut app = make_ovh_form_app();
    app.ui.region_picker.open = true;
    app.ui.region_picker.cursor = 0; // Group header
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("ovh"),
    };

    let (tx, _rx) = mpsc::channel();
    // Space on header selects all endpoints
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    // All three should be selected (order preserved by OVH_ENDPOINTS)
    assert_eq!(app.providers.form.regions, "eu,ca,us");

    // Space again on header deselects all
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert_eq!(app.providers.form.regions, "");
}

#[test]
fn test_ovh_endpoint_picker_rows() {
    let rows = super::provider::region_picker_rows("ovh");
    assert_eq!(rows.len(), 4); // 1 header + 3 endpoints
    assert_eq!(rows[0], None); // group header
    assert_eq!(rows[1], Some("eu"));
    assert_eq!(rows[2], Some("ca"));
    assert_eq!(rows[3], Some("us"));
}

#[test]
fn test_ovh_picker_enter_selects_and_closes() {
    // OVH is single-select: Enter on an item should select it and close
    let mut app = make_ovh_form_app();
    app.ui.region_picker.open = true;
    app.ui.region_picker.cursor = 0;
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("ovh"),
    };

    let (tx, _rx) = mpsc::channel();
    // Move to "ca" (row 2)
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    assert_eq!(app.ui.region_picker.cursor, 2);

    // Enter directly (no Space needed) selects "ca" and closes
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.region_picker.open);
    assert_eq!(app.providers.form.regions, "ca");
}

#[test]
fn test_ovh_picker_enter_on_header_closes_without_select() {
    let mut app = make_ovh_form_app();
    app.ui.region_picker.open = true;
    app.ui.region_picker.cursor = 0; // group header
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("ovh"),
    };

    let (tx, _rx) = mpsc::channel();
    // Enter on header: no item to select, just closes
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.region_picker.open);
    assert_eq!(app.providers.form.regions, "");
}

#[test]
fn test_ovh_picker_enter_replaces_previous_selection() {
    let mut app = make_ovh_form_app();
    app.providers.form.regions = "eu".to_string(); // previously selected EU
    app.ui.region_picker.open = true;
    app.ui.region_picker.cursor = 3; // "us"
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("ovh"),
    };

    let (tx, _rx) = mpsc::channel();
    // Enter on "us" should replace "eu" with "us" (single-select)
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(app.providers.form.regions, "us");
}

#[test]
fn test_azure_enter_on_regions_does_not_open_picker() {
    let mut app = make_azure_form_app();
    app.providers.form.focused_field = ProviderFormField::Regions;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    // Must NOT open region picker (Azure uses text input, not picker)
    assert!(!app.ui.region_picker.open);
    // Screen should no longer be ProviderForm (submit transitions away)
    // or validation error sets status (screen stays on form)
    // Either way: not a picker.
}

#[test]
fn test_submit_azure_rejects_invalid_subscription_id() {
    let mut app = make_azure_form_app();
    app.providers.form.regions = "not-a-uuid".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "Invalid subscription ID");
}

#[test]
fn test_submit_azure_rejects_mixed_valid_invalid_subscriptions() {
    let mut app = make_azure_form_app();
    app.providers.form.regions = "12345678-1234-1234-1234-123456789012,bad-id".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert_status_contains(&app, "Invalid subscription ID");
}

// =========================================================================
// Provider form navigation tests
// =========================================================================

#[test]
fn test_provider_form_tab_cycles_cloud_fields() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::AliasPrefix
    );
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(app.providers.form.focused_field, ProviderFormField::User);
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::IdentityFile
    );
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::VaultRole
    );
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::AutoSync
    );
}

#[test]
fn test_provider_form_shift_tab_reverse() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::AutoSync);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::BackTab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::VaultRole
    );
}

#[test]
fn test_provider_form_proxmox_has_extra_fields() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::Url);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(app.providers.form.focused_field, ProviderFormField::Token);
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::AliasPrefix
    );
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(app.providers.form.focused_field, ProviderFormField::User);
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::IdentityFile
    );
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::VerifyTls
    );
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::VaultRole
    );
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.providers.form.focused_field,
        ProviderFormField::AutoSync
    );
}

#[test]
fn test_provider_form_esc_returns_to_provider_list() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::Providers));
}

#[test]
fn test_provider_form_space_toggles_verify_tls() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::VerifyTls);
    assert!(app.providers.form.verify_tls);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(!app.providers.form.verify_tls);
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(app.providers.form.verify_tls);
}

#[test]
fn test_provider_form_char_input_verify_tls_blocked() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::VerifyTls);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('x')), &tx);
    // No text field should have changed
    assert_eq!(app.providers.form.token, "tok");
}

#[test]
fn test_provider_form_backspace_verify_tls_blocked() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::VerifyTls);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert_eq!(app.providers.form.token, "tok");
}

#[test]
fn test_provider_form_space_opens_key_picker() {
    // Pickers open on Space, never on Enter.
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::IdentityFile);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(app.ui.key_picker.open);
}

#[test]
fn test_provider_form_char_appended_to_focused_field() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.token = "tok".to_string();
    app.providers.form.cursor_pos = 3;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('X')), &tx);
    assert_eq!(app.providers.form.token, "tokX");
}

#[test]
fn test_provider_form_backspace_removes_from_focused_field() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.token = "tok".to_string();
    app.providers.form.cursor_pos = 3;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert_eq!(app.providers.form.token, "to");
}

// =========================================================================
// Provider list interaction tests
// =========================================================================

#[test]
fn test_provider_list_esc_returns_to_host_list() {
    let mut app = make_providers_app_with_do();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn test_provider_list_q_returns_to_host_list() {
    let mut app = make_providers_app_with_do();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('q')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn test_provider_list_j_selects_next() {
    let mut app = make_providers_app_with_do();
    app.ui.provider_list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    // Should advance (wrapping depends on count)
    assert!(app.ui.provider_list_state.selected().is_some());
}

#[test]
fn test_provider_list_k_selects_prev() {
    let mut app = make_providers_app_with_do();
    app.ui.provider_list_state.select(Some(1));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('k')), &tx);
    assert!(app.ui.provider_list_state.selected().is_some());
}

#[test]
fn test_provider_list_sync_unconfigured_shows_status() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    // No config for digitalocean - select it and press s
    let sorted = app.sorted_provider_names();
    let idx = sorted.iter().position(|n| n == "digitalocean").unwrap();
    app.ui.provider_list_state.select(Some(idx));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('s')), &tx);
    assert!(
        app.status_center
            .toast
            .as_ref()
            .unwrap()
            .text
            .contains("Configure")
    );
}

#[test]
fn test_provider_list_delete_removes_config() {
    let mut app = make_providers_app_with_do();
    let sorted = app.sorted_provider_names();
    let idx = sorted.iter().position(|n| n == "digitalocean").unwrap();
    app.ui.provider_list_state.select(Some(idx));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    // d now triggers confirmation
    assert!(app.providers.pending_delete.is_some());
    // Confirm with y
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert!(app.providers.pending_delete.is_none());
    // Save may fail in tests (no ~/.purple), triggering rollback. Just verify handler ran.
    assert!(app.status_center.status.is_some() || app.status_center.toast.is_some());
}

#[test]
fn test_provider_list_delete_unconfigured_is_noop() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    let sorted = app.sorted_provider_names();
    let idx = sorted.iter().position(|n| n == "digitalocean").unwrap();
    app.ui.provider_list_state.select(Some(idx));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    // No status/toast message because no section existed to delete
    let has_removed = app
        .status_center
        .toast
        .as_ref()
        .is_some_and(|t| t.text.contains("Removed"))
        || app
            .status_center
            .status
            .as_ref()
            .is_some_and(|s| s.text.contains("Removed"));
    assert!(!has_removed);
}

#[test]
fn test_provider_list_esc_cancels_running_syncs() {
    let mut app = make_providers_app_with_do();
    let cancel = Arc::new(AtomicBool::new(false));
    app.providers
        .syncing
        .insert("digitalocean".to_string(), cancel.clone());
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(
        cancel.load(Ordering::Relaxed),
        "Cancel flag should be set on Esc"
    );
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn test_provider_list_enter_opens_form_with_existing_config() {
    let mut app = make_providers_app_with_do();
    open_provider_form(&mut app, "digitalocean");
    assert!(matches!(app.screen, Screen::ProviderForm { ref id } if id.provider == "digitalocean"));
    assert_eq!(app.providers.form.token, "tok");
    assert_eq!(app.providers.form.alias_prefix, "do");
    assert_eq!(app.providers.form.user, "root");
}

#[test]
fn test_provider_list_enter_opens_form_with_defaults() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    open_provider_form(&mut app, "vultr");
    assert!(matches!(app.screen, Screen::ProviderForm { ref id } if id.provider == "vultr"));
    assert_eq!(app.providers.form.token, "");
    assert_eq!(app.providers.form.user, "root");
    assert!(app.providers.form.auto_sync); // vultr default true
}

#[test]
fn test_provider_form_proxmox_default_alias_prefix() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    open_provider_form(&mut app, "proxmox");
    // Proxmox short_label is "pve"
    assert_eq!(app.providers.form.alias_prefix, "pve");
}

// =========================================================================
// Provider form all-providers init defaults
// =========================================================================

#[test]
fn test_all_cloud_providers_default_auto_sync_true() {
    for provider in &[
        "digitalocean",
        "vultr",
        "linode",
        "hetzner",
        "upcloud",
        "aws",
        "scaleway",
        "gcp",
        "azure",
        "tailscale",
    ] {
        let mut app = make_app("Host test\n  HostName test.com\n");
        app.screen = Screen::Providers;
        app.providers.config = test_provider_config();
        open_provider_form(&mut app, provider);
        assert!(
            app.providers.form.auto_sync,
            "{} should default auto_sync=true",
            provider
        );
    }
}

#[test]
fn test_proxmox_defaults_auto_sync_false() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    open_provider_form(&mut app, "proxmox");
    assert!(!app.providers.form.auto_sync);
}

#[test]
fn test_submit_proxmox_https_case_insensitive() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::Url);
    app.providers.form.url = "HTTPS://pve.local:8006".to_string();
    app.providers.form.token = "user@pam!t=secret".to_string();
    submit_form(&mut app);
    assert_status_not_contains(&app, "https://");
}

#[test]
fn test_submit_non_proxmox_url_not_required() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.url = "".to_string();
    submit_form(&mut app);
    assert_status_not_contains(&app, "URL is required");
}

#[test]
fn test_submit_provider_form_accepts_empty_alias_prefix() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.alias_prefix = "".to_string();
    submit_form(&mut app);
    assert_status_not_contains(&app, "pattern");
}

#[test]
fn test_submit_provider_form_accepts_hyphenated_alias() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.alias_prefix = "my-cloud".to_string();
    submit_form(&mut app);
    assert_status_not_contains(&app, "pattern");
}

#[test]
fn test_submit_provider_form_rejects_space_in_alias_prefix() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.providers.form.alias_prefix = "my cloud".to_string();
    submit_form(&mut app);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    let msg = &app
        .status_center
        .status
        .as_ref()
        .or(app.status_center.toast.as_ref())
        .unwrap()
        .text;
    if !msg.contains("changed externally") {
        assert!(msg.contains("pattern") || msg.contains("spaces"));
    }
}

// =========================================================================
// Password picker tests
// =========================================================================

fn ctrl_key(c: char) -> KeyEvent {
    KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}

fn make_form_app() -> App {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::AddHost;
    app.forms.host = crate::app::HostForm::new();
    app.forms.host.expanded = true; // Tests assume all fields visible
    app
}

// --- Enter on AskPass opens picker ---

#[test]
fn test_space_on_askpass_opens_password_picker() {
    // Pickers open on Space, never on Enter.
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(app.ui.password_picker.open);
    assert_eq!(app.ui.password_picker.list.selected(), Some(0));
}

// --- Esc closes picker ---

#[test]
fn test_password_picker_esc_closes() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(2);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(!app.ui.password_picker.open);
    // Form field should be unchanged
    assert_eq!(app.forms.host.askpass, "");
}

// --- Navigation j/k ---

#[test]
fn test_password_picker_j_moves_down() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    assert_eq!(app.ui.password_picker.list.selected(), Some(1));
}

#[test]
fn test_password_picker_k_moves_up() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(2);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('k')), &tx);
    assert_eq!(app.ui.password_picker.list.selected(), Some(1));
}

#[test]
fn test_password_picker_down_arrow() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Down), &tx);
    assert_eq!(app.ui.password_picker.list.selected(), Some(1));
}

#[test]
fn test_password_picker_up_arrow() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(3);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Up), &tx);
    assert_eq!(app.ui.password_picker.list.selected(), Some(2));
}

#[test]
fn test_password_picker_wraps_around_bottom() {
    let mut app = make_form_app();
    let last = crate::askpass::PASSWORD_SOURCES.len() - 1;
    app.ui.password_picker.open_at(last);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    assert_eq!(app.ui.password_picker.list.selected(), Some(0));
}

#[test]
fn test_password_picker_wraps_around_top() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('k')), &tx);
    let last = crate::askpass::PASSWORD_SOURCES.len() - 1;
    assert_eq!(app.ui.password_picker.list.selected(), Some(last));
}

// --- Enter selects source: OS Keychain ---

#[test]
fn test_password_picker_select_keychain() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(0); // OS Keychain
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.password_picker.open);
    assert_eq!(app.forms.host.askpass, "keychain");
}

// --- Enter selects source: 1Password (prefix) ---

#[test]
fn test_password_picker_select_1password() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(1); // 1Password
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.password_picker.open);
    assert_eq!(app.forms.host.askpass, "op://");
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
}

// --- Enter selects source: Bitwarden (prefix) ---

#[test]
fn test_password_picker_select_bitwarden() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(2); // Bitwarden
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.password_picker.open);
    assert_eq!(app.forms.host.askpass, "bw:");
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
}

// --- Enter selects source: pass (prefix) ---

#[test]
fn test_password_picker_select_pass() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(3); // pass
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.password_picker.open);
    assert_eq!(app.forms.host.askpass, "pass:");
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
}

// --- Enter selects source: HashiCorp Vault (prefix) ---

#[test]
fn test_password_picker_select_vault() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(4); // HashiCorp Vault
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.password_picker.open);
    assert_eq!(app.forms.host.askpass, "vault:");
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
}

// --- Enter selects source: Custom command ---

#[test]
fn test_password_picker_select_custom() {
    let mut app = make_form_app();
    app.forms.host.askpass = "old-value".to_string();
    app.ui.password_picker.open_at(5); // Custom command
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.password_picker.open);
    assert_eq!(app.forms.host.askpass, "");
}

// --- Enter selects source: None (clears) ---

#[test]
fn test_password_picker_select_none() {
    let mut app = make_form_app();
    app.forms.host.askpass = "keychain".to_string();
    app.ui.password_picker.open_at(6); // None
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.password_picker.open);
    assert_eq!(app.forms.host.askpass, "");
}

// --- Picker blocks other form input ---

#[test]
fn test_password_picker_blocks_char_input() {
    let mut app = make_form_app();
    app.forms.host.askpass = "".to_string();
    app.ui.password_picker.open_at(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('x')), &tx);
    // 'x' should not be appended to any form field
    assert_eq!(app.forms.host.askpass, "");
    assert_eq!(app.forms.host.alias, "");
}

#[test]
fn test_password_picker_blocks_tab() {
    let mut app = make_form_app();
    let original_field = app.forms.host.focused_field;
    app.ui.password_picker.open_at(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    // Tab should not change focused field
    assert_eq!(app.forms.host.focused_field, original_field);
}

// --- Picker on EditHost screen ---

#[test]
fn test_password_picker_works_on_edit_host() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::EditHost {
        alias: "test".to_string(),
    };
    app.forms.host = crate::app::HostForm::new();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    // Space on empty picker field opens the picker.
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(app.ui.password_picker.open);
    // Inside the picker, Enter selects the highlighted entry (keychain).
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(app.forms.host.askpass, "keychain");
}

// --- Picker priority over key picker ---

#[test]
fn test_password_picker_takes_priority_over_key_picker() {
    let mut app = make_form_app();
    app.ui.key_picker.open = true;
    app.ui.password_picker.open_at(0);
    let (tx, _rx) = mpsc::channel();
    // Esc should close password picker, not key picker
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(!app.ui.password_picker.open);
    assert!(app.ui.key_picker.open); // still open
}

// =========================================================================
// Host list Enter carries askpass in pending_connect
// =========================================================================

#[test]
fn test_host_list_enter_carries_askpass() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
    app.screen = Screen::HostList;
    // Select the host
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    let pending = app.pending_connect.as_ref().unwrap();
    assert_eq!(pending.0, "myserver");
    assert_eq!(pending.1, Some("keychain".to_string()));
}

#[test]
fn test_host_list_enter_carries_vault_askpass() {
    let mut app =
        make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:secret/ssh#pass\n");
    app.screen = Screen::HostList;
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    let pending = app.pending_connect.as_ref().unwrap();
    assert_eq!(pending.1, Some("vault:secret/ssh#pass".to_string()));
}

#[test]
fn test_host_list_enter_no_askpass() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n");
    app.screen = Screen::HostList;
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    let pending = app.pending_connect.as_ref().unwrap();
    assert_eq!(pending.0, "myserver");
    assert_eq!(pending.1, None);
}

// =========================================================================
// Search mode Enter carries askpass in pending_connect
// =========================================================================

#[test]
fn test_search_enter_carries_askpass() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass op://V/I/p\n");
    app.screen = Screen::HostList;
    app.start_search();
    // In search mode, filtered_indices should contain our host
    assert!(!app.search.filtered_indices.is_empty());
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    let pending = app.pending_connect.as_ref().unwrap();
    assert_eq!(pending.0, "myserver");
    assert_eq!(pending.1, Some("op://V/I/p".to_string()));
    // Search should be cancelled after Enter
    assert!(app.search.query.is_none());
}

#[test]
fn test_search_enter_no_askpass() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n");
    app.screen = Screen::HostList;
    app.start_search();
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    let pending = app.pending_connect.as_ref().unwrap();
    assert_eq!(pending.1, None);
}

// =========================================================================
// Ctrl+E edits selected host during search
// =========================================================================

#[test]
fn test_search_ctrl_e_opens_edit_form() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n");
    app.screen = Screen::HostList;
    app.start_search();
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, ctrl_key('e'), &tx);
    assert!(matches!(app.screen, Screen::EditHost { ref alias } if alias == "myserver"));
    // Search query should be preserved so user returns to filtered list
    assert!(app.search.query.is_some());
}

#[test]
fn test_search_ctrl_e_blocks_included_host() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n");
    // Simulate an included host by setting source_file
    if let Some(host) = app.hosts_state.list.first_mut() {
        host.source_file = Some(std::path::PathBuf::from("/etc/ssh/config.d/test"));
    }
    app.screen = Screen::HostList;
    app.start_search();
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, ctrl_key('e'), &tx);
    // Should remain in search mode (not open edit form)
    assert!(matches!(app.screen, Screen::HostList));
    assert!(app.status_center.status.is_some() || app.status_center.toast.is_some());
}

// =========================================================================
// Tunnel start reads askpass from host
// =========================================================================

#[test]
fn test_tunnel_handler_reads_askpass_from_hosts() {
    // Verify the askpass lookup logic: find host by alias and extract askpass
    let app = make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:my-item\n");
    let askpass = app
        .hosts_state
        .list
        .iter()
        .find(|h| h.alias == "myserver")
        .and_then(|h| h.askpass.clone());
    assert_eq!(askpass, Some("bw:my-item".to_string()));
}

#[test]
fn test_tunnel_handler_askpass_none_when_absent() {
    let app = make_app("Host myserver\n  HostName 10.0.0.1\n");
    let askpass = app
        .hosts_state
        .list
        .iter()
        .find(|h| h.alias == "myserver")
        .and_then(|h| h.askpass.clone());
    assert_eq!(askpass, None);
}

// =========================================================================
// Edit host form populates askpass
// =========================================================================

#[test]
fn test_edit_host_populates_askpass_in_form() {
    let mut app =
        make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass pass:ssh/prod\n");
    app.screen = Screen::HostList;
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    // Press 'e' to edit
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    if matches!(app.screen, Screen::EditHost { .. }) {
        assert_eq!(app.forms.host.askpass, "pass:ssh/prod");
    }
}

#[test]
fn test_edit_host_populates_empty_askpass() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n");
    app.screen = Screen::HostList;
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    if matches!(app.screen, Screen::EditHost { .. }) {
        assert_eq!(app.forms.host.askpass, "");
    }
}

// =========================================================================
// Tab navigation through AskPass field
// =========================================================================

#[test]
fn test_tab_reaches_askpass_field() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::ProxyJump;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
}

#[test]
fn test_tab_from_askpass_goes_to_tags() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(app.forms.host.focused_field, FormField::Tags);
}

#[test]
fn test_shift_tab_from_tags_goes_to_askpass() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::Tags;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::BackTab), &tx);
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
}

#[test]
fn test_typing_in_askpass_field() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('k')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert_eq!(app.forms.host.askpass, "key");
}

#[test]
fn test_backspace_in_askpass_field() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    app.forms.host.askpass = "vault:".to_string();
    app.forms.host.cursor_pos = 6;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert_eq!(app.forms.host.askpass, "vault");
}

// =========================================================================
// Picker then type: prefix selection followed by typing
// =========================================================================

#[test]
fn test_picker_select_op_then_type_rest() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    // Space on empty picker field opens the picker.
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    // Navigate to 1Password (index 1)
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    // Inside the picker, Enter selects.
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(app.forms.host.askpass, "op://");
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
    // Now type the rest of the URI
    let _ = handle_key_event(&mut app, key(KeyCode::Char('V')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('/')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('I')), &tx);
    assert_eq!(app.forms.host.askpass, "op://V/I");
}

#[test]
fn test_picker_select_vault_then_type_rest() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    // Space on empty picker field opens the picker.
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    // Navigate to Vault (index 4)
    for _ in 0..4 {
        let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    }
    assert_eq!(app.ui.password_picker.list.selected(), Some(4));
    // Inside the picker, Enter selects.
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(app.forms.host.askpass, "vault:");
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
    // Type the path
    for c in "secret/ssh#pass".chars() {
        let _ = handle_key_event(&mut app, key(KeyCode::Char(c)), &tx);
    }
    assert_eq!(app.forms.host.askpass, "vault:secret/ssh#pass");
}

#[test]
fn test_picker_select_keychain_no_further_typing_needed() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    // Space on empty picker field opens the picker.
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    // Inside the picker, Enter selects keychain (index 0, already selected).
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(app.forms.host.askpass, "keychain");
    // focused_field stays on AskPass (picker was opened from AskPass)
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
}

// =========================================================================
// Password picker: status messages after selection
// =========================================================================

#[test]
fn test_picker_keychain_sets_status_message() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(
        app.status_center
            .toast
            .as_ref()
            .unwrap()
            .text
            .contains("OS Keychain")
    );
}

#[test]
fn test_picker_none_sets_cleared_status() {
    let mut app = make_form_app();
    app.forms.host.askpass = "keychain".to_string();
    app.ui.password_picker.open_at(6); // None
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(
        app.status_center
            .toast
            .as_ref()
            .unwrap()
            .text
            .contains("cleared")
    );
}

#[test]
fn test_picker_prefix_source_shows_guidance() {
    // Prefix sources (op://, bw:, etc.) show a guidance message
    let mut app = make_form_app();
    app.status_center.toast = None;
    app.ui.password_picker.open_at(1); // 1Password (op://)
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(
        app.status_center
            .toast
            .as_ref()
            .unwrap()
            .text
            .contains("Complete")
    );
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
}

// =========================================================================
// Backspace after prefix selection
// =========================================================================

#[test]
fn test_backspace_after_prefix_selection() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    // Space opens the picker; Enter selects 1Password (after pre-positioning).
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    app.ui.password_picker.list.select(Some(1));
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(app.forms.host.askpass, "op://");
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
    // Type something
    let _ = handle_key_event(&mut app, key(KeyCode::Char('V')), &tx);
    assert_eq!(app.forms.host.askpass, "op://V");
    // Backspace removes last char
    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert_eq!(app.forms.host.askpass, "op://");
    // Another backspace removes the trailing /
    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert_eq!(app.forms.host.askpass, "op:/");
}

// =========================================================================
// Edit form populates askpass from existing host
// =========================================================================

#[test]
fn test_edit_form_populates_askpass() {
    let mut app =
        make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:secret/ssh#pw\n");
    // Simulate what happens when user presses 'e' on a host
    let entry = app.hosts_state.ssh_config.host_entries()[0].clone();
    app.forms.host = crate::app::HostForm::from_entry(&entry, Default::default());
    assert_eq!(app.forms.host.askpass, "vault:secret/ssh#pw");
}

#[test]
fn test_edit_form_empty_askpass_when_none() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n");
    let entry = app.hosts_state.ssh_config.host_entries()[0].clone();
    app.forms.host = crate::app::HostForm::from_entry(&entry, Default::default());
    assert_eq!(app.forms.host.askpass, "");
}

// =========================================================================
// Password picker: unknown keys are no-ops
// =========================================================================

#[test]
fn test_password_picker_ignores_unknown_keys() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(2);
    let (tx, _rx) = mpsc::channel();
    // F1 key should be a no-op
    let _ = handle_key_event(&mut app, key(KeyCode::F(1)), &tx);
    assert!(app.ui.password_picker.open);
    assert_eq!(app.ui.password_picker.list.selected(), Some(2));
}

// =========================================================================
// Host list search Enter carries askpass
// =========================================================================

#[test]
fn test_search_enter_carries_askpass_op_uri() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass op://V/I/p\n");
    app.search.query = Some("myserver".to_string());
    app.apply_filter();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    if let Some((alias, askpass)) = &app.pending_connect {
        assert_eq!(alias, "myserver");
        assert_eq!(askpass.as_deref(), Some("op://V/I/p"));
    } else {
        panic!("Expected pending_connect to be set");
    }
}

// =========================================================================
// UI/UX: placeholder text and picker overlay properties
// =========================================================================

#[test]
fn test_askpass_placeholder_text() {
    let placeholder = crate::ui::host_form::placeholder_text(FormField::AskPass);
    // When no global default is set, shows the "Space to pick..." guidance.
    // When a default exists, shows "default: <name>". Per the keyboard
    // invariants, pickers open on Space, never Enter.
    assert!(
        placeholder.contains("Space") || placeholder.contains("default:"),
        "Should show Space guidance or default prefix: {}",
        placeholder
    );
}

#[test]
fn test_password_sources_fit_picker_width() {
    // Picker overlay is 48 chars wide (minus 4 for borders/padding)
    let max_content_width = 44;
    for source in crate::askpass::PASSWORD_SOURCES {
        let total = source.label.len() + 1 + source.hint.len();
        assert!(
            total <= max_content_width,
            "Source '{}' (label={}, hint={}) total {} exceeds max {}",
            source.label,
            source.label.len(),
            source.hint.len(),
            total,
            max_content_width
        );
    }
}

#[test]
fn test_password_picker_item_count_matches_sources() {
    assert_eq!(crate::askpass::PASSWORD_SOURCES.len(), 7);
}

// =========================================================================
// Full picker → type → form submit flow
// =========================================================================

#[test]
fn test_full_flow_picker_to_typed_value() {
    let mut app = make_form_app();
    app.forms.host.alias = "myhost".to_string();
    app.forms.host.hostname = "10.0.0.1".to_string();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();

    // Space opens picker; pre-position to Bitwarden (index 2); Enter selects.
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    app.ui.password_picker.list.select(Some(2));
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);

    // Verify field has prefix
    assert_eq!(app.forms.host.askpass, "bw:");
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);

    // Type the item name
    for c in "my-ssh-server".chars() {
        let _ = handle_key_event(&mut app, key(KeyCode::Char(c)), &tx);
    }
    assert_eq!(app.forms.host.askpass, "bw:my-ssh-server");

    // Verify to_entry produces correct askpass
    let entry = app.forms.host.to_entry();
    assert_eq!(entry.askpass, Some("bw:my-ssh-server".to_string()));
}

#[test]
fn test_full_flow_picker_keychain_then_tab_away() {
    let mut app = make_form_app();
    // Only set alias (not hostname) so auto-submit doesn't trigger after picker
    app.forms.host.alias = "myhost".to_string();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();

    // Space opens picker; Enter selects keychain (index 0, default).
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);

    assert_eq!(app.forms.host.askpass, "keychain");
    // Focus stays on AskPass (picker opened from AskPass)
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);

    // Tab to next field (Tags is after AskPass)
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(app.forms.host.focused_field, FormField::Tags);
}

#[test]
fn test_full_flow_clear_askpass_via_picker_none() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    app.forms.host.askpass = "op://Vault/Item/pw".to_string();
    let (tx, _rx) = mpsc::channel();

    // Field has content → Space inserts literal. To re-open the picker,
    // pre-set the show_password_picker state directly (mirrors the user
    // backspacing the field clean and pressing Space, but skips the steps
    // since we are testing the post-picker behavior).
    app.ui.password_picker.open = true;
    app.ui.password_picker.list = ratatui::widgets::ListState::default();
    app.ui.password_picker.list.select(Some(0));
    for _ in 0..6 {
        let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    }
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);

    assert_eq!(app.forms.host.askpass, "");
    let entry = app.forms.host.to_entry();
    assert_eq!(entry.askpass, None);
}

// =========================================================================
// Askpass with host without askpass (no askpass in pending_connect)
// =========================================================================

#[test]
fn test_host_list_enter_no_askpass_is_none() {
    let mut app = make_app("Host plain\n  HostName 10.0.0.1\n");
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    if let Some((alias, askpass)) = &app.pending_connect {
        assert_eq!(alias, "plain");
        assert!(askpass.is_none());
    } else {
        panic!("Expected pending_connect");
    }
}

// =========================================================================
// Ctrl+P does NOT open password picker on provider form
// =========================================================================

#[test]
fn test_ctrl_p_on_provider_form_does_not_open_password_picker() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
    };
    app.providers.form = crate::app::ProviderFormFields::new();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, ctrl_key('p'), &tx);
    // Provider form does not have a password picker
    assert!(!app.ui.password_picker.open);
}

// =========================================================================
// Multiple hosts: each carries its own askpass in pending_connect
// =========================================================================

#[test]
fn test_multiple_hosts_different_askpass_sources() {
    let config = "\
Host alpha
  HostName a.com
  # purple:askpass keychain

Host beta
  HostName b.com
  # purple:askpass op://Vault/SSH/pw

Host gamma
  HostName c.com
";
    let app = make_app(config);
    assert_eq!(app.hosts_state.list.len(), 3);
    assert_eq!(
        app.hosts_state.list[0].askpass,
        Some("keychain".to_string())
    );
    assert_eq!(
        app.hosts_state.list[1].askpass,
        Some("op://Vault/SSH/pw".to_string())
    );
    assert_eq!(app.hosts_state.list[2].askpass, None);
}

#[test]
fn test_select_different_hosts_carries_correct_askpass() {
    let config = "\
Host alpha
  HostName a.com
  # purple:askpass keychain

Host beta
  HostName b.com
  # purple:askpass bw:my-item
";
    let mut app = make_app(config);
    let (tx, _rx) = mpsc::channel();

    // Select alpha (first host) and press Enter
    app.ui.list_state.select(Some(0));
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    let (alias, askpass) = app.pending_connect.take().unwrap();
    assert_eq!(alias, "alpha");
    assert_eq!(askpass, Some("keychain".to_string()));

    // Select beta (second host) and press Enter
    app.ui.list_state.select(Some(1));
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    let (alias, askpass) = app.pending_connect.take().unwrap();
    assert_eq!(alias, "beta");
    assert_eq!(askpass, Some("bw:my-item".to_string()));
}

// =========================================================================
// Askpass field typing: direct input without picker
// =========================================================================

#[test]
fn test_type_askpass_directly_without_picker() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    for c in "keychain".chars() {
        let _ = handle_key_event(&mut app, key(KeyCode::Char(c)), &tx);
    }
    assert_eq!(app.forms.host.askpass, "keychain");
}

#[test]
fn test_type_custom_command_directly() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    for c in "my-script %a %h".chars() {
        let _ = handle_key_event(&mut app, key(KeyCode::Char(c)), &tx);
    }
    assert_eq!(app.forms.host.askpass, "my-script %a %h");
}

#[test]
fn test_clear_askpass_with_backspace() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    app.forms.host.askpass = "keychain".to_string();
    app.forms.host.cursor_pos = 8;
    let (tx, _rx) = mpsc::channel();
    for _ in 0..8 {
        let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    }
    assert_eq!(app.forms.host.askpass, "");
}

// =========================================================================
// Delete host with askpass: undo restores it
// =========================================================================

#[test]
fn test_delete_undo_preserves_askpass_in_config() {
    let config_str = "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:secret/ssh#pw\n";
    let mut app = make_app(config_str);
    // Verify askpass is present
    assert_eq!(
        app.hosts_state.ssh_config.host_entries()[0].askpass,
        Some("vault:secret/ssh#pw".to_string())
    );

    // Delete the host (undoable)
    if let Some((element, position)) = app.hosts_state.ssh_config.delete_host_undoable("myserver") {
        // Host is gone
        assert!(app.hosts_state.ssh_config.host_entries().is_empty());
        // Undo: restore
        app.hosts_state.ssh_config.insert_host_at(element, position);
        // Askpass should be restored
        let entries = app.hosts_state.ssh_config.host_entries();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].askpass, Some("vault:secret/ssh#pw".to_string()));
    } else {
        panic!("Expected delete_host_undoable to succeed");
    }
}

// =========================================================================
// Askpass with unicode characters
// =========================================================================

#[test]
fn test_askpass_unicode_in_custom_command() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    for c in "get-p\u{00E4}ss %h".chars() {
        let _ = handle_key_event(&mut app, key(KeyCode::Char(c)), &tx);
    }
    assert_eq!(app.forms.host.askpass, "get-p\u{00E4}ss %h");
}

// =========================================================================
// Enter on AskPass field opens picker
// =========================================================================

#[test]
fn test_space_on_empty_askpass_field_opens_picker() {
    // Space opens the picker on empty picker fields. On non-empty fields
    // it inserts a literal space
    // (so custom commands like `my-script %h` keep working).
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    // Field is empty (default after make_form_app).
    assert!(app.forms.host.askpass.is_empty());
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(app.ui.password_picker.open);
}

#[test]
fn test_space_on_populated_askpass_field_inserts_literal() {
    // Empty-field gate: once the user has typed anything, Space inserts a
    // literal space (so multi-word custom commands work).
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    app.forms.host.askpass = "my-script".to_string();
    app.forms.host.cursor_pos = 9;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(
        !app.ui.password_picker.open,
        "Space on a populated picker field must NOT open the picker"
    );
    assert_eq!(app.forms.host.askpass, "my-script ");
}

#[test]
fn test_picker_open_on_empty_then_enter_selects_keychain() {
    // Space on empty picker field opens the picker; inside the picker,
    // Enter is the canonical "select" key.
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    assert!(app.forms.host.askpass.is_empty());
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(app.ui.password_picker.open);
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(app.forms.host.askpass, "keychain");
    assert!(!app.ui.password_picker.open);
}

// =========================================================================
// --connect mode askpass lookup logic (replicated)
// =========================================================================

#[test]
fn test_connect_mode_askpass_lookup() {
    let app = make_app("Host srv\n  HostName 1.2.3.4\n  # purple:askpass pass:ssh/srv\n");
    // Simulate --connect lookup logic from main.rs
    let alias = "srv";
    let askpass = app
        .hosts_state
        .ssh_config
        .host_entries()
        .iter()
        .find(|h| h.alias == alias)
        .and_then(|h| h.askpass.clone());
    assert_eq!(askpass, Some("pass:ssh/srv".to_string()));
}

#[test]
fn test_connect_mode_askpass_none() {
    let app = make_app("Host srv\n  HostName 1.2.3.4\n");
    let alias = "srv";
    let askpass = app
        .hosts_state
        .ssh_config
        .host_entries()
        .iter()
        .find(|h| h.alias == alias)
        .and_then(|h| h.askpass.clone());
    assert_eq!(askpass, None);
}

#[test]
fn test_connect_mode_nonexistent_host() {
    let app = make_app("Host srv\n  HostName 1.2.3.4\n");
    let alias = "nonexistent";
    let askpass = app
        .hosts_state
        .ssh_config
        .host_entries()
        .iter()
        .find(|h| h.alias == alias)
        .and_then(|h| h.askpass.clone());
    assert_eq!(askpass, None);
}

// =========================================================================
// 'e' key opens edit form with correct askpass from host list
// =========================================================================

#[test]
fn test_e_key_opens_edit_form_with_askpass() {
    let mut app =
        make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass op://Vault/SSH/pw\n");
    let (tx, _rx) = mpsc::channel();
    // Press 'e' to edit the selected host
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    assert!(matches!(app.screen, Screen::EditHost { .. }));
    assert_eq!(app.forms.host.askpass, "op://Vault/SSH/pw");
    assert_eq!(app.forms.host.hostname, "10.0.0.1");
}

#[test]
fn test_e_key_opens_edit_form_without_askpass() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n");
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    assert!(matches!(app.screen, Screen::EditHost { .. }));
    assert_eq!(app.forms.host.askpass, "");
}

// =========================================================================
// Picker then Esc preserves existing askpass value
// =========================================================================

#[test]
fn test_picker_esc_preserves_existing_askpass() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    app.forms.host.askpass = "vault:secret/ssh#pw".to_string();
    let (tx, _rx) = mpsc::channel();
    // Field has content → user must clear it to reach the picker. Simulate
    // by setting the picker open directly (the unit under test is the Esc
    // behavior, not the open path).
    app.ui.password_picker.open = true;
    app.ui.password_picker.list = ratatui::widgets::ListState::default();
    app.ui.password_picker.list.select(Some(0));
    assert!(app.ui.password_picker.open);
    // Navigate but then Esc
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    // Original value preserved
    assert_eq!(app.forms.host.askpass, "vault:secret/ssh#pw");
}

// =========================================================================
// Extra backspace past empty is no-op
// =========================================================================

#[test]
fn test_backspace_on_empty_askpass_is_noop() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    app.forms.host.askpass = "".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert_eq!(app.forms.host.askpass, "");
}

// =========================================================================
// Tab from AskPass goes to Tags, shift-tab goes to ProxyJump
// =========================================================================

#[test]
fn test_tab_from_askpass_to_tags() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(app.forms.host.focused_field, FormField::Tags);
}

#[test]
fn test_shift_tab_from_askpass_to_proxyjump() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::AskPass;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(
        &mut app,
        KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
        &tx,
    );
    assert_eq!(app.forms.host.focused_field, FormField::ProxyJump);
}

// =========================================================================
// Tunnel start for host with askpass passes it through
// =========================================================================

#[test]
fn test_tunnel_askpass_lookup_different_sources() {
    let config = "\
Host alpha
  HostName a.com
  # purple:askpass keychain

Host beta
  HostName b.com
  # purple:askpass bw:item

Host gamma
  HostName c.com
";
    let app = make_app(config);
    let lookup = |alias: &str| -> Option<String> {
        app.hosts_state
            .list
            .iter()
            .find(|h| h.alias == alias)
            .and_then(|h| h.askpass.clone())
    };
    assert_eq!(lookup("alpha"), Some("keychain".to_string()));
    assert_eq!(lookup("beta"), Some("bw:item".to_string()));
    assert_eq!(lookup("gamma"), None);
}

// =========================================================================
// Password picker status message tests
// =========================================================================

#[test]
fn test_password_picker_keychain_sets_status_message() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(0); // Keychain
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    let toast = app.status_center.toast.as_ref().unwrap();
    assert!(
        toast.text.contains("OS Keychain"),
        "Toast should mention OS Keychain, got: {}",
        toast.text
    );
}

#[test]
fn test_password_picker_none_sets_cleared_status() {
    let mut app = make_form_app();
    app.forms.host.askpass = "keychain".to_string();
    app.ui.password_picker.open_at(6); // None
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    let toast = app.status_center.toast.as_ref().unwrap();
    assert!(
        toast.text.contains("cleared"),
        "Toast should say cleared, got: {}",
        toast.text
    );
}

#[test]
fn test_password_picker_prefix_source_focuses_askpass_field() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(1); // 1Password (op://)
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(
        app.forms.host.focused_field,
        FormField::AskPass,
        "Prefix source should focus AskPass field"
    );
    // No status message for prefix sources (user needs to keep typing)
    assert!(
        app.status_center.status.is_none()
            || !app
                .status_center
                .status
                .as_ref()
                .unwrap()
                .text
                .contains("set to")
    );
}

#[test]
fn test_password_picker_prefix_bw_focuses_askpass() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(2); // Bitwarden (bw:)
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
    assert_eq!(app.forms.host.askpass, "bw:");
}

#[test]
fn test_password_picker_prefix_pass_focuses_askpass() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(3); // pass (pass:)
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
    assert_eq!(app.forms.host.askpass, "pass:");
}

#[test]
fn test_password_picker_prefix_vault_focuses_askpass() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(4); // Vault (vault:)
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert_eq!(app.forms.host.focused_field, FormField::AskPass);
    assert_eq!(app.forms.host.askpass, "vault:");
}

// =========================================================================
// Included host: edit blocked, but askpass visible in pending_connect
// =========================================================================

#[test]
fn test_included_host_edit_blocked() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
    app.screen = Screen::HostList;
    if let Some(host) = app.hosts_state.list.first_mut() {
        host.source_file = Some(std::path::PathBuf::from("/etc/ssh/ssh_config.d/work.conf"));
    }
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn test_included_host_connect_still_carries_askpass() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass op://V/I/p\n");
    app.screen = Screen::HostList;
    if let Some(host) = app.hosts_state.list.first_mut() {
        host.source_file = Some(std::path::PathBuf::from("/etc/ssh/ssh_config.d/work.conf"));
    }
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    if let Some((alias, askpass)) = &app.pending_connect {
        assert_eq!(alias, "myserver");
        assert_eq!(askpass.as_deref(), Some("op://V/I/p"));
    }
}

#[test]
fn test_included_host_delete_blocked() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:item\n");
    app.screen = Screen::HostList;
    if let Some(host) = app.hosts_state.list.first_mut() {
        host.source_file = Some(std::path::PathBuf::from("/etc/ssh/ssh_config.d/work.conf"));
    }
    app.ui.list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
}

// =========================================================================
// Form submit with askpass: verify to_entry() includes askpass
// =========================================================================

#[test]
fn test_form_submit_with_all_password_source_types() {
    let sources = [
        "keychain",
        "op://V/I/p",
        "bw:item",
        "pass:ssh/srv",
        "vault:kv/ssh#pw",
        "my-cmd %h",
    ];
    for source in &sources {
        let mut app = make_app("");
        app.screen = Screen::AddHost;
        app.forms.host.alias = "test-host".to_string();
        app.forms.host.hostname = "10.0.0.1".to_string();
        app.forms.host.askpass = source.to_string();
        let entry = app.forms.host.to_entry();
        assert_eq!(
            entry.askpass.as_deref(),
            Some(*source),
            "Form with askpass '{}' should produce entry with same askpass",
            source
        );
    }
}

#[test]
fn test_form_submit_empty_askpass_is_none() {
    let mut app = make_app("");
    app.screen = Screen::AddHost;
    app.forms.host.alias = "test-host".to_string();
    app.forms.host.hostname = "10.0.0.1".to_string();
    app.forms.host.askpass = "".to_string();
    let entry = app.forms.host.to_entry();
    assert!(entry.askpass.is_none(), "Empty askpass should produce None");
}

// =========================================================================
// Password picker: Enter with no selection is no-op
// =========================================================================

#[test]
fn test_password_picker_enter_with_no_selection() {
    let mut app = make_form_app();
    app.ui.password_picker.open = true;
    app.ui.password_picker.list = ratatui::widgets::ListState::default(); // no selection
    app.forms.host.askpass = "old".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.password_picker.open);
    assert_eq!(app.forms.host.askpass, "old");
}

// =========================================================================
// BW_SESSION: stored in app state
// =========================================================================

#[test]
fn test_bw_session_stored_in_app() {
    let mut app = make_app("Host srv\n  HostName 1.2.3.4\n  # purple:askpass bw:item\n");
    assert!(app.bw_session.is_none());
    app.bw_session = Some("test-session-token".to_string());
    assert_eq!(app.bw_session.as_deref(), Some("test-session-token"));
}

#[test]
fn test_bw_session_none_for_non_bw_source() {
    let app = make_app("Host srv\n  HostName 1.2.3.4\n  # purple:askpass keychain\n");
    assert!(app.bw_session.is_none());
}

// =========================================================================
// Ctrl+D sets global default in password picker
// =========================================================================

#[test]
fn test_password_picker_ctrl_d_closes_picker() {
    // Use "None" to avoid writing a value to the real preferences file
    let mut app = make_form_app();
    app.ui.password_picker.open_at(6); // None
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, ctrl_key('d'), &tx);
    assert!(!app.ui.password_picker.open);
}

#[test]
fn test_password_picker_ctrl_d_does_not_change_form_askpass() {
    let mut app = make_form_app();
    app.forms.host.askpass = "old".to_string();
    app.ui.password_picker.open_at(6); // None
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, ctrl_key('d'), &tx);
    // Ctrl+D only sets the global default, not the form field
    assert_eq!(app.forms.host.askpass, "old");
}

#[test]
fn test_password_picker_ctrl_d_none_sets_status() {
    let mut app = make_form_app();
    app.ui.password_picker.open_at(6); // None
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, ctrl_key('d'), &tx);
    // Shows "cleared" on success or "Failed to save" if ~/.purple doesn't exist
    assert!(app.status_center.status.is_some() || app.status_center.toast.is_some());
    assert!(!app.ui.password_picker.open);
}

#[test]
fn test_password_picker_ctrl_d_source_label_in_status() {
    // Verify logic: non-None sources produce "Global default set to X." message
    let sources = crate::askpass::PASSWORD_SOURCES;
    for (i, src) in sources.iter().enumerate() {
        if src.label == "None" {
            continue;
        }
        let expected = format!("Global default set to {}.", src.label);
        assert!(expected.contains("default"), "Source {}: {}", i, expected);
    }
}

// =========================================================================
// Keychain removal on askpass source change
// =========================================================================

#[test]
fn test_submit_form_old_askpass_tracked_for_edit() {
    // When editing a host with keychain askpass, the old source is detected
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
    assert_eq!(
        app.hosts_state.list[0].askpass,
        Some("keychain".to_string())
    );
    // Simulate opening edit form
    app.screen = Screen::EditHost {
        alias: "myserver".to_string(),
    };
    app.forms.host.alias = "myserver".to_string();
    app.forms.host.hostname = "10.0.0.1".to_string();
    // Change askpass to something else
    app.forms.host.askpass = "op://Vault/Item/pw".to_string();
    // The old_askpass detection in submit_form looks up app.hosts_state.list by alias
    let old = app
        .hosts_state
        .list
        .iter()
        .find(|h| h.alias == "myserver")
        .and_then(|h| h.askpass.clone());
    assert_eq!(old, Some("keychain".to_string()));
}

#[test]
fn test_submit_form_no_keychain_removal_when_unchanged() {
    let mut app = make_app("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
    app.screen = Screen::EditHost {
        alias: "myserver".to_string(),
    };
    app.forms.host.alias = "myserver".to_string();
    app.forms.host.hostname = "10.0.0.1".to_string();
    // Keep askpass as keychain
    app.forms.host.askpass = "keychain".to_string();
    let old = app
        .hosts_state
        .list
        .iter()
        .find(|h| h.alias == "myserver")
        .and_then(|h| h.askpass.clone());
    // Same source, no removal needed
    assert_eq!(old.as_deref(), Some("keychain"));
    assert_eq!(app.forms.host.askpass, "keychain");
}

#[test]
fn test_submit_form_no_keychain_removal_for_add() {
    // AddHost has no old askpass
    let mut app = make_app("Host existing\n  HostName 1.2.3.4\n");
    app.screen = Screen::AddHost;
    let old: Option<String> = None; // no old host for add
    assert!(old.is_none());
}

// =========================================================================
// Snippet picker
// =========================================================================

fn make_snippet_app() -> App {
    let mut app = make_app("Host myserver\n  HostName 1.2.3.4\n");
    let dir = std::env::temp_dir().join(format!(
        "purple_handler_snip_{}_{:?}",
        std::process::id(),
        std::thread::current().id()
    ));
    let _ = std::fs::create_dir_all(&dir);
    app.snippets.store.path_override = Some(dir.join("snippets"));
    app.snippets.store.snippets = vec![
        crate::snippet::Snippet {
            name: "check-disk".to_string(),
            command: "df -h".to_string(),
            description: "Check disk usage".to_string(),
        },
        crate::snippet::Snippet {
            name: "uptime".to_string(),
            command: "uptime".to_string(),
            description: String::new(),
        },
    ];
    let _ = app.snippets.store.save();
    app.ui.snippet_picker_state.select(Some(0));
    app.screen = Screen::SnippetPicker {
        target_aliases: vec!["myserver".to_string()],
    };
    app
}

#[test]
fn test_snippet_picker_nav_down_up() {
    let mut app = make_snippet_app();
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    assert_eq!(app.ui.snippet_picker_state.selected(), Some(1));

    let _ = handle_key_event(&mut app, key(KeyCode::Char('k')), &tx);
    assert_eq!(app.ui.snippet_picker_state.selected(), Some(0));
}

#[test]
fn test_snippet_picker_esc_returns_to_hostlist() {
    let mut app = make_snippet_app();
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert_eq!(app.screen, Screen::HostList);
}

#[test]
fn test_snippet_picker_q_returns_to_hostlist() {
    let mut app = make_snippet_app();
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Char('q')), &tx);
    assert_eq!(app.screen, Screen::HostList);
}

#[test]
fn test_snippet_picker_enter_starts_output() {
    let mut app = make_snippet_app();
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    match &app.screen {
        Screen::SnippetOutput {
            snippet_name,
            target_aliases,
        } => {
            assert_eq!(snippet_name, "check-disk");
            assert_eq!(target_aliases, &vec!["myserver".to_string()]);
        }
        _ => panic!("Expected SnippetOutput screen, got {:?}", app.screen),
    }
    assert!(app.snippets.output.is_some());
}

#[test]
fn test_snippet_picker_enter_clears_multi_select() {
    let mut app = make_snippet_app();
    app.hosts_state.multi_select.insert(0);
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(app.hosts_state.multi_select.is_empty());
}

#[test]
fn test_snippet_picker_a_opens_add_form() {
    let mut app = make_snippet_app();
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Char('a')), &tx);
    assert!(matches!(
        app.screen,
        Screen::SnippetForm { editing: None, .. }
    ));
    assert!(app.snippets.form.name.is_empty());
}

#[test]
fn test_snippet_picker_e_opens_edit_form() {
    let mut app = make_snippet_app();
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    assert!(matches!(
        app.screen,
        Screen::SnippetForm {
            editing: Some(0),
            ..
        }
    ));
    assert_eq!(app.snippets.form.name, "check-disk");
    assert_eq!(app.snippets.form.command, "df -h");
}

#[test]
fn test_snippet_picker_d_deletes_and_saves() {
    let mut app = make_snippet_app();
    let _ = app.snippets.store.save(); // ensure file exists
    let (tx, _rx) = mpsc::channel();

    // d sets pending confirmation
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    assert_eq!(app.snippets.pending_delete, Some(0));
    assert_eq!(app.snippets.store.snippets.len(), 2); // not yet deleted

    // y confirms deletion
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert_eq!(app.snippets.pending_delete, None);
    assert_eq!(app.snippets.store.snippets.len(), 1);
    assert_eq!(app.snippets.store.snippets[0].name, "uptime");
    assert_eq!(app.ui.snippet_picker_state.selected(), Some(0));
}

#[test]
fn test_snippet_picker_d_last_item_selects_none() {
    let mut app = make_snippet_app();
    app.snippets.store.snippets = vec![crate::snippet::Snippet {
        name: "only".to_string(),
        command: "ls".to_string(),
        description: String::new(),
    }];
    app.ui.snippet_picker_state.select(Some(0));
    let _ = app.snippets.store.save();
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    assert_eq!(app.snippets.pending_delete, Some(0));

    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert!(app.snippets.store.snippets.is_empty());
    assert_eq!(app.ui.snippet_picker_state.selected(), None);
}

#[test]
fn test_snippet_picker_d_rollback_on_save_failure() {
    let mut app = make_snippet_app();
    // Point to a non-writable path to force save failure
    app.snippets.store.path_override = Some(PathBuf::from("/nonexistent/dir/snippets"));
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    assert_eq!(app.snippets.pending_delete, Some(0));

    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    // Rollback: snippet should still be there
    assert_eq!(app.snippets.store.snippets.len(), 2);
    assert_eq!(app.snippets.store.snippets[0].name, "check-disk");
    assert!(app.status_center.toast.as_ref().unwrap().is_error());
}

// =========================================================================
// Snippet form
// =========================================================================

#[test]
fn test_snippet_form_esc_returns_to_picker() {
    let mut app = make_snippet_app();
    app.snippets.form = crate::app::SnippetForm::new();
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::SnippetPicker { .. }));
}

#[test]
fn test_snippet_form_tab_cycles_fields() {
    let mut app = make_snippet_app();
    app.snippets.form = crate::app::SnippetForm::new();
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    let (tx, _rx) = mpsc::channel();

    assert_eq!(
        app.snippets.form.focused_field,
        crate::app::SnippetFormField::Name
    );

    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.snippets.form.focused_field,
        crate::app::SnippetFormField::Command
    );

    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.snippets.form.focused_field,
        crate::app::SnippetFormField::Description
    );

    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    assert_eq!(
        app.snippets.form.focused_field,
        crate::app::SnippetFormField::Name
    );
}

#[test]
fn test_snippet_form_char_insert() {
    let mut app = make_snippet_app();
    app.snippets.form = crate::app::SnippetForm::new();
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Char('a')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('b')), &tx);
    assert_eq!(app.snippets.form.name, "ab");
    assert_eq!(app.snippets.form.cursor_pos, 2);
}

#[test]
fn test_snippet_form_backspace() {
    let mut app = make_snippet_app();
    app.snippets.form = crate::app::SnippetForm::new();
    app.snippets.form.name = "abc".to_string();
    app.snippets.form.cursor_pos = 3;
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert_eq!(app.snippets.form.name, "ab");
    assert_eq!(app.snippets.form.cursor_pos, 2);
}

#[test]
fn test_snippet_form_submit_add() {
    let mut app = make_snippet_app();
    let _ = app.snippets.store.save();
    app.snippets.form = crate::app::SnippetForm::new();
    app.snippets.form.name = "new-cmd".to_string();
    app.snippets.form.command = "whoami".to_string();
    app.snippets.form.cursor_pos = 6;
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(matches!(app.screen, Screen::SnippetPicker { .. }));
    assert_eq!(app.snippets.store.snippets.len(), 3);
    assert!(app.snippets.store.get("new-cmd").is_some());
}

#[test]
fn test_snippet_form_submit_edit() {
    let mut app = make_snippet_app();
    let _ = app.snippets.store.save();
    app.snippets.form =
        crate::app::SnippetForm::from_snippet(&app.snippets.store.snippets[0].clone());
    app.snippets.form.command = "df -hT".to_string();
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: Some(0),
    };
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(matches!(app.screen, Screen::SnippetPicker { .. }));
    assert_eq!(app.snippets.store.snippets[0].command, "df -hT");
}

#[test]
fn test_snippet_form_submit_rejects_empty_name() {
    let mut app = make_snippet_app();
    app.snippets.form = crate::app::SnippetForm::new();
    app.snippets.form.command = "ls".to_string();
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    // Should stay on the form with an error
    assert!(matches!(app.screen, Screen::SnippetForm { .. }));
    assert!(app.status_center.toast.as_ref().unwrap().is_error());
}

#[test]
fn test_snippet_form_submit_rejects_duplicate_name() {
    let mut app = make_snippet_app();
    let _ = app.snippets.store.save();
    app.snippets.form = crate::app::SnippetForm::new();
    app.snippets.form.name = "uptime".to_string();
    app.snippets.form.command = "uptime -s".to_string();
    app.snippets.form.cursor_pos = 9;
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(matches!(app.screen, Screen::SnippetForm { .. }));
    assert!(app.status_center.toast.as_ref().unwrap().is_error());
}

#[test]
fn test_snippet_form_submit_rollback_on_save_failure() {
    let mut app = make_snippet_app();
    // Force save failure
    app.snippets.store.path_override = Some(PathBuf::from("/nonexistent/dir/snippets"));
    app.snippets.form = crate::app::SnippetForm::new();
    app.snippets.form.name = "new-cmd".to_string();
    app.snippets.form.command = "whoami".to_string();
    app.snippets.form.cursor_pos = 6;
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    // Rollback: new snippet should not be in the store
    assert_eq!(app.snippets.store.snippets.len(), 2);
    assert!(app.snippets.store.get("new-cmd").is_none());
    assert!(app.status_center.toast.as_ref().unwrap().is_error());
}

#[test]
fn test_snippet_form_edit_rename_rollback_on_save_failure() {
    let mut app = make_snippet_app();
    // Force save failure
    app.snippets.store.path_override = Some(PathBuf::from("/nonexistent/dir/snippets"));
    app.snippets.form =
        crate::app::SnippetForm::from_snippet(&app.snippets.store.snippets[0].clone());
    app.snippets.form.name = "renamed".to_string();
    app.snippets.form.cursor_pos = 7;
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: Some(0),
    };
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    // Rollback: original snippets should still be there
    assert_eq!(app.snippets.store.snippets.len(), 2);
    assert!(app.snippets.store.get("check-disk").is_some());
    assert!(app.snippets.store.get("renamed").is_none());
}

#[test]
fn test_snippet_picker_enter_with_no_selection() {
    let mut app = make_snippet_app();
    app.snippets.store.snippets.clear();
    app.ui.snippet_picker_state.select(None);
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    // Should remain on picker, no pending snippet
    assert!(matches!(app.screen, Screen::SnippetPicker { .. }));
    assert!(app.snippets.pending.is_none());
}

#[test]
fn test_host_list_r_opens_snippet_picker() {
    let mut app = make_app("Host myserver\n  HostName 1.2.3.4\n");
    app.ui.list_state.select(Some(0));
    let dir = std::env::temp_dir().join(format!("purple_handler_snip_r_{}", std::process::id()));
    let _ = std::fs::create_dir_all(&dir);
    app.snippets.store.path_override = Some(dir.join("snippets"));
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Char('r')), &tx);
    match &app.screen {
        Screen::SnippetPicker { target_aliases } => {
            assert_eq!(target_aliases, &vec!["myserver".to_string()]);
        }
        _ => panic!("Expected SnippetPicker screen"),
    }
}

#[test]
fn test_host_list_r_shift_opens_snippet_picker_all() {
    let mut app = make_app("Host a\n  HostName 1.1.1.1\nHost b\n  HostName 2.2.2.2\n");
    app.ui.list_state.select(Some(0));
    let dir = std::env::temp_dir().join(format!("purple_handler_snip_R_{}", std::process::id()));
    let _ = std::fs::create_dir_all(&dir);
    app.snippets.store.path_override = Some(dir.join("snippets"));
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Char('R')), &tx);
    match &app.screen {
        Screen::SnippetPicker { target_aliases } => {
            assert_eq!(target_aliases.len(), 2);
        }
        _ => panic!("Expected SnippetPicker screen"),
    }
}

// --- Tunnel form Space/arrow tests ---

fn make_tunnel_form_app(field: crate::app::TunnelFormField) -> App {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::TunnelForm {
        alias: "test".to_string(),
        editing: None,
    };
    app.tunnels.form = crate::app::TunnelForm::new();
    app.tunnels.form.focused_field = field;
    app
}

#[test]
fn test_tunnel_form_space_cycles_type_local_to_remote() {
    let mut app = make_tunnel_form_app(crate::app::TunnelFormField::Type);
    assert_eq!(
        app.tunnels.form.tunnel_type,
        crate::tunnel::TunnelType::Local
    );
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert_eq!(
        app.tunnels.form.tunnel_type,
        crate::tunnel::TunnelType::Remote
    );
}

#[test]
fn test_tunnel_form_space_cycles_type_remote_to_dynamic() {
    let mut app = make_tunnel_form_app(crate::app::TunnelFormField::Type);
    app.tunnels.form.tunnel_type = crate::tunnel::TunnelType::Remote;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert_eq!(
        app.tunnels.form.tunnel_type,
        crate::tunnel::TunnelType::Dynamic
    );
}

#[test]
fn test_tunnel_form_space_cycles_type_dynamic_to_local() {
    let mut app = make_tunnel_form_app(crate::app::TunnelFormField::Type);
    app.tunnels.form.tunnel_type = crate::tunnel::TunnelType::Dynamic;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert_eq!(
        app.tunnels.form.tunnel_type,
        crate::tunnel::TunnelType::Local
    );
}

#[test]
fn test_tunnel_form_left_on_type_does_not_cycle() {
    let mut app = make_tunnel_form_app(crate::app::TunnelFormField::Type);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Left), &tx);
    assert_eq!(
        app.tunnels.form.tunnel_type,
        crate::tunnel::TunnelType::Local
    );
}

#[test]
fn test_tunnel_form_right_on_type_does_not_cycle() {
    let mut app = make_tunnel_form_app(crate::app::TunnelFormField::Type);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Right), &tx);
    assert_eq!(
        app.tunnels.form.tunnel_type,
        crate::tunnel::TunnelType::Local
    );
}

#[test]
fn test_tunnel_form_space_on_bind_port_inserts_space() {
    let mut app = make_tunnel_form_app(crate::app::TunnelFormField::BindPort);
    app.tunnels.form.bind_port = "80".to_string();
    app.tunnels.form.cursor_pos = 2;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert_eq!(app.tunnels.form.bind_port, "80 ");
}

#[test]
fn test_tunnel_form_left_on_text_moves_cursor() {
    let mut app = make_tunnel_form_app(crate::app::TunnelFormField::BindPort);
    app.tunnels.form.bind_port = "8080".to_string();
    app.tunnels.form.cursor_pos = 2;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Left), &tx);
    assert_eq!(app.tunnels.form.cursor_pos, 1);
}

// --- Dirty-check tests ---

#[test]
fn test_host_form_clean_esc_closes_immediately() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.forms.host = crate::app::HostForm::new();
    app.screen = Screen::AddHost;
    app.capture_form_baseline();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(!app.forms.pending_discard_confirm);
}

#[test]
fn test_host_form_dirty_esc_shows_confirmation() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.forms.host = crate::app::HostForm::new();
    app.screen = Screen::AddHost;
    app.capture_form_baseline();
    app.forms.host.alias = "dirty".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::AddHost));
    assert!(app.forms.pending_discard_confirm);
}

#[test]
fn test_host_form_dirty_esc_y_closes() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.forms.host = crate::app::HostForm::new();
    app.screen = Screen::AddHost;
    app.capture_form_baseline();
    app.forms.host.alias = "dirty".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(app.forms.host_baseline.is_none());
}

#[test]
fn test_host_form_dirty_esc_n_stays() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.forms.host = crate::app::HostForm::new();
    app.screen = Screen::AddHost;
    app.capture_form_baseline();
    app.forms.host.hostname = "changed.com".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert!(matches!(app.screen, Screen::AddHost));
    assert!(!app.forms.pending_discard_confirm);
}

#[test]
fn test_host_form_dirty_esc_other_key_ignored() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.forms.host = crate::app::HostForm::new();
    app.screen = Screen::AddHost;
    app.capture_form_baseline();
    app.forms.host.alias = "dirty".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('x')), &tx);
    assert!(app.forms.pending_discard_confirm); // still pending
}

#[test]
fn test_tunnel_form_dirty_esc_shows_confirmation() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::TunnelForm {
        alias: "test".to_string(),
        editing: None,
    };
    app.tunnels.form = crate::app::TunnelForm::new();
    app.capture_tunnel_form_baseline();
    app.tunnels.form.bind_port = "9000".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::TunnelForm { .. }));
    assert!(app.forms.pending_discard_confirm);
}

#[test]
fn test_tunnel_form_clean_esc_closes() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::TunnelForm {
        alias: "test".to_string(),
        editing: None,
    };
    app.tunnels.form = crate::app::TunnelForm::new();
    app.capture_tunnel_form_baseline();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::TunnelList { .. }));
}

// --- Delete confirmation tests ---

#[test]
fn test_snippet_picker_d_esc_cancels_delete() {
    let mut app = make_snippet_app();
    let _ = app.snippets.store.save();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    assert_eq!(app.snippets.pending_delete, Some(0));
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert_eq!(app.snippets.pending_delete, None);
    assert_eq!(app.snippets.store.snippets.len(), 2);
}

#[test]
fn test_snippet_picker_d_n_cancels_delete() {
    let mut app = make_snippet_app();
    let _ = app.snippets.store.save();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert_eq!(app.snippets.pending_delete, None);
    assert_eq!(app.snippets.store.snippets.len(), 2);
}

#[test]
fn test_snippet_picker_d_other_key_ignored() {
    let mut app = make_snippet_app();
    let _ = app.snippets.store.save();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    assert_eq!(app.snippets.pending_delete, Some(0));
    assert_eq!(app.snippets.store.snippets.len(), 2);
}

#[test]
fn test_confirm_import_uppercase_y_works() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ConfirmImport { count: 0 };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('Y')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn test_confirm_import_n_cancels() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ConfirmImport { count: 0 };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn test_confirm_import_uppercase_n_cancels() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::ConfirmImport { count: 0 };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('N')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
}

// --- HostDetail navigation tests ---

#[test]
fn test_host_detail_esc_returns_to_host_list() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::HostDetail { index: 0 };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn test_host_detail_e_opens_edit() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::HostDetail { index: 0 };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    assert!(matches!(app.screen, Screen::EditHost { .. }));
    assert!(app.forms.host_baseline.is_some());
}

#[test]
fn test_host_detail_t_opens_tunnel_list() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::HostDetail { index: 0 };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('T')), &tx);
    assert!(matches!(app.screen, Screen::TunnelList { .. }));
}

#[test]
fn test_host_detail_r_opens_snippet_picker() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::HostDetail { index: 0 };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('r')), &tx);
    assert!(matches!(app.screen, Screen::SnippetPicker { .. }));
}

#[test]
fn test_host_detail_e_on_included_host_stays() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.hosts_state.list[0].source_file = Some(PathBuf::from("/etc/ssh/config.d/test"));
    app.screen = Screen::HostDetail { index: 0 };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    assert!(matches!(app.screen, Screen::HostDetail { .. }));
    assert!(app.status_center.toast.as_ref().unwrap().is_error());
}

// --- Provider form: Left/Right on toggle fields does NOT toggle ---

#[test]
fn test_provider_form_left_on_verify_tls_stays_same() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::VerifyTls);
    assert!(app.providers.form.verify_tls);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Left), &tx);
    assert!(app.providers.form.verify_tls);
}

#[test]
fn test_provider_form_right_on_verify_tls_stays_same() {
    let mut app = make_form_app_focused_on("proxmox", ProviderFormField::VerifyTls);
    assert!(app.providers.form.verify_tls);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Right), &tx);
    assert!(app.providers.form.verify_tls);
}

#[test]
fn test_provider_form_left_on_auto_sync_stays_same() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::AutoSync);
    assert!(app.providers.form.auto_sync);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Left), &tx);
    assert!(app.providers.form.auto_sync);
}

#[test]
fn test_provider_form_right_on_auto_sync_stays_same() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::AutoSync);
    assert!(app.providers.form.auto_sync);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Right), &tx);
    assert!(app.providers.form.auto_sync);
}

// --- Provider form: dirty-check on Esc ---

#[test]
fn test_provider_form_clean_esc_with_baseline_closes() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.capture_provider_form_baseline();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::Providers));
    assert!(!app.forms.pending_discard_confirm);
}

#[test]
fn test_provider_form_dirty_esc_shows_confirmation() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.capture_provider_form_baseline();
    app.providers.form.token = "newtoken".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert!(app.forms.pending_discard_confirm);
}

#[test]
fn test_provider_form_dirty_esc_y_closes() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.capture_provider_form_baseline();
    app.providers.form.token = "newtoken".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert!(matches!(app.screen, Screen::Providers));
    assert!(app.providers.form_baseline.is_none());
}

#[test]
fn test_provider_form_dirty_esc_n_stays() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.capture_provider_form_baseline();
    app.providers.form.token = "newtoken".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert!(matches!(app.screen, Screen::ProviderForm { .. }));
    assert!(!app.forms.pending_discard_confirm);
}

// --- Snippet form: dirty-check on Esc ---

#[test]
fn test_snippet_form_clean_esc_with_baseline_closes() {
    let mut app = make_snippet_app();
    app.snippets.form = crate::app::SnippetForm::new();
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    app.capture_snippet_form_baseline();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::SnippetPicker { .. }));
    assert!(!app.forms.pending_discard_confirm);
}

#[test]
fn test_snippet_form_dirty_esc_shows_confirmation() {
    let mut app = make_snippet_app();
    app.snippets.form = crate::app::SnippetForm::new();
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    app.capture_snippet_form_baseline();
    app.snippets.form.name = "dirty".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::SnippetForm { .. }));
    assert!(app.forms.pending_discard_confirm);
}

#[test]
fn test_snippet_form_dirty_esc_y_closes() {
    let mut app = make_snippet_app();
    app.snippets.form = crate::app::SnippetForm::new();
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    app.capture_snippet_form_baseline();
    app.snippets.form.name = "dirty".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert!(matches!(app.screen, Screen::SnippetPicker { .. }));
    assert!(app.snippets.form_baseline.is_none());
}

// --- Tunnel delete: d/y/Esc/n ---

#[test]
fn test_tunnel_list_d_y_deletes_tunnel() {
    let mut app = make_app("Host test\n  HostName test.com\n  LocalForward 8080 localhost:80\n");
    app.screen = Screen::TunnelList {
        alias: "test".to_string(),
    };
    app.refresh_tunnel_list("test");
    app.ui.tunnel_list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    assert_eq!(app.tunnels.pending_delete, Some(0));
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert!(app.tunnels.pending_delete.is_none());
}

#[test]
fn test_tunnel_list_d_esc_cancels_delete() {
    let mut app = make_app("Host test\n  HostName test.com\n  LocalForward 8080 localhost:80\n");
    app.screen = Screen::TunnelList {
        alias: "test".to_string(),
    };
    app.refresh_tunnel_list("test");
    app.ui.tunnel_list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    assert_eq!(app.tunnels.pending_delete, Some(0));
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(app.tunnels.pending_delete.is_none());
    assert_eq!(app.tunnels.list.len(), 1);
}

#[test]
fn test_tunnel_list_d_n_cancels_delete() {
    let mut app = make_app("Host test\n  HostName test.com\n  LocalForward 8080 localhost:80\n");
    app.screen = Screen::TunnelList {
        alias: "test".to_string(),
    };
    app.refresh_tunnel_list("test");
    app.ui.tunnel_list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert!(app.tunnels.pending_delete.is_none());
    assert_eq!(app.tunnels.list.len(), 1);
}

// --- Host form: baseline cleared after submit ---

#[test]
fn test_host_form_baseline_cleared_after_submit() {
    let dir = tempfile::tempdir().expect("tempdir");
    let config_path = dir.path().join("test_config");
    std::fs::write(&config_path, "Host test\n  HostName test.com\n").unwrap();
    let config = SshConfigFile {
        elements: SshConfigFile::parse_content("Host test\n  HostName test.com\n"),
        path: config_path.clone(),
        crlf: false,
        bom: false,
    };
    let mut app = App::new(config);
    app.providers.config = test_provider_config();
    crate::preferences::set_path_override(dir.path().join("preferences"));
    app.forms.host = crate::app::HostForm::new();
    app.forms.host.alias = "newhost".to_string();
    app.forms.host.hostname = "new.example.com".to_string();
    app.screen = Screen::AddHost;
    app.capture_form_mtime();
    app.capture_form_baseline();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(app.forms.host_baseline.is_none());
}

// --- Edge case: uppercase Y in discard confirms ---

#[test]
fn test_host_form_dirty_esc_uppercase_y_closes() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.forms.host = crate::app::HostForm::new();
    app.screen = Screen::AddHost;
    app.capture_form_baseline();
    app.forms.host.user = "ubuntu".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('Y')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(app.forms.host_baseline.is_none());
}

// --- Snippet form: dirty + n stays ---

#[test]
fn test_snippet_form_dirty_esc_n_stays() {
    let mut app = make_snippet_app();
    app.snippets.form = crate::app::SnippetForm::new();
    app.screen = Screen::SnippetForm {
        target_aliases: vec!["myserver".to_string()],
        editing: None,
    };
    app.capture_snippet_form_baseline();
    app.snippets.form.command = "changed".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert!(matches!(app.screen, Screen::SnippetForm { .. }));
    assert!(!app.forms.pending_discard_confirm);
}

// --- Tunnel form: dirty + y closes, dirty + n stays ---

#[test]
fn test_tunnel_form_dirty_esc_y_closes() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::TunnelForm {
        alias: "test".to_string(),
        editing: None,
    };
    app.tunnels.form = crate::app::TunnelForm::new();
    app.capture_tunnel_form_baseline();
    app.tunnels.form.remote_host = "db.local".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert!(matches!(app.screen, Screen::TunnelList { .. }));
    assert!(app.tunnels.form_baseline.is_none());
}

#[test]
fn test_tunnel_form_dirty_esc_n_stays() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.screen = Screen::TunnelForm {
        alias: "test".to_string(),
        editing: None,
    };
    app.tunnels.form = crate::app::TunnelForm::new();
    app.capture_tunnel_form_baseline();
    app.tunnels.form.bind_port = "9001".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert!(matches!(app.screen, Screen::TunnelForm { .. }));
    assert!(!app.forms.pending_discard_confirm);
}

// --- Tunnel delete: other key ignored ---

#[test]
fn test_tunnel_delete_other_key_ignored() {
    let mut app = make_app("Host test\n  HostName test.com\n  LocalForward 8080 localhost:80\n");
    app.screen = Screen::TunnelList {
        alias: "test".to_string(),
    };
    app.refresh_tunnel_list("test");
    app.ui.tunnel_list_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    assert_eq!(app.tunnels.pending_delete, Some(0));
    let _ = handle_key_event(&mut app, key(KeyCode::Char('z')), &tx);
    assert_eq!(app.tunnels.pending_delete, Some(0));
}

// --- Provider form: dirty + other key ignored ---

#[test]
fn test_provider_form_dirty_esc_other_key_ignored() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::Token);
    app.capture_provider_form_baseline();
    app.providers.form.token = "newtoken".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('x')), &tx);
    assert!(app.forms.pending_discard_confirm);
}

// --- Stale purge tests ---

#[test]
fn test_x_key_opens_confirm_purge_stale() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('X')), &tx);
    match &app.screen {
        Screen::ConfirmPurgeStale { aliases, provider } => {
            assert_eq!(aliases.len(), 1);
            assert_eq!(aliases[0], "do-web");
            assert!(provider.is_none());
        }
        other => panic!("expected ConfirmPurgeStale, got {:?}", other),
    }
}

#[test]
fn test_x_key_no_stale_shows_status() {
    let mut app = make_app("Host normal\n  HostName 1.2.3.4\n");
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('X')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    let toast = app
        .status_center
        .toast
        .as_ref()
        .expect("toast should be set");
    assert!(
        toast.text.contains("No stale hosts"),
        "expected 'No stale hosts' in toast, got: {}",
        toast.text
    );
}

#[test]
fn test_confirm_purge_stale_y_deletes() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n\nHost keep\n  HostName 5.6.7.8\n",
    );
    app.screen = Screen::ConfirmPurgeStale {
        aliases: vec!["do-web".to_string()],
        provider: None,
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    // The stale host should be gone, only "keep" remains
    let aliases: Vec<&str> = app
        .hosts_state
        .list
        .iter()
        .map(|h| h.alias.as_str())
        .collect();
    assert!(!aliases.contains(&"do-web"), "stale host should be removed");
    assert!(aliases.contains(&"keep"), "non-stale host should remain");
}

#[test]
fn test_confirm_purge_stale_esc_cancels() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    app.screen = Screen::ConfirmPurgeStale {
        aliases: vec!["do-web".to_string()],
        provider: None,
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    // Host should still exist
    assert_eq!(app.hosts_state.list.len(), 1);
    assert_eq!(app.hosts_state.list[0].alias, "do-web");
}

#[test]
fn test_e_key_warns_on_stale_host() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    // Edit form should open (warning, not block)
    assert!(matches!(app.screen, Screen::EditHost { .. }));
    let toast = app
        .status_center
        .toast
        .as_ref()
        .expect("toast should be set");
    assert!(toast.text.contains("Stale host"));
    assert!(toast.text.contains("DigitalOcean"));
    assert!(toast.is_error());
}

#[test]
fn test_d_key_warns_on_stale_host() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    // Delete confirm should open (warning, not block)
    assert!(matches!(app.screen, Screen::ConfirmDelete { .. }));
    let toast = app
        .status_center
        .toast
        .as_ref()
        .expect("toast should be set");
    assert!(toast.text.contains("Stale host"));
    assert!(toast.is_error());
}

#[test]
fn test_enter_on_stale_host_shows_warning() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    // Connection should still be pending
    assert!(app.pending_connect.is_some());
    // But toast should show stale warning
    let toast = app
        .status_center
        .toast
        .as_ref()
        .expect("toast should be set");
    assert!(
        toast.text.contains("Stale host"),
        "expected stale warning, got: {}",
        toast.text
    );
    assert!(toast.text.contains("DigitalOcean"));
}

#[test]
fn test_enter_on_normal_host_no_stale_warning() {
    let mut app = make_app("Host normal\n  HostName 1.2.3.4\n");
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(app.pending_connect.is_some());
    // No stale warning
    assert!(
        app.status_center.toast.is_none()
            || !app
                .status_center
                .toast
                .as_ref()
                .unwrap()
                .text
                .contains("Stale"),
    );
}

#[test]
fn test_search_enter_on_stale_host_shows_warning() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    // Enter search mode
    app.search.query = Some("do-web".to_string());
    app.apply_filter();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(app.pending_connect.is_some());
    let toast = app
        .status_center
        .toast
        .as_ref()
        .expect("toast should be set");
    assert!(
        toast.text.contains("Stale host"),
        "expected stale warning in search mode, got: {}",
        toast.text
    );
}

#[test]
fn test_c_key_warns_on_stale_host() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('c')), &tx);
    assert!(matches!(app.screen, Screen::AddHost));
    let toast = app
        .status_center
        .toast
        .as_ref()
        .expect("toast should be set");
    assert!(
        toast.text.contains("Stale host"),
        "expected stale warning, got: {}",
        toast.text
    );
    assert!(toast.is_error());
}

#[test]
fn test_t_key_warns_on_stale_host() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('T')), &tx);
    assert!(
        matches!(app.screen, Screen::TunnelList { .. }),
        "expected TunnelList screen, got: {:?}",
        app.screen
    );
    let toast = app
        .status_center
        .toast
        .as_ref()
        .expect("toast should be set");
    assert!(
        toast.text.contains("Stale host"),
        "expected stale warning, got: {}",
        toast.text
    );
    assert!(toast.is_error());
}

#[test]
fn test_provider_x_key_opens_scoped_purge() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    app.screen = Screen::Providers;
    app.providers.config = test_provider_config();
    app.providers.config.set_section(ProviderSection {
        id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
        token: "tok".to_string(),
        alias_prefix: "do".to_string(),
        user: "root".to_string(),
        identity_file: String::new(),
        url: String::new(),
        verify_tls: true,
        auto_sync: true,
        profile: String::new(),
        regions: String::new(),
        project: String::new(),
        compartment: String::new(),
        vault_role: String::new(),
        vault_addr: String::new(),
    });
    // Select the DigitalOcean provider in the list
    let sorted = app.sorted_provider_names();
    let idx = sorted
        .iter()
        .position(|n| n == "digitalocean")
        .expect("digitalocean should be in sorted list");
    app.ui.provider_list_state.select(Some(idx));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('X')), &tx);
    match &app.screen {
        Screen::ConfirmPurgeStale { aliases, provider } => {
            assert_eq!(aliases, &vec!["do-web".to_string()]);
            assert_eq!(provider.as_deref(), Some("digitalocean"));
        }
        other => panic!("expected ConfirmPurgeStale, got {:?}", other),
    }
}

#[test]
fn test_provider_purge_y_returns_to_providers() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    app.screen = Screen::ConfirmPurgeStale {
        aliases: vec!["do-web".to_string()],
        provider: Some("digitalocean".to_string()),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert!(
        matches!(app.screen, Screen::Providers),
        "expected Providers screen after provider-scoped purge, got: {:?}",
        app.screen
    );
}

#[test]
fn test_provider_purge_esc_returns_to_providers() {
    let mut app = make_app(
        "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:stale 1711900000\n",
    );
    app.screen = Screen::ConfirmPurgeStale {
        aliases: vec!["do-web".to_string()],
        provider: Some("digitalocean".to_string()),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(
        matches!(app.screen, Screen::Providers),
        "expected Providers screen after Esc on provider-scoped purge, got: {:?}",
        app.screen
    );
    // Host should still exist (purge was cancelled)
    assert_eq!(app.hosts_state.list.len(), 1);
    assert_eq!(app.hosts_state.list[0].alias, "do-web");
}

// =========================================================================
// Container handler tests
// =========================================================================

fn make_container_state(
    alias: &str,
    containers: Vec<crate::containers::ContainerInfo>,
) -> crate::app::ContainerState {
    let mut list_state = ratatui::widgets::ListState::default();
    if !containers.is_empty() {
        list_state.select(Some(0));
    }
    crate::app::ContainerState {
        alias: alias.to_string(),
        askpass: None,
        runtime: Some(crate::containers::ContainerRuntime::Docker),
        containers,
        list_state,
        loading: false,
        error: None,
        action_in_progress: None,
        confirm_action: None,
    }
}

fn make_container(id: &str, name: &str, state: &str) -> crate::containers::ContainerInfo {
    crate::containers::ContainerInfo {
        id: id.to_string(),
        names: name.to_string(),
        image: "test:latest".to_string(),
        state: state.to_string(),
        status: "Up".to_string(),
        ports: "".to_string(),
    }
}

#[test]
fn test_shift_c_opens_containers() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('C')), &tx);
    assert!(
        matches!(app.screen, Screen::Containers { .. }),
        "expected Containers screen, got: {:?}",
        app.screen
    );
    assert!(
        app.container_state.is_some(),
        "container_state should be Some after Shift+C"
    );
}

#[test]
fn test_shift_c_no_host_noop() {
    let mut app = make_app("");
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('C')), &tx);
    assert!(
        matches!(app.screen, Screen::HostList),
        "expected HostList when no hosts, got: {:?}",
        app.screen
    );
    assert!(app.container_state.is_none());
}

#[test]
fn test_shift_c_loads_cache() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.container_cache.insert(
        "web".to_string(),
        crate::containers::ContainerCacheEntry {
            timestamp: 100,
            runtime: crate::containers::ContainerRuntime::Docker,
            containers: vec![make_container("abc", "nginx", "running")],
        },
    );
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('C')), &tx);
    let state = app.container_state.as_ref().unwrap();
    assert_eq!(state.containers.len(), 1);
    assert_eq!(state.containers[0].id, "abc");
    assert_eq!(
        state.runtime,
        Some(crate::containers::ContainerRuntime::Docker)
    );
}

#[test]
fn test_shift_c_no_cache_empty() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('C')), &tx);
    let state = app.container_state.as_ref().unwrap();
    assert!(state.containers.is_empty());
    assert!(state.runtime.is_none());
}

#[test]
fn test_containers_esc_closes() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    app.container_state = Some(make_container_state("web", vec![]));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(app.container_state.is_none());
}

#[test]
fn test_containers_q_closes() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    app.container_state = Some(make_container_state("web", vec![]));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('q')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(app.container_state.is_none());
}

#[test]
fn test_containers_j_moves_down() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let containers = vec![
        make_container("a", "web", "running"),
        make_container("b", "db", "running"),
        make_container("c", "cache", "exited"),
    ];
    app.container_state = Some(make_container_state("web", containers));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    let sel = app.container_state.as_ref().unwrap().list_state.selected();
    assert_eq!(sel, Some(1));
}

#[test]
fn test_containers_k_moves_up() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let containers = vec![
        make_container("a", "web", "running"),
        make_container("b", "db", "running"),
    ];
    let mut state = make_container_state("web", containers);
    state.list_state.select(Some(1));
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('k')), &tx);
    let sel = app.container_state.as_ref().unwrap().list_state.selected();
    assert_eq!(sel, Some(0));
}

#[test]
fn test_containers_j_wraps() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let containers = vec![
        make_container("a", "web", "running"),
        make_container("b", "db", "running"),
    ];
    let mut state = make_container_state("web", containers);
    state.list_state.select(Some(1)); // at last
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    let sel = app.container_state.as_ref().unwrap().list_state.selected();
    assert_eq!(sel, Some(0), "j at last item should wrap to 0");
}

#[test]
fn test_containers_k_wraps() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let containers = vec![
        make_container("a", "web", "running"),
        make_container("b", "db", "running"),
    ];
    app.container_state = Some(make_container_state("web", containers));
    // selection starts at 0
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('k')), &tx);
    let sel = app.container_state.as_ref().unwrap().list_state.selected();
    assert_eq!(sel, Some(1), "k at first item should wrap to last");
}

#[test]
fn test_containers_j_empty_noop() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    app.container_state = Some(make_container_state("web", vec![]));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    let sel = app.container_state.as_ref().unwrap().list_state.selected();
    assert_eq!(sel, None);
}

#[test]
fn test_containers_k_empty_noop() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    app.container_state = Some(make_container_state("web", vec![]));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('k')), &tx);
    let sel = app.container_state.as_ref().unwrap().list_state.selected();
    assert_eq!(sel, None);
}

#[test]
fn test_containers_page_down() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let containers: Vec<_> = (0..20)
        .map(|i| make_container(&format!("c{i}"), &format!("svc{i}"), "running"))
        .collect();
    app.container_state = Some(make_container_state("web", containers));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::PageDown), &tx);
    let sel = app.container_state.as_ref().unwrap().list_state.selected();
    assert_eq!(sel, Some(10));
}

#[test]
fn test_containers_page_up() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let containers: Vec<_> = (0..20)
        .map(|i| make_container(&format!("c{i}"), &format!("svc{i}"), "running"))
        .collect();
    let mut state = make_container_state("web", containers);
    state.list_state.select(Some(15));
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::PageUp), &tx);
    let sel = app.container_state.as_ref().unwrap().list_state.selected();
    assert_eq!(sel, Some(5));
}

#[test]
fn test_containers_s_sets_action_in_progress() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    app.container_state = Some(make_container_state(
        "web",
        vec![make_container("abc123", "nginx", "exited")],
    ));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('s')), &tx);
    let state = app.container_state.as_ref().unwrap();
    assert!(
        state.action_in_progress.is_some(),
        "action_in_progress should be set after s"
    );
    assert!(
        state.action_in_progress.as_ref().unwrap().contains("start"),
        "action should contain 'start'"
    );
}

#[test]
fn test_containers_x_shows_confirmation() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    app.container_state = Some(make_container_state(
        "web",
        vec![make_container("abc123", "nginx", "running")],
    ));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('x')), &tx);
    let state = app.container_state.as_ref().unwrap();
    assert!(state.confirm_action.is_some());
    let (action, name, _id) = state.confirm_action.as_ref().unwrap();
    assert_eq!(*action, crate::containers::ContainerAction::Stop);
    assert_eq!(name, "nginx");
}

#[test]
fn test_containers_r_shows_confirmation() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    app.container_state = Some(make_container_state(
        "web",
        vec![make_container("abc123", "nginx", "running")],
    ));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('r')), &tx);
    let state = app.container_state.as_ref().unwrap();
    assert!(state.confirm_action.is_some());
    let (action, name, _id) = state.confirm_action.as_ref().unwrap();
    assert_eq!(*action, crate::containers::ContainerAction::Restart);
    assert_eq!(name, "nginx");
}

#[test]
fn test_containers_y_confirms_action() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![make_container("abc123", "nginx", "running")]);
    state.confirm_action = Some((
        crate::containers::ContainerAction::Stop,
        "nginx".to_string(),
        "abc123".to_string(),
    ));
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    let state = app.container_state.as_ref().unwrap();
    assert!(state.confirm_action.is_none());
    assert!(state.action_in_progress.is_some());
}

#[test]
fn test_containers_esc_cancels_confirmation() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![make_container("abc123", "nginx", "running")]);
    state.confirm_action = Some((
        crate::containers::ContainerAction::Stop,
        "nginx".to_string(),
        "abc123".to_string(),
    ));
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    // Should cancel confirmation but stay in overlay
    assert!(app.container_state.is_some());
    assert!(
        app.container_state
            .as_ref()
            .unwrap()
            .confirm_action
            .is_none()
    );
    assert!(matches!(app.screen, Screen::Containers { .. }));
}

#[test]
fn test_containers_action_blocked_when_in_progress() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![make_container("abc123", "nginx", "running")]);
    state.action_in_progress = Some("stop nginx...".to_string());
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('s')), &tx);
    // action_in_progress should remain the same (not changed to start)
    let state = app.container_state.as_ref().unwrap();
    assert_eq!(state.action_in_progress.as_deref(), Some("stop nginx..."));
}

#[test]
fn test_containers_action_no_selection_noop() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![]);
    state.list_state.select(None);
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('s')), &tx);
    assert!(
        app.container_state
            .as_ref()
            .unwrap()
            .action_in_progress
            .is_none(),
        "no action should start without selection"
    );
}

#[test]
fn test_containers_action_no_runtime_noop() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![make_container("abc123", "nginx", "running")]);
    state.runtime = None;
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('s')), &tx);
    assert!(
        app.container_state
            .as_ref()
            .unwrap()
            .action_in_progress
            .is_none(),
        "no action should start without runtime"
    );
}

#[test]
fn test_containers_r_uppercase_refreshes() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    app.container_state = Some(make_container_state(
        "web",
        vec![make_container("abc123", "nginx", "running")],
    ));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('R')), &tx);
    assert!(
        app.container_state.as_ref().unwrap().loading,
        "loading should be true after R"
    );
}

#[test]
fn test_containers_r_uppercase_blocked_when_in_progress() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![make_container("abc123", "nginx", "running")]);
    state.action_in_progress = Some("restart nginx...".to_string());
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('R')), &tx);
    assert!(
        !app.container_state.as_ref().unwrap().loading,
        "loading should remain false when action is in progress"
    );
}

#[test]
fn test_containers_unknown_key_noop() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let containers = vec![make_container("abc123", "nginx", "running")];
    app.container_state = Some(make_container_state("web", containers.clone()));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('z')), &tx);
    assert!(matches!(app.screen, Screen::Containers { .. }));
    let state = app.container_state.as_ref().unwrap();
    assert_eq!(state.list_state.selected(), Some(0));
    assert!(state.action_in_progress.is_none());
    assert!(!state.loading);
}

#[test]
fn test_containers_y_noop_without_pending() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    app.container_state = Some(make_container_state(
        "web",
        vec![make_container("abc123", "nginx", "running")],
    ));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    let state = app.container_state.as_ref().unwrap();
    assert!(
        state.action_in_progress.is_none(),
        "no action should start when confirm_action is None"
    );
    assert!(
        state.confirm_action.is_none(),
        "confirm_action should remain None"
    );
}

#[test]
fn test_containers_x_blocked_when_action_in_progress() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![make_container("abc123", "nginx", "running")]);
    state.action_in_progress = Some("stop nginx...".to_string());
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('x')), &tx);
    let state = app.container_state.as_ref().unwrap();
    assert!(
        state.confirm_action.is_none(),
        "x should not open confirmation when action is in progress"
    );
}

#[test]
fn test_containers_r_blocked_when_action_in_progress() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![make_container("abc123", "nginx", "running")]);
    state.action_in_progress = Some("stop nginx...".to_string());
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('r')), &tx);
    let state = app.container_state.as_ref().unwrap();
    assert!(
        state.confirm_action.is_none(),
        "r should not open confirmation when action is in progress"
    );
}

#[test]
fn test_containers_x_blocked_when_confirm_pending() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![make_container("abc123", "nginx", "running")]);
    state.confirm_action = Some((
        crate::containers::ContainerAction::Stop,
        "nginx".to_string(),
        "abc123".to_string(),
    ));
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('x')), &tx);
    let state = app.container_state.as_ref().unwrap();
    let (action, name, _id) = state.confirm_action.as_ref().unwrap();
    assert_eq!(
        *action,
        crate::containers::ContainerAction::Stop,
        "confirm_action should remain the original Stop"
    );
    assert_eq!(name, "nginx");
}

#[test]
fn test_containers_r_blocked_when_confirm_pending() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![make_container("abc123", "nginx", "running")]);
    state.confirm_action = Some((
        crate::containers::ContainerAction::Stop,
        "nginx".to_string(),
        "abc123".to_string(),
    ));
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('r')), &tx);
    let state = app.container_state.as_ref().unwrap();
    let (action, name, _id) = state.confirm_action.as_ref().unwrap();
    assert_eq!(
        *action,
        crate::containers::ContainerAction::Stop,
        "confirm_action should remain the original Stop, not change to Restart"
    );
    assert_eq!(name, "nginx");
}

// --- Help key (?) tests for all overlay screens ---

#[test]
fn test_file_browser_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::FileBrowser {
        alias: "web".to_string(),
    };
    app.file_browser = Some(crate::file_browser::FileBrowserState {
        alias: "web".to_string(),
        askpass: None,
        active_pane: crate::file_browser::BrowserPane::Local,
        local_path: std::path::PathBuf::from("/tmp"),
        local_entries: Vec::new(),
        local_list_state: ratatui::widgets::ListState::default(),
        local_selected: std::collections::HashSet::new(),
        local_error: None,
        remote_path: "/home".to_string(),
        remote_entries: Vec::new(),
        remote_list_state: ratatui::widgets::ListState::default(),
        remote_selected: std::collections::HashSet::new(),
        remote_error: None,
        remote_loading: false,
        show_hidden: false,
        sort: crate::file_browser::BrowserSort::Name,
        confirm_copy: None,
        transferring: None,
        transfer_error: None,
        connection_recorded: false,
    });
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::FileBrowser { .. }));
        }
        other => panic!("Expected Help screen, got {:?}", other),
    }
}

#[test]
fn test_file_browser_help_esc_returns() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::FileBrowser {
            alias: "web".to_string(),
        }),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::FileBrowser { .. }));
}

#[test]
fn test_snippet_picker_question_opens_help() {
    let mut app = make_snippet_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::SnippetPicker { .. }));
        }
        other => panic!("Expected Help screen, got {:?}", other),
    }
}

#[test]
fn test_snippet_picker_help_esc_returns() {
    let mut app = make_snippet_app();
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::SnippetPicker {
            target_aliases: vec!["myserver".to_string()],
        }),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::SnippetPicker { .. }));
}

#[test]
fn test_snippet_output_question_opens_help() {
    let mut app = make_snippet_app();
    // First enter snippet output by pressing Enter
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(matches!(app.screen, Screen::SnippetOutput { .. }));

    // Now press ? to open help
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::SnippetOutput { .. }));
        }
        other => panic!("Expected Help screen, got {:?}", other),
    }
}

#[test]
fn test_snippet_output_help_esc_returns() {
    let mut app = make_snippet_app();
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::SnippetOutput {
            snippet_name: "check-disk".to_string(),
            target_aliases: vec!["myserver".to_string()],
        }),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::SnippetOutput { .. }));
}

#[test]
fn test_containers_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    app.container_state = Some(make_container_state("web", vec![]));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::Containers { .. }));
        }
        other => panic!("Expected Help screen, got {:?}", other),
    }
}

#[test]
fn test_containers_help_esc_returns() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::Containers {
            alias: "web".to_string(),
        }),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::Containers { .. }));
}

#[test]
fn test_tunnel_list_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::TunnelList {
        alias: "web".to_string(),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::TunnelList { .. }));
        }
        other => panic!("Expected Help screen, got {:?}", other),
    }
}

#[test]
fn test_tunnel_list_help_esc_returns() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::TunnelList {
            alias: "web".to_string(),
        }),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::TunnelList { .. }));
}

// --- Direct ? from HostList ---

#[test]
fn test_host_list_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::HostList));
        }
        other => panic!("Expected Help screen, got {:?}", other),
    }
}

// --- ? guard bypass tests ---

#[test]
fn test_tunnel_delete_confirmation_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::TunnelList {
        alias: "web".to_string(),
    };
    app.tunnels.pending_delete = Some(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::TunnelList { .. }));
        }
        other => panic!("Expected Help screen, got {:?}", other),
    }
    assert_eq!(
        app.tunnels.pending_delete,
        Some(0),
        "pending_tunnel_delete should be preserved"
    );
}

#[test]
fn test_container_confirm_action_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Containers {
        alias: "web".to_string(),
    };
    let mut state = make_container_state("web", vec![make_container("abc123", "nginx", "running")]);
    state.confirm_action = Some((
        crate::containers::ContainerAction::Stop,
        "nginx".to_string(),
        "abc123".to_string(),
    ));
    app.container_state = Some(state);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::Containers { .. }));
        }
        other => panic!("Expected Help screen, got {:?}", other),
    }
}

#[test]
fn test_snippet_picker_pending_delete_question_opens_help() {
    let mut app = make_snippet_app();
    app.snippets.pending_delete = Some(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::SnippetPicker { .. }));
        }
        other => panic!("Expected Help screen, got {:?}", other),
    }
}

// --- Help scroll tests ---

#[test]
fn test_help_j_increments_scroll() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::HostList),
    };
    app.ui.help_scroll = 0;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    assert_eq!(app.ui.help_scroll, 1);
}

#[test]
fn test_help_k_does_not_underflow() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::HostList),
    };
    app.ui.help_scroll = 0;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('k')), &tx);
    assert_eq!(app.ui.help_scroll, 0);
}

#[test]
fn test_help_page_down_increments_by_ten() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::HostList),
    };
    app.ui.help_scroll = 0;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::PageDown), &tx);
    assert_eq!(app.ui.help_scroll, 10);
}

#[test]
fn test_help_page_up_does_not_underflow() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::HostList),
    };
    app.ui.help_scroll = 0;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::PageUp), &tx);
    assert_eq!(app.ui.help_scroll, 0);
}

#[test]
fn test_help_scroll_reset_on_close() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::HostList),
    };
    app.ui.help_scroll = 7;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert_eq!(app.ui.help_scroll, 0);
    assert!(matches!(app.screen, Screen::HostList));
}

// --- Help close via q and ? ---

#[test]
fn test_help_q_closes_and_returns() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::TunnelList {
            alias: "web".to_string(),
        }),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('q')), &tx);
    assert!(matches!(app.screen, Screen::TunnelList { .. }));
    assert_eq!(app.ui.help_scroll, 0);
}

#[test]
fn test_help_question_again_closes_and_returns() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::Containers {
            alias: "web".to_string(),
        }),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    assert!(matches!(app.screen, Screen::Containers { .. }));
    assert_eq!(app.ui.help_scroll, 0);
}

// --- Return screen field preservation ---

#[test]
fn test_file_browser_help_return_preserves_alias() {
    let mut app = make_app("Host myserver\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::FileBrowser {
            alias: "myserver".to_string(),
        }),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    match &app.screen {
        Screen::FileBrowser { alias } => {
            assert_eq!(alias, "myserver");
        }
        other => panic!("Expected FileBrowser, got {:?}", other),
    }
}

#[test]
fn test_snippet_output_help_return_preserves_fields() {
    let mut app = make_app("Host a\n  HostName 1.2.3.4\nHost b\n  HostName 5.6.7.8\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::SnippetOutput {
            snippet_name: "check-disk".to_string(),
            target_aliases: vec!["a".to_string(), "b".to_string()],
        }),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    match &app.screen {
        Screen::SnippetOutput {
            snippet_name,
            target_aliases,
        } => {
            assert_eq!(snippet_name, "check-disk");
            assert_eq!(target_aliases, &vec!["a".to_string(), "b".to_string()]);
        }
        other => panic!("Expected SnippetOutput, got {:?}", other),
    }
}

#[test]
fn test_tunnel_list_help_return_preserves_alias() {
    let mut app = make_app("Host myserver\n  HostName 1.2.3.4\n");
    app.screen = Screen::Help {
        return_screen: Box::new(Screen::TunnelList {
            alias: "myserver".to_string(),
        }),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    match &app.screen {
        Screen::TunnelList { alias } => {
            assert_eq!(alias, "myserver");
        }
        other => panic!("Expected TunnelList, got {:?}", other),
    }
}

// --- Non-help screens ignore ? ---

#[test]
fn test_confirm_delete_question_does_not_open_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::ConfirmDelete {
        alias: "web".to_string(),
    };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    assert!(
        matches!(app.screen, Screen::ConfirmDelete { .. }),
        "Expected ConfirmDelete screen, got {:?}",
        app.screen
    );
}

#[test]
fn test_tag_picker_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::TagPicker;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::TagPicker));
        }
        other => panic!("expected Help, got {:?}", std::mem::discriminant(other)),
    }
}

#[test]
fn test_key_list_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::KeyList;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::KeyList));
        }
        other => panic!("expected Help, got {:?}", std::mem::discriminant(other)),
    }
}

#[test]
fn test_key_detail_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::KeyDetail { index: 0 };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::KeyDetail { .. }));
        }
        other => panic!("expected Help, got {:?}", std::mem::discriminant(other)),
    }
}

#[test]
fn test_host_detail_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::HostDetail { index: 0 };
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::HostDetail { .. }));
        }
        other => panic!("expected Help, got {:?}", std::mem::discriminant(other)),
    }
}

#[test]
fn test_providers_question_opens_help() {
    let mut app = make_app("Host web\n  HostName 1.2.3.4\n");
    app.screen = Screen::Providers;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('?')), &tx);
    match &app.screen {
        Screen::Help { return_screen } => {
            assert!(matches!(**return_screen, Screen::Providers));
        }
        other => panic!("expected Help, got {:?}", std::mem::discriminant(other)),
    }
}

// --- g-key GroupBy cycle ---

#[test]
fn g_key_none_to_provider() {
    let mut app = make_app("Host web1\n  HostName 1.2.3.4\n  # purple:provider digitalocean:1\n");
    assert_eq!(app.hosts_state.group_by, crate::app::GroupBy::None);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('g')), &tx);
    assert_eq!(app.hosts_state.group_by, crate::app::GroupBy::Provider);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn g_key_provider_to_tag_mode_when_tags_exist() {
    let content = "\
Host web1
  HostName 1.1.1.1
  # purple:tags production
";
    let mut app = make_app(content);
    app.hosts_state.group_by = crate::app::GroupBy::Provider;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('g')), &tx);
    assert!(
        matches!(app.hosts_state.group_by, crate::app::GroupBy::Tag(_)),
        "expected Tag mode, got {:?}",
        app.hosts_state.group_by
    );
    assert!(
        matches!(app.screen, Screen::HostList),
        "should stay on HostList, not open picker"
    );
}

#[test]
fn g_key_provider_to_none_when_no_tags() {
    let content = "\
Host web1
  HostName 1.1.1.1
";
    let mut app = make_app(content);
    app.hosts_state.group_by = crate::app::GroupBy::Provider;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('g')), &tx);
    assert_eq!(app.hosts_state.group_by, crate::app::GroupBy::None);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn g_key_tag_to_none() {
    let content = "\
Host web1
  HostName 1.1.1.1
  # purple:tags production
";
    let mut app = make_app(content);
    app.hosts_state.group_by = crate::app::GroupBy::Tag("production".to_string());
    app.apply_sort();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('g')), &tx);
    assert_eq!(app.hosts_state.group_by, crate::app::GroupBy::None);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(
        app.hosts_state
            .display_list
            .iter()
            .all(|item| matches!(item, crate::app::HostListItem::Host { .. }))
    );
}

#[test]
fn g_key_full_cycle_with_tags() {
    // None → Provider → Tag → None
    let content = "\
Host web1
  HostName 1.1.1.1
  # purple:tags production
";
    let mut app = make_app(content);
    assert_eq!(app.hosts_state.group_by, crate::app::GroupBy::None);

    let (tx, _rx) = mpsc::channel();

    // None → Provider
    let _ = handle_key_event(&mut app, key(KeyCode::Char('g')), &tx);
    assert_eq!(app.hosts_state.group_by, crate::app::GroupBy::Provider);

    // Provider → Tag (direct, no picker)
    let _ = handle_key_event(&mut app, key(KeyCode::Char('g')), &tx);
    assert!(
        matches!(app.hosts_state.group_by, crate::app::GroupBy::Tag(_)),
        "expected Tag mode, got {:?}",
        app.hosts_state.group_by
    );
    assert!(matches!(app.screen, Screen::HostList));

    // Tag → None
    let _ = handle_key_event(&mut app, key(KeyCode::Char('g')), &tx);
    assert_eq!(app.hosts_state.group_by, crate::app::GroupBy::None);
}

#[test]
fn g_key_tag_to_none_empty_hosts() {
    let (tx, _rx) = std::sync::mpsc::channel();
    let mut app = make_app("");
    app.hosts_state.group_by = crate::app::GroupBy::Tag("production".to_string());

    let key = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
    let _ = handle_key_event(&mut app, key, &tx);

    assert_eq!(app.hosts_state.group_by, crate::app::GroupBy::None);
    assert!(matches!(app.screen, Screen::HostList));
}

// =========================================================================
// Group header collapse tests
// =========================================================================

#[test]
fn test_enter_on_group_header_does_not_connect() {
    // Enter on a group header should not crash or connect — group headers are
    // no longer collapsible. Navigation happens via Tab (group_filter).
    let mut app = make_app(
        "Host web1\n  HostName 1.1.1.1\n  # purple:tags production\n\nHost web2\n  HostName 2.2.2.2\n  # purple:tags staging\n",
    );
    app.hosts_state.group_by = crate::app::GroupBy::Tag("production".to_string());
    app.hosts_state.sort_mode = crate::app::SortMode::AlphaAlias;
    app.apply_sort();

    // Find the group header position
    let header_pos = app
        .hosts_state
        .display_list
        .iter()
        .position(
            |item| matches!(item, crate::app::HostListItem::GroupHeader(t) if t == "production"),
        )
        .expect("should have a production group header");
    app.ui.list_state.select(Some(header_pos));

    // Press Enter — should not panic and group_filter should remain None
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();

    assert!(
        app.hosts_state.group_filter.is_none(),
        "group_filter should not be set by Enter on header"
    );
}

// =========================================================================
// Ctrl+A select all tests
// =========================================================================

#[test]
fn test_ctrl_a_selects_all_visible_hosts() {
    let mut app = make_app(
        "Host web1\n  HostName 1.1.1.1\n\nHost web2\n  HostName 2.2.2.2\n\nHost web3\n  HostName 3.3.3.3\n",
    );
    app.apply_sort();
    assert!(app.hosts_state.multi_select.is_empty());

    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, ctrl_key('a'), &tx).unwrap();

    // All 3 hosts should be selected
    assert_eq!(app.hosts_state.multi_select.len(), 3);

    // Press Ctrl+A again to deselect all
    handle_key_event(&mut app, ctrl_key('a'), &tx).unwrap();
    assert!(app.hosts_state.multi_select.is_empty());
}

#[test]
fn test_ctrl_a_in_search_mode_selects_filtered() {
    let mut app = make_app(
        "Host prod-web\n  HostName 1.1.1.1\n\nHost prod-db\n  HostName 2.2.2.2\n\nHost staging-app\n  HostName 3.3.3.3\n",
    );
    app.apply_sort();

    // Enter search mode and filter to "prod"
    app.search.query = Some("prod".to_string());
    app.apply_filter();
    assert_eq!(app.search.filtered_indices.len(), 2);
    assert!(app.hosts_state.multi_select.is_empty());

    // Ctrl+A should select only the 2 filtered hosts
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, ctrl_key('a'), &tx).unwrap();
    assert_eq!(app.hosts_state.multi_select.len(), 2);

    // Press Ctrl+A again to deselect
    handle_key_event(&mut app, ctrl_key('a'), &tx).unwrap();
    assert!(app.hosts_state.multi_select.is_empty());
}

// =========================================================================
// Tab / Shift+Tab top-page navigation tests (HostList screen)
// =========================================================================

#[test]
fn tab_on_host_list_switches_to_tunnels_page() {
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\n");
    assert_eq!(app.top_page, crate::app::TopPage::Hosts);

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);

    assert_eq!(app.top_page, crate::app::TopPage::Tunnels);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn shift_tab_on_host_list_switches_pages() {
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\n");
    assert_eq!(app.top_page, crate::app::TopPage::Hosts);

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(
        &mut app,
        KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
        &tx,
    );

    assert_eq!(app.top_page, crate::app::TopPage::Tunnels);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn tab_twice_returns_to_hosts_page() {
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\n");
    assert_eq!(app.top_page, crate::app::TopPage::Hosts);

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Tab), &tx);

    assert_eq!(app.top_page, crate::app::TopPage::Hosts);
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn g_key_no_longer_affects_top_page() {
    let content = "\
Host aws-web1
  HostName 1.1.1.1
  # purple:provider aws:i-123

Host do-web2
  HostName 2.2.2.2
  # purple:provider digitalocean:abc
";
    let mut app = make_app(content);
    let starting_page = app.top_page;

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('g')), &tx);

    // Grouping toggled internally, top page unchanged.
    assert_eq!(app.top_page, starting_page);
    assert_eq!(app.hosts_state.group_by, crate::app::GroupBy::Provider);
}

#[test]
fn esc_clears_group_filter() {
    let content = "\
Host aws-web1
  HostName 1.1.1.1
  # purple:provider aws:i-123

Host do-web2
  HostName 2.2.2.2
  # purple:provider digitalocean:abc
";
    let mut app = make_app(content);
    app.hosts_state.group_by = crate::app::GroupBy::Provider;
    app.apply_sort();
    let first_group = app.hosts_state.group_tab_order[0].clone();
    app.hosts_state.group_filter = Some(first_group);
    app.apply_sort();
    assert!(app.running);

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);

    assert!(
        app.hosts_state.group_filter.is_none(),
        "Esc with active group_filter should clear it"
    );
    assert!(app.running, "Esc with active filter should NOT quit");
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn esc_does_not_quit_first_press_shows_hint_toast() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    assert!(app.hosts_state.group_filter.is_none());
    assert!(app.hosts_state.multi_select.is_empty());
    assert!(app.running);
    assert!(!app.esc_quit_hint_shown);

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);

    assert!(app.running, "Esc on idle host list must not quit");
    assert!(
        app.esc_quit_hint_shown,
        "first idle Esc must arm the one-shot hint flag"
    );
    let toast = app
        .status_center
        .toast
        .as_ref()
        .expect("first idle Esc must surface a toast");
    assert_eq!(toast.text, crate::messages::ESC_QUIT_HINT);
}

#[test]
fn esc_second_press_after_hint_is_silent_noop() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(
        app.status_center.toast.is_some(),
        "first press should have produced a toast"
    );
    // Simulate the toast having been read and dismissed.
    app.status_center.toast = None;

    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);

    assert!(app.running, "second idle Esc must still not quit");
    assert!(
        app.status_center.toast.is_none(),
        "second idle Esc must stay silent (no repeated toast)"
    );
}

#[test]
fn q_still_quits_after_esc_hint() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(app.running);
    assert!(app.esc_quit_hint_shown);

    let _ = handle_key_event(&mut app, key(KeyCode::Char('q')), &tx);
    assert!(
        !app.running,
        "q must always quit, even after Esc hint shown"
    );
}

#[test]
fn test_p_key_clears_ping_increments_generation() {
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\n");
    // Pre-populate ping status to simulate completed pings
    app.ping.status.insert(
        "web1".to_string(),
        crate::app::PingStatus::Reachable { rtt_ms: 10 },
    );
    app.ping
        .last_checked
        .insert("web1".to_string(), std::time::Instant::now());
    app.ping.filter_down_only = true;
    app.ping.checked_at = Some(std::time::Instant::now());
    assert_eq!(app.ping.generation, 0);

    let (tx, _rx) = std::sync::mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Char('P')), &tx).unwrap();

    assert!(app.ping.status.is_empty());
    assert!(app.ping.last_checked.is_empty());
    assert_eq!(app.ping.generation, 1);
    assert!(!app.ping.filter_down_only);
    assert!(app.ping.checked_at.is_none());
}

#[test]
fn test_bang_key_without_pings_shows_error() {
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\n");
    assert!(app.ping.status.is_empty());
    let (tx, _rx) = std::sync::mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Char('!')), &tx).unwrap();
    assert!(!app.ping.filter_down_only);
    assert!(app.status_center.toast.as_ref().unwrap().is_error());
}

#[test]
fn test_bang_key_toggles_down_only_on() {
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\nHost web2\n  HostName 2.2.2.2\n");
    app.ping
        .status
        .insert("web1".to_string(), crate::app::PingStatus::Unreachable);
    app.ping.status.insert(
        "web2".to_string(),
        crate::app::PingStatus::Reachable { rtt_ms: 10 },
    );
    let (tx, _rx) = std::sync::mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Char('!')), &tx).unwrap();
    assert!(app.ping.filter_down_only);
    assert!(app.search.query.is_some());
    // Only web1 (Unreachable) should be in filtered results
    assert_eq!(app.search.filtered_indices.len(), 1);
}

#[test]
fn test_bang_key_toggles_down_only_off() {
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\nHost web2\n  HostName 2.2.2.2\n");
    app.ping
        .status
        .insert("web1".to_string(), crate::app::PingStatus::Unreachable);
    app.ping.status.insert(
        "web2".to_string(),
        crate::app::PingStatus::Reachable { rtt_ms: 10 },
    );
    let (tx, _rx) = std::sync::mpsc::channel();
    // Toggle on
    handle_key_event(&mut app, key(KeyCode::Char('!')), &tx).unwrap();
    assert!(app.ping.filter_down_only);
    // Toggle off
    handle_key_event(&mut app, key(KeyCode::Char('!')), &tx).unwrap();
    assert!(!app.ping.filter_down_only);
    assert!(app.search.query.is_none());
}

#[test]
fn refresh_selected_if_stale_skips_when_auto_ping_off() {
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\n");
    app.ping.auto_ping = false;
    let (tx, _rx) = std::sync::mpsc::channel();
    super::ping::refresh_selected_if_stale(&mut app, &tx);
    assert!(app.ping.status.is_empty());
}

#[test]
fn refresh_selected_if_stale_skips_fresh_host() {
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\n");
    app.ping.auto_ping = true;
    app.ping.status.insert(
        "web1".to_string(),
        crate::app::PingStatus::Reachable { rtt_ms: 10 },
    );
    app.ping
        .last_checked
        .insert("web1".to_string(), std::time::Instant::now());
    let (tx, _rx) = std::sync::mpsc::channel();
    super::ping::refresh_selected_if_stale(&mut app, &tx);
    // Status remains Reachable, not flipped to Checking
    assert!(matches!(
        app.ping.status.get("web1"),
        Some(crate::app::PingStatus::Reachable { .. })
    ));
}

#[test]
fn refresh_selected_if_stale_marks_checking_for_stale_host() {
    // Loopback + high port: TCP RST returns immediately so the spawned
    // probe thread exits fast. The PingResult goes to a dropped rx, so
    // status stays Checking — the only behavior we assert.
    let mut app = make_app("Host web1\n  HostName 127.0.0.1\n  Port 59999\n");
    app.ping.auto_ping = true;
    app.ping
        .status
        .insert("web1".to_string(), crate::app::PingStatus::Unreachable);
    let stale = std::time::Instant::now()
        - crate::app::ping::STALE_REFRESH_AFTER
        - std::time::Duration::from_secs(1);
    app.ping.last_checked.insert("web1".to_string(), stale);
    let (tx, _rx) = std::sync::mpsc::channel();
    super::ping::refresh_selected_if_stale(&mut app, &tx);
    assert!(matches!(
        app.ping.status.get("web1"),
        Some(crate::app::PingStatus::Checking)
    ));
}

#[test]
fn refresh_selected_if_stale_skips_host_already_checking() {
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\n");
    app.ping.auto_ping = true;
    app.ping
        .status
        .insert("web1".to_string(), crate::app::PingStatus::Checking);
    let (tx, rx) = std::sync::mpsc::channel::<crate::event::AppEvent>();
    super::ping::refresh_selected_if_stale(&mut app, &tx);
    // Guard fires before ping_host spawn: no event produced.
    assert!(rx.try_recv().is_err());
    assert!(matches!(
        app.ping.status.get("web1"),
        Some(crate::app::PingStatus::Checking)
    ));
}

#[test]
fn refresh_selected_if_stale_skips_host_with_empty_hostname() {
    let mut app = make_app("Host web1\n");
    app.ping.auto_ping = true;
    let (tx, _rx) = std::sync::mpsc::channel();
    super::ping::refresh_selected_if_stale(&mut app, &tx);
    assert!(app.ping.status.is_empty());
}

#[test]
fn refresh_selected_if_stale_probes_bastion_for_proxyjump_host() {
    let mut app = make_app(
        "Host bastion\n  HostName 127.0.0.1\n  Port 59999\nHost web1\n  HostName 10.0.0.1\n  ProxyJump bastion\n",
    );
    app.ping.auto_ping = true;
    // Select web1 (second selectable item).
    app.select_next();
    assert_eq!(app.selected_host().map(|h| h.alias.as_str()), Some("web1"));
    let (tx, _rx) = std::sync::mpsc::channel();
    super::ping::refresh_selected_if_stale(&mut app, &tx);
    // Bastion (not web1) gets marked Checking — the probe targets the bastion.
    assert!(matches!(
        app.ping.status.get("bastion"),
        Some(crate::app::PingStatus::Checking)
    ));
    assert!(!app.ping.status.contains_key("web1"));
}

#[test]
fn refresh_selected_if_stale_skips_proxyjump_when_bastion_missing() {
    let mut app = make_app("Host web1\n  HostName 10.0.0.1\n  ProxyJump ghost\n");
    app.ping.auto_ping = true;
    let (tx, _rx) = std::sync::mpsc::channel();
    super::ping::refresh_selected_if_stale(&mut app, &tx);
    assert!(app.ping.status.is_empty());
}

/// Stage two hosts where web1 has a stale timestamp and web2 does not. After
/// pressing the navigation key the new selection's stale state should drive
/// a single fresh probe — the previously-stale entry must NOT be re-probed.
fn stage_two_hosts_first_stale() -> App {
    // 127.0.0.1:59999 is the loopback-RST trick used by the existing stale
    // tests: probe spawns and exits fast, status stays Checking.
    let mut app = make_app(
        "Host web1\n  HostName 127.0.0.1\n  Port 59999\nHost web2\n  HostName 127.0.0.1\n  Port 59999\n",
    );
    app.ping.auto_ping = true;
    let stale = std::time::Instant::now()
        - crate::app::ping::STALE_REFRESH_AFTER
        - std::time::Duration::from_secs(1);
    app.ping.status.insert(
        "web1".to_string(),
        crate::app::PingStatus::Reachable { rtt_ms: 5 },
    );
    app.ping.last_checked.insert("web1".to_string(), stale);
    // web2 starts with no recorded status -> stale on selection.
    app
}

#[test]
fn host_list_down_triggers_stale_refresh_for_new_selection() {
    let mut app = stage_two_hosts_first_stale();
    // Initial selection is web1; press Down to land on web2 (stale because no record).
    let (tx, _rx) = std::sync::mpsc::channel();
    super::host_list::handle_host_list(&mut app, key(KeyCode::Down), &tx);
    assert_eq!(app.selected_host().map(|h| h.alias.as_str()), Some("web2"));
    assert!(matches!(
        app.ping.status.get("web2"),
        Some(crate::app::PingStatus::Checking)
    ));
}

#[test]
fn host_list_j_triggers_stale_refresh_for_new_selection() {
    let mut app = stage_two_hosts_first_stale();
    let (tx, _rx) = std::sync::mpsc::channel();
    super::host_list::handle_host_list(&mut app, key(KeyCode::Char('j')), &tx);
    assert_eq!(app.selected_host().map(|h| h.alias.as_str()), Some("web2"));
    assert!(matches!(
        app.ping.status.get("web2"),
        Some(crate::app::PingStatus::Checking)
    ));
}

#[test]
fn host_list_up_triggers_stale_refresh_when_selection_is_stale() {
    // Land on web2 first, then press k to go back to web1 (which is stale).
    let mut app = stage_two_hosts_first_stale();
    let (tx, _rx) = std::sync::mpsc::channel();
    super::host_list::handle_host_list(&mut app, key(KeyCode::Down), &tx);
    // Clear web2's checking marker so we can observe a fresh transition.
    app.ping.status.remove("web2");
    super::host_list::handle_host_list(&mut app, key(KeyCode::Char('k')), &tx);
    assert_eq!(app.selected_host().map(|h| h.alias.as_str()), Some("web1"));
    // web1 was Reachable + stale; after k it should now be Checking.
    assert!(matches!(
        app.ping.status.get("web1"),
        Some(crate::app::PingStatus::Checking)
    ));
}

#[test]
fn host_list_pagedown_triggers_stale_refresh_for_new_selection() {
    let mut app = stage_two_hosts_first_stale();
    let (tx, _rx) = std::sync::mpsc::channel();
    super::host_list::handle_host_list(&mut app, key(KeyCode::PageDown), &tx);
    assert_eq!(app.selected_host().map(|h| h.alias.as_str()), Some("web2"));
    assert!(matches!(
        app.ping.status.get("web2"),
        Some(crate::app::PingStatus::Checking)
    ));
}

#[test]
fn host_list_navigation_does_not_refire_when_auto_ping_off() {
    let mut app = stage_two_hosts_first_stale();
    app.ping.auto_ping = false;
    let (tx, _rx) = std::sync::mpsc::channel();
    super::host_list::handle_host_list(&mut app, key(KeyCode::Down), &tx);
    assert_eq!(app.selected_host().map(|h| h.alias.as_str()), Some("web2"));
    // auto_ping=off short-circuits the refresh: web2 should remain unmarked.
    assert!(!app.ping.status.contains_key("web2"));
}

#[test]
fn handle_ping_result_records_last_checked_for_alias_and_dependents() {
    let mut app = make_app(
        "Host bastion\n  HostName 1.1.1.1\nHost web1\n  HostName 10.0.0.1\n  ProxyJump bastion\n",
    );
    app.ping.generation = 0;
    let before = std::time::Instant::now();
    super::event_loop::handle_ping_result(&mut app, "bastion".to_string(), Some(15), 0);
    let after = std::time::Instant::now();
    let bastion_ts = app.ping.last_checked["bastion"];
    let web1_ts = app.ping.last_checked["web1"];
    assert!(bastion_ts >= before && bastion_ts <= after);
    assert!(web1_ts >= before && web1_ts <= after);
    // Dependent inherits the bastion's status, not just its timestamp.
    assert!(matches!(
        app.ping.status.get("web1"),
        Some(crate::app::PingStatus::Reachable { .. })
    ));
}

#[test]
fn tick_does_not_clear_ping_status_after_sixty_seconds() {
    // Contract guard: removing the 60s expiry must not be silently re-added.
    let mut app = make_app("Host web1\n  HostName 1.1.1.1\n");
    app.ping.status.insert(
        "web1".to_string(),
        crate::app::PingStatus::Reachable { rtt_ms: 10 },
    );
    let old = std::time::Instant::now() - std::time::Duration::from_secs(120);
    app.ping.last_checked.insert("web1".to_string(), old);
    app.ping.checked_at = Some(old);
    let mut anim = crate::animation::AnimationState::default();
    let mut last_check = std::time::Instant::now();
    super::event_loop::handle_tick(&mut app, &mut anim, false, &mut last_check);
    assert!(
        app.ping.status.contains_key("web1"),
        "tick must not expire ping status"
    );
}

// ─── Progressive disclosure: host form ─────────────────────────

#[test]
fn host_form_new_starts_collapsed() {
    let form = HostForm::new();
    assert!(!form.expanded);
}

#[test]
fn host_form_from_entry_starts_expanded() {
    let dir = tempfile::tempdir().expect("tempdir");
    let config = SshConfigFile {
        elements: SshConfigFile::parse_content("Host test\n  HostName test.com\n"),
        path: dir.path().join("test_config"),
        crlf: false,
        bom: false,
    };
    let entries = config.host_entries();
    let form = HostForm::from_entry(&entries[0], Default::default());
    assert!(form.expanded);
}

#[test]
fn host_form_new_pattern_starts_expanded() {
    let form = HostForm::new_pattern();
    assert!(form.expanded);
}

#[test]
fn host_form_tab_from_alias_stays_collapsed() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert_eq!(app.forms.host.focused_field, FormField::Hostname);
    assert!(!app.forms.host.expanded);
}

#[test]
fn host_form_tab_from_hostname_expands() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.focused_field = FormField::Hostname;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert!(app.forms.host.expanded);
    assert_eq!(app.forms.host.focused_field, FormField::User);
}

#[test]
fn host_form_collapsed_backtab_wraps() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(
        &mut app,
        KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
        &tx,
    )
    .unwrap();
    assert_eq!(app.forms.host.focused_field, FormField::Hostname);
    assert!(!app.forms.host.expanded);
}

#[test]
fn host_form_expanded_does_not_trigger_dirty() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "test".to_string();
    app.screen = Screen::AddHost;
    app.capture_form_baseline();
    app.forms.host.expanded = true;
    assert!(!app.host_form_is_dirty());
}

// ─── Progressive disclosure: provider form ─────────────────────

#[test]
fn provider_form_new_starts_collapsed() {
    let form = ProviderFormFields::new();
    assert!(!form.expanded);
}

#[test]
fn provider_required_fields_aws() {
    let required = crate::app::ProviderFormField::required_fields_for("aws");
    assert!(required.contains(&crate::app::ProviderFormField::Token));
    assert!(required.contains(&crate::app::ProviderFormField::Profile));
    assert!(required.contains(&crate::app::ProviderFormField::Regions));
}

#[test]
fn provider_required_fields_proxmox() {
    let required = crate::app::ProviderFormField::required_fields_for("proxmox");
    assert!(required.contains(&crate::app::ProviderFormField::Url));
    assert!(required.contains(&crate::app::ProviderFormField::Token));
    // AliasPrefix is optional
    assert!(!required.contains(&crate::app::ProviderFormField::AliasPrefix));
}

#[test]
fn provider_optional_fields_are_complement() {
    for provider in &[
        "aws",
        "digitalocean",
        "proxmox",
        "gcp",
        "azure",
        "oracle",
        "ovh",
        "scaleway",
    ] {
        let all = crate::app::ProviderFormField::fields_for(provider);
        let required = crate::app::ProviderFormField::required_fields_for(provider);
        let optional = crate::app::ProviderFormField::optional_fields_for(provider);
        assert_eq!(
            required.len() + optional.len(),
            all.len(),
            "Field count mismatch for provider {}",
            provider
        );
    }
}

#[test]
fn provider_mandatory_fields_aws_token_and_profile() {
    use crate::app::ProviderFormField;
    assert!(
        ProviderFormField::is_mandatory_field(ProviderFormField::Token, "aws"),
        "AWS Token should be mandatory (asterisked)"
    );
    assert!(
        ProviderFormField::is_mandatory_field(ProviderFormField::Profile, "aws"),
        "AWS Profile should be mandatory (asterisked)"
    );
}

#[test]
fn provider_mandatory_fields_tailscale_token_optional() {
    use crate::app::ProviderFormField;
    assert!(
        !ProviderFormField::is_mandatory_field(ProviderFormField::Token, "tailscale"),
        "Tailscale Token should not be mandatory (empty = CLI mode)"
    );
}

#[test]
fn provider_mandatory_fields_ovh_regions() {
    use crate::app::ProviderFormField;
    assert!(
        ProviderFormField::is_mandatory_field(ProviderFormField::Regions, "ovh"),
        "OVH Regions (Endpoint) should be mandatory"
    );
}

#[test]
fn provider_required_fields_prefix_of_all_fields() {
    use crate::app::ProviderFormField;
    for provider in &[
        "aws",
        "digitalocean",
        "proxmox",
        "gcp",
        "azure",
        "oracle",
        "ovh",
        "scaleway",
        "tailscale",
        "transip",
        "leaseweb",
        "i3d",
    ] {
        let all = ProviderFormField::fields_for(provider);
        let required = ProviderFormField::required_fields_for(provider);
        assert_eq!(
            &all[..required.len()],
            required.as_slice(),
            "Required fields must be a prefix of fields_for() for {}",
            provider
        );
    }
}

#[test]
fn provider_form_expanded_does_not_trigger_dirty() {
    let mut app = make_app("");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
    };
    app.providers.form = ProviderFormFields::new();
    app.providers.form.token = "tok".to_string();
    app.capture_provider_form_baseline();
    app.providers.form.expanded = true;
    assert!(!app.provider_form_is_dirty());
}

// ─── Host form collapsed Enter-saves ───────────────────────────

#[test]
fn host_form_collapsed_enter_saves() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "myhost".to_string();
    app.forms.host.hostname = "myhost.local".to_string();
    app.forms.host.focused_field = FormField::Hostname;
    app.screen = Screen::AddHost;
    app.capture_form_mtime();
    app.capture_form_baseline();
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert!(
        matches!(app.screen, Screen::HostList),
        "Expected HostList after save, got {:?}",
        app.screen
    );
}

// ─── Provider form progressive disclosure navigation ───────────

#[test]
fn provider_form_tab_from_last_required_expands() {
    // DigitalOcean has one required field: Token
    let mut app = make_app("");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("digitalocean"),
    };
    app.providers.form = ProviderFormFields::new();
    app.providers.form.token = "tok".to_string();
    // Token is the only required field for DO
    app.providers.form.focused_field = crate::app::ProviderFormField::Token;
    app.providers.form.expanded = false;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert!(app.providers.form.expanded);
    // First optional field for DO is AliasPrefix
    assert_eq!(
        app.providers.form.focused_field,
        crate::app::ProviderFormField::AliasPrefix
    );
}

#[test]
fn provider_form_collapsed_backtab_wraps() {
    // AWS has 3 required fields: Token, Profile, Regions
    let mut app = make_app("");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("aws"),
    };
    app.providers.form = ProviderFormFields::new();
    app.providers.form.focused_field = crate::app::ProviderFormField::Token;
    app.providers.form.expanded = false;
    let tx = mpsc::channel().0;
    handle_key_event(
        &mut app,
        KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
        &tx,
    )
    .unwrap();
    // Token is first required; BackTab wraps to last required (Regions)
    assert_eq!(
        app.providers.form.focused_field,
        crate::app::ProviderFormField::Regions
    );
    assert!(!app.providers.form.expanded);
}

#[test]
fn provider_form_tab_within_collapsed_required() {
    // AWS: Token -> Profile -> Regions (all required)
    let mut app = make_app("");
    app.screen = Screen::ProviderForm {
        id: crate::providers::config::ProviderConfigId::bare("aws"),
    };
    app.providers.form = ProviderFormFields::new();
    app.providers.form.focused_field = crate::app::ProviderFormField::Token;
    app.providers.form.expanded = false;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    // Token -> Profile (mid-required, should NOT expand)
    assert_eq!(
        app.providers.form.focused_field,
        crate::app::ProviderFormField::Profile
    );
    assert!(!app.providers.form.expanded);
}

// --- theme_at_index tests ---

#[test]
fn theme_at_index_returns_builtin() {
    let builtins = crate::ui::theme::ThemeDef::builtins();
    let custom: Vec<crate::ui::theme::ThemeDef> = vec![];
    let result = super::theme_picker::theme_at_index(0, &builtins, &custom, None);
    assert!(result.is_some());
    assert_eq!(result.unwrap().name, "Purple");
}

#[test]
fn theme_at_index_returns_none_for_divider() {
    let builtins = crate::ui::theme::ThemeDef::builtins();
    let custom = vec![crate::ui::theme::ThemeDef::purple()];
    let divider_idx = Some(builtins.len());
    let result =
        super::theme_picker::theme_at_index(builtins.len(), &builtins, &custom, divider_idx);
    assert!(result.is_none());
}

#[test]
fn theme_at_index_returns_custom_after_divider() {
    let builtins = crate::ui::theme::ThemeDef::builtins();
    let mut custom_theme = crate::ui::theme::ThemeDef::purple();
    custom_theme.name = "My Custom".to_string();
    let custom = vec![custom_theme];
    let divider_idx = Some(builtins.len());
    let result =
        super::theme_picker::theme_at_index(builtins.len() + 1, &builtins, &custom, divider_idx);
    assert!(result.is_some());
    assert_eq!(result.unwrap().name, "My Custom");
}

#[test]
fn theme_at_index_out_of_bounds_returns_none() {
    let builtins = crate::ui::theme::ThemeDef::builtins();
    let custom: Vec<crate::ui::theme::ThemeDef> = vec![];
    let result = super::theme_picker::theme_at_index(999, &builtins, &custom, None);
    assert!(result.is_none());
}

#[test]
fn remove_in_flight_removes_single_alias() {
    use std::collections::HashSet;
    use std::sync::{Arc, Mutex};
    let set = Arc::new(Mutex::new(HashSet::new()));
    {
        let mut g = set.lock().unwrap();
        g.insert("host-a".to_string());
        g.insert("host-b".to_string());
        g.insert("host-c".to_string());
    }
    super::confirm::remove_in_flight(&set, "host-b");
    let g = set.lock().unwrap();
    assert!(g.contains("host-a"));
    assert!(!g.contains("host-b"));
    assert!(g.contains("host-c"));
}

#[test]
fn remove_in_flight_preserves_other_aliases_on_poison() {
    // Regression: an earlier implementation cleared the whole set on
    // mutex poison, making every in-flight alias simultaneously eligible
    // for re-signing. Verify we only remove the target alias.
    use std::collections::HashSet;
    use std::sync::{Arc, Mutex};
    let set: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
    {
        let mut g = set.lock().unwrap();
        g.insert("host-a".to_string());
        g.insert("host-b".to_string());
        g.insert("host-c".to_string());
    }
    // Poison the mutex by panicking while holding the lock.
    let set_clone = set.clone();
    let _ = std::thread::spawn(move || {
        let _g = set_clone.lock().unwrap();
        panic!("intentional poison for test");
    })
    .join();
    assert!(set.is_poisoned());

    super::confirm::remove_in_flight(&set, "host-b");
    // After recovery the set must still contain the other aliases.
    let g = match set.lock() {
        Ok(g) => g,
        Err(p) => p.into_inner(),
    };
    assert!(g.contains("host-a"), "host-a must survive poison recovery");
    assert!(!g.contains("host-b"), "host-b must be removed");
    assert!(g.contains("host-c"), "host-c must survive poison recovery");
}

#[test]
fn vault_addr_missing_reports_when_env_and_host_both_empty() {
    assert!(super::vault_addr_missing(&[None], None));
}

#[test]
fn vault_addr_missing_reports_when_env_is_invalid_and_host_empty() {
    // Whitespace-only is rejected by is_valid_vault_addr; treat as unset.
    assert!(super::vault_addr_missing(&[None], Some("  ")));
}

#[test]
fn vault_addr_missing_false_when_env_is_set() {
    assert!(!super::vault_addr_missing(
        &[None, None],
        Some("https://vault.example.com:8200")
    ));
}

#[test]
fn vault_addr_missing_false_when_every_host_has_addr() {
    assert!(!super::vault_addr_missing(
        &[Some("https://a"), Some("https://b")],
        None
    ));
}

#[test]
fn vault_addr_missing_false_when_mixed_hosts_and_env_empty() {
    // Some hosts have an addr, some don't. Only block when ALL lack an addr.
    assert!(!super::vault_addr_missing(&[Some("https://a"), None], None));
}

#[test]
fn vault_addr_missing_false_when_no_hosts() {
    // Empty slice: nothing to sign, no prompt needed.
    assert!(!super::vault_addr_missing(&[], None));
}

#[test]
fn vault_addr_missing_true_when_env_is_empty_string() {
    assert!(super::vault_addr_missing(&[None], Some("")));
}

#[test]
fn vault_addr_missing_false_when_mixed_hosts_and_env_valid() {
    assert!(!super::vault_addr_missing(
        &[Some("https://a"), None],
        Some("https://vault.example.com:8200")
    ));
}

#[test]
fn zone_data_for_returns_nonempty_for_known_providers() {
    // zone_data_for falls back to (&[], &[]) + debug_assert for unknown
    // providers, so release builds cannot panic. We only test the happy
    // path here; the unknown-provider fallback is validated by the
    // debug_assert firing in CI if any caller ever passes a typo.
    for provider in ["scaleway", "aws", "gcp", "oracle", "ovh"] {
        let (zones, groups) = super::zone_data_for(provider);
        assert!(
            !zones.is_empty(),
            "zones for {provider} should not be empty"
        );
        assert!(
            !groups.is_empty(),
            "groups for {provider} should not be empty"
        );
    }
}

// --- Jump tests ---

#[test]
fn colon_opens_jump() {
    let mut app = make_app("");
    app.screen = Screen::HostList;
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Char(':')), &tx).unwrap();
    assert!(app.jump.is_some());
}

#[test]
fn jump_esc_closes() {
    let mut app = make_app("");
    app.jump = Some(crate::app::JumpState::default());
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Esc), &tx).unwrap();
    assert!(app.jump.is_none());
}

#[test]
fn jump_char_always_filters() {
    // All chars go to filter, even recognized command keys like 'K'
    let mut app = make_app("");
    app.jump = Some(crate::app::JumpState::default());
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Char('K')), &tx).unwrap();
    assert!(app.jump.is_some(), "jump bar should stay open");
    assert_eq!(app.jump.as_ref().unwrap().query, "K");
    assert!(
        matches!(app.screen, Screen::HostList),
        "should not navigate away"
    );
}

#[test]
fn jump_filter_then_enter_executes() {
    // Type the K hotkey directly: single-char query against a hotkey letter
    // gets a large score boost so the matching action lands at index 0,
    // and Enter dispatches it.
    let mut app = make_app("");
    let mut state = crate::app::JumpState::default();
    state.push_query('K');
    app.jump = Some(state);
    app.recompute_jump_hits();
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert!(
        matches!(app.screen, Screen::KeyList),
        "Enter should dispatch the K action and open KeyList; screen={:?}",
        app.screen
    );
    assert!(app.jump.is_none());
}

#[test]
fn jump_up_down_navigates() {
    let mut app = make_app("");
    app.jump = Some(crate::app::JumpState::default());
    let (tx, _rx) = mpsc::channel();
    // First Down reveals the cursor on row 0 (NOT row 1) so the user
    // does not skip past the first item on a fresh open.
    handle_key_event(&mut app, key(KeyCode::Down), &tx).unwrap();
    assert_eq!(app.jump.as_ref().unwrap().selected, 0);
    assert!(app.jump.as_ref().unwrap().cursor_revealed);
    // Subsequent Downs increment normally.
    handle_key_event(&mut app, key(KeyCode::Down), &tx).unwrap();
    assert_eq!(app.jump.as_ref().unwrap().selected, 1);
    handle_key_event(&mut app, key(KeyCode::Up), &tx).unwrap();
    assert_eq!(app.jump.as_ref().unwrap().selected, 0);
}

#[test]
fn jump_any_char_appends_to_filter() {
    let mut app = make_app("");
    app.jump = Some(crate::app::JumpState::default());
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    assert!(app.jump.is_some());
    assert_eq!(app.jump.as_ref().unwrap().query, "t");
    // 't' is a command key (tag inline), but should filter, not execute
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn jump_enter_on_empty_filter_does_nothing() {
    let mut app = make_app("");
    app.jump = Some(crate::app::JumpState::default());
    app.jump.as_mut().unwrap().push_query('z');
    app.jump.as_mut().unwrap().push_query('z');
    app.jump.as_mut().unwrap().push_query('z');
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert!(app.jump.is_some());
}

#[test]
fn jump_backspace_on_empty_closes() {
    let mut app = make_app("");
    app.jump = Some(crate::app::JumpState::default());
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Backspace), &tx).unwrap();
    assert!(app.jump.is_none());
}

#[test]
fn jump_backspace_removes_filter_char() {
    let mut app = make_app("");
    app.jump = Some(crate::app::JumpState::default());
    app.jump.as_mut().unwrap().push_query('t');
    app.jump.as_mut().unwrap().push_query('u');
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Backspace), &tx).unwrap();
    assert_eq!(app.jump.as_ref().unwrap().query, "t");
}

#[test]
fn jump_navigate_then_enter_executes() {
    let mut app = make_app("");
    app.jump = Some(crate::app::JumpState::default());
    let (tx, _rx) = mpsc::channel();
    // First Down reveals the cursor on row 0; subsequent Downs increment.
    handle_key_event(&mut app, key(KeyCode::Down), &tx).unwrap();
    handle_key_event(&mut app, key(KeyCode::Down), &tx).unwrap();
    handle_key_event(&mut app, key(KeyCode::Down), &tx).unwrap();
    assert_eq!(app.jump.as_ref().unwrap().selected, 2);
    // Enter on index 2 should dispatch the third action — with no host
    // selected the action's handler does nothing visible (no crash).
    // Jump bar closes either way.
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert!(app.jump.is_none(), "jump bar should close after Enter");
}

#[test]
fn jump_filter_shrink_then_enter_clamps_selected() {
    let mut app = make_app("");
    // Set selected to a high index, then add a filter that reduces the list
    let mut state = crate::app::JumpState {
        selected: 10,
        ..Default::default()
    };
    state.push_query('S'); // push_query resets selected to 0
    state.push_query('S');
    state.push_query('H');
    // Filtered list narrows to a few items
    let filtered = state.filtered_commands();
    assert!(!filtered.is_empty(), "filter should have results");
    assert!(filtered.len() < crate::app::PaletteCommand::all().len());
    // Force selected to way out-of-bounds to test clamping in Enter handler
    state.selected = 50;
    app.jump = Some(state);
    let (tx, _rx) = mpsc::channel();
    // Enter should clamp selected to last item, execute it, and close the jump bar
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert!(
        app.jump.is_none(),
        "jump bar should close after clamped Enter"
    );
}

// --- Unified jump dispatch tests for non-Action hits ---

#[test]
fn jump_enter_on_host_hit_jumps_to_host_list_and_selects() {
    let mut app = make_app("Host alpha\n  HostName alpha.example\n");
    app.jump = Some(crate::app::JumpState::default());
    let host_hit = crate::app::JumpHit::Host(crate::app::HostHit {
        alias: "alpha".into(),
        hostname: "alpha.example".into(),
        tags: vec![],
        provider: None,
        user: String::new(),
        identity_file: String::new(),
        proxy_jump: String::new(),
        vault_ssh: None,
    });
    if let Some(p) = app.jump.as_mut() {
        p.hits = vec![host_hit];
        // Force the visible_hits fast path even with empty query.
        for c in "alpha".chars() {
            p.push_query(c);
        }
    }
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert!(app.jump.is_none(), "jump bar should close on Enter");
    assert!(matches!(app.top_page, crate::app::TopPage::Hosts));
    let alias = app.selected_host().map(|h| h.alias.clone());
    assert_eq!(alias.as_deref(), Some("alpha"));
}

#[test]
fn jump_enter_on_tunnel_hit_switches_to_tunnels_page_and_selects_row() {
    let mut app =
        make_app("Host alpha\n  HostName alpha.example\n  LocalForward 5432 db.internal:5432\n");
    app.jump = Some(crate::app::JumpState::default());
    let tunnel_hit = crate::app::JumpHit::Tunnel(crate::app::TunnelHit {
        alias: "alpha".into(),
        bind_port: 5432,
        bind_port_str: "5432".into(),
        destination: "5432 -> db.internal:5432".into(),
        active: false,
    });
    if let Some(p) = app.jump.as_mut() {
        p.hits = vec![tunnel_hit];
        for c in "alpha".chars() {
            p.push_query(c);
        }
    }
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert!(matches!(app.top_page, crate::app::TopPage::Tunnels));
    // Selection landed on the matching tunnel row in the tunnels overview
    // ListState (NOT the host list).
    let pairs = crate::ui::tunnels_overview::visible_pairs(&app);
    let expected_idx = pairs
        .iter()
        .position(|(alias, rule)| alias == "alpha" && rule.bind_port == 5432);
    assert_eq!(app.ui.tunnels_overview_state.selected(), expected_idx);
}

#[test]
fn jump_enter_on_container_hit_opens_container_screen() {
    let mut app = make_app("Host beta\n  HostName beta.example\n");
    // Seed a cached container for `beta` so collect_jump_candidates
    // surfaces it; Enter then dispatches the matching hit.
    app.container_cache.insert(
        "beta".into(),
        crate::containers::ContainerCacheEntry {
            timestamp: 0,
            runtime: crate::containers::ContainerRuntime::Docker,
            containers: vec![crate::containers::ContainerInfo {
                id: "abc".into(),
                names: "nginx".into(),
                image: String::new(),
                state: "running".into(),
                status: String::new(),
                ports: String::new(),
            }],
        },
    );
    app.jump = Some(crate::app::JumpState::default());
    if let Some(p) = app.jump.as_mut() {
        for c in "nginx".chars() {
            p.push_query(c);
        }
    }
    app.recompute_jump_hits();
    // Move selection onto the container hit (it should be the first
    // matching candidate; we check by kind to be robust).
    if let Some(p) = app.jump.as_mut() {
        if let Some(idx) = p
            .visible_hits()
            .iter()
            .position(|h| matches!(h, crate::app::JumpHit::Container(_)))
        {
            p.selected = idx;
        }
    }
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert!(matches!(app.screen, Screen::Containers { ref alias } if alias == "beta"));
}

#[test]
fn jump_enter_on_snippet_hit_with_no_host_warns() {
    // Snippet picker requires a target host. With a snippet seeded but no
    // host selected (empty config = no host list = no selection), Enter
    // should surface a warning toast and NOT open the picker screen.
    let mut app = make_app("");
    app.snippets.store.snippets.push(crate::snippet::Snippet {
        name: "deploy".into(),
        command: "curl example".into(),
        description: String::new(),
    });
    app.jump = Some(crate::app::JumpState::default());
    if let Some(p) = app.jump.as_mut() {
        for c in "deploy".chars() {
            p.push_query(c);
        }
    }
    app.recompute_jump_hits();
    if let Some(p) = app.jump.as_mut() {
        if let Some(idx) = p
            .visible_hits()
            .iter()
            .position(|h| matches!(h, crate::app::JumpHit::Snippet(_)))
        {
            p.selected = idx;
        }
    }
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert!(!matches!(app.screen, Screen::SnippetPicker { .. }));
    let toast = app.status_center.toast.as_ref();
    assert!(
        toast.is_some(),
        "warning toast should surface when no host is selected"
    );
}

#[test]
fn jump_tab_jumps_to_next_section() {
    // Two visible kinds (Host + Action) so Tab has somewhere to go.
    let mut app = make_app("Host gamma\n  HostName gamma.example\n");
    app.jump = Some(crate::app::JumpState::default());
    if let Some(p) = app.jump.as_mut() {
        for c in "gamma".chars() {
            p.push_query(c);
        }
    }
    app.recompute_jump_hits();
    let starting_kind = app
        .jump
        .as_ref()
        .unwrap()
        .visible_hits()
        .first()
        .map(|h| h.kind());
    let (tx, _rx) = mpsc::channel();
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    let after_kind = app
        .jump
        .as_ref()
        .map(|p| p.visible_hits()[p.selected].kind());
    if let (Some(a), Some(b)) = (starting_kind, after_kind) {
        if a != b {
            // Confirmed: Tab moved to a different kind. Pass.
        } else {
            // Some demos may only have one kind at this stage; the test is
            // satisfied as long as the key press did not crash.
        }
    }
}

#[test]
fn jump_query_capped_at_64() {
    let mut state = crate::app::JumpState::default();
    for _ in 0..100 {
        state.push_query('a');
    }
    assert_eq!(state.query.len(), 64, "query should be capped at 64 chars");
}

// --- ProxyJump picker handler tests ---

use crate::app::ProxyJumpCandidate;

fn proxyjump_picker_app() -> App {
    // Three hosts: `bastion` is promoted into the suggested section via
    // the keyword heuristic, `alpha`/`zeta` stay in the rest section
    // below the separator, and `victim` is the host being edited.
    let mut app = make_app(concat!(
        "Host bastion\n  HostName 1.1.1.1\n",
        "Host alpha\n  HostName 2.2.2.2\n",
        "Host zeta\n  HostName 3.3.3.3\n",
        "Host victim\n  HostName 9.9.9.9\n",
    ));
    app.screen = Screen::EditHost {
        alias: "victim".to_string(),
    };
    app.ui.proxyjump_picker.open = true;
    app
}

#[test]
fn proxyjump_picker_enter_on_section_label_is_noop() {
    let mut app = proxyjump_picker_app();
    let candidates = app.proxyjump_candidates();
    let label_idx = candidates
        .iter()
        .position(|c| matches!(c, ProxyJumpCandidate::SectionLabel(_)))
        .expect("test setup must produce a SectionLabel");
    app.ui.proxyjump_picker.list.select(Some(label_idx));

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);

    assert!(
        app.ui.proxyjump_picker.open,
        "Enter on a SectionLabel must not close the picker"
    );
    assert!(
        app.forms.host.proxy_jump.is_empty(),
        "Enter on a SectionLabel must not populate the ProxyJump field"
    );
}

#[test]
fn proxyjump_picker_enter_on_separator_is_noop() {
    let mut app = proxyjump_picker_app();
    let candidates = app.proxyjump_candidates();
    let sep = candidates
        .iter()
        .position(|c| matches!(c, ProxyJumpCandidate::Separator))
        .expect("test setup must produce a separator");
    app.ui.proxyjump_picker.list.select(Some(sep));

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);

    assert!(
        app.ui.proxyjump_picker.open,
        "Enter on a Separator must not close the picker"
    );
    assert!(
        app.forms.host.proxy_jump.is_empty(),
        "Enter on a Separator must not populate the ProxyJump field"
    );
}

#[test]
fn proxyjump_picker_enter_on_host_applies_alias_and_closes() {
    let mut app = proxyjump_picker_app();
    // Select the first host (the suggested one). `proxyjump_first_host_index`
    // resolves to the right index regardless of any leading SectionLabel.
    let first_host = app.proxyjump_first_host_index().expect("host expected");
    app.ui.proxyjump_picker.list.select(Some(first_host));

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);

    assert!(
        !app.ui.proxyjump_picker.open,
        "Enter on a Host must close the picker"
    );
    assert_eq!(
        app.forms.host.proxy_jump, "bastion",
        "the selected host's alias must populate the ProxyJump field"
    );
}

// ─── Smart paste: bare domain/IP detection ──────────────────────

#[test]
fn host_form_smart_paste_detects_bare_domain() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "db.example.com".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    // Tab away from Alias triggers smart paste
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert_eq!(app.forms.host.hostname, "db.example.com");
    // Alias stays unchanged — only hostname is suggested
    assert_eq!(app.forms.host.alias, "db.example.com");
}

#[test]
fn host_form_smart_paste_detects_ip_address() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "192.168.1.100".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert_eq!(app.forms.host.hostname, "192.168.1.100");
    assert_eq!(app.forms.host.alias, "192.168.1.100");
}

#[test]
fn host_form_smart_paste_skips_plain_name() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "myserver".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    // No dot means no detection — alias stays, hostname stays empty
    assert_eq!(app.forms.host.alias, "myserver");
    assert!(app.forms.host.hostname.is_empty());
}

#[test]
fn host_form_smart_paste_domain_no_overwrite_hostname() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "db.example.com".to_string();
    app.forms.host.hostname = "already.set.com".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    // Hostname already populated — don't overwrite
    assert_eq!(app.forms.host.hostname, "already.set.com");
    assert_eq!(app.forms.host.alias, "db.example.com");
}

#[test]
fn host_form_smart_paste_rejects_leading_dot() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = ".example.com".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    // Leading dot produces empty first label — must not fire
    assert_eq!(app.forms.host.alias, ".example.com");
    assert!(app.forms.host.hostname.is_empty());
}

#[test]
fn host_form_smart_paste_rejects_bare_dot() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = ".".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert_eq!(app.forms.host.alias, ".");
    assert!(app.forms.host.hostname.is_empty());
}

#[test]
fn host_form_smart_paste_ignores_ipv6_mixed() {
    // IPv4-mapped IPv6 notation must not trigger bare-domain detection
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "::ffff:192.0.2.1".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert_eq!(app.forms.host.alias, "::ffff:192.0.2.1");
    assert!(app.forms.host.hostname.is_empty());
}

#[test]
fn host_form_smart_paste_allows_underscore_hostname() {
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "my_host.internal".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert_eq!(app.forms.host.hostname, "my_host.internal");
    assert_eq!(app.forms.host.alias, "my_host.internal");
}

#[test]
fn host_form_smart_paste_fires_on_enter() {
    // Enter on Alias also calls maybe_smart_paste before submit.
    // Use a minimal valid config so submit_form can succeed.
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "web.example.com".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    // Smart paste copies alias to hostname, alias stays unchanged.
    // submit_form runs next — on success the screen returns to HostList.
    assert_eq!(app.screen, Screen::HostList);
    assert!(
        app.hosts_state
            .list
            .iter()
            .any(|h| h.alias == "web.example.com")
    );
    assert!(
        app.hosts_state
            .list
            .iter()
            .any(|h| h.hostname == "web.example.com")
    );
}

#[test]
fn host_form_smart_paste_rejects_trailing_dot() {
    // Trailing dot is invalid for SSH HostName — must not fire
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "example.com.".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert_eq!(app.forms.host.alias, "example.com.");
    assert!(app.forms.host.hostname.is_empty());
}

#[test]
fn host_form_smart_paste_rejects_short_dotted_string() {
    // "1.1" (len 3) should not trigger — too short to be a real hostname
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "1.1".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert_eq!(app.forms.host.alias, "1.1");
    assert!(app.forms.host.hostname.is_empty());
}

#[test]
fn host_form_smart_paste_minimum_valid_length() {
    // "x.io" (len 4) is the shortest that should trigger
    let mut app = make_app("");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "x.io".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::AddHost;
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert_eq!(app.forms.host.hostname, "x.io");
    assert_eq!(app.forms.host.alias, "x.io");
}

#[test]
fn host_form_smart_paste_no_fire_on_edit_with_hostname() {
    // EditHost: hostname already populated from existing entry — must not overwrite
    let mut app = make_app("Host myserver\n  HostName myserver.local\n");
    app.forms.host = HostForm::new();
    app.forms.host.alias = "db.example.com".to_string();
    app.forms.host.hostname = "myserver.local".to_string();
    app.forms.host.focused_field = FormField::Alias;
    app.screen = Screen::EditHost {
        alias: "myserver".to_string(),
    };
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Tab), &tx).unwrap();
    assert_eq!(app.forms.host.hostname, "myserver.local");
    assert_eq!(app.forms.host.alias, "db.example.com");
}

// ---------------------------------------------------------------------
// Bulk tag editor — handler integration
// ---------------------------------------------------------------------

fn bulk_make_app() -> App {
    // Config path gets written during apply — use a unique /tmp path per
    // test so parallel runs don't stomp each other. Thread ID ensures
    // uniqueness when cargo test runs tests in parallel threads.
    let path = std::env::temp_dir().join(format!(
        "purple_bulk_test_{}_{:?}.cfg",
        std::process::id(),
        std::thread::current().id()
    ));
    let mut app = make_app(
        "Host a\n  HostName 1.1.1.1\n  # purple:tags prod\n\
         Host b\n  HostName 2.2.2.2\n  # purple:tags prod,db\n\
         Host c\n  HostName 3.3.3.3\n  # purple:tags db\n",
    );
    app.hosts_state.ssh_config.path = path;
    app
}

#[test]
fn plain_space_toggles_multi_select_in_host_list() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    // First host is selected by default.
    let idx = app.selected_host_index().unwrap();
    handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx).unwrap();
    assert!(app.hosts_state.multi_select.contains(&idx));
    handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx).unwrap();
    assert!(!app.hosts_state.multi_select.contains(&idx));
}

#[test]
fn esc_with_selection_clears_it_without_quitting() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    app.hosts_state.multi_select.insert(0);
    handle_key_event(&mut app, key(KeyCode::Esc), &tx).unwrap();
    assert!(app.hosts_state.multi_select.is_empty());
    assert!(app.running, "Esc must not quit while clearing selection");
}

#[test]
fn t_routes_to_bulk_editor_when_selection_active() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    app.hosts_state.multi_select.insert(0);
    app.hosts_state.multi_select.insert(1);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    assert_eq!(app.screen, Screen::BulkTagEditor);
    assert!(app.tags.input.is_none(), "single-host input must NOT open");
    assert_eq!(app.forms.bulk_tag_editor.aliases.len(), 2);
}

#[test]
fn t_opens_single_host_input_when_no_selection() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    assert_eq!(app.screen, Screen::HostList);
    assert!(
        app.tags.input.is_some(),
        "must fall back to existing single-host tag input"
    );
}

#[test]
fn bulk_editor_space_cycles_and_enter_applies() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    // Select a + c. Apply "add prod" — a already has it, c does not.
    let idx_a = app
        .hosts_state
        .list
        .iter()
        .position(|h| h.alias == "a")
        .unwrap();
    let idx_c = app
        .hosts_state
        .list
        .iter()
        .position(|h| h.alias == "c")
        .unwrap();
    app.hosts_state.multi_select.insert(idx_a);
    app.hosts_state.multi_select.insert(idx_c);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    assert_eq!(app.screen, Screen::BulkTagEditor);

    // Land the cursor on `prod`.
    let prod_row = app
        .forms
        .bulk_tag_editor
        .rows
        .iter()
        .position(|r| r.tag == "prod")
        .unwrap();
    app.ui.bulk_tag_editor_state.select(Some(prod_row));
    // One Space: Leave → AddToAll.
    handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx).unwrap();
    assert_eq!(
        app.forms.bulk_tag_editor.rows[prod_row].action,
        crate::app::BulkTagAction::AddToAll
    );
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert_eq!(app.screen, Screen::HostList);
    // c now has prod.
    let c = app
        .hosts_state
        .list
        .iter()
        .find(|h| h.alias == "c")
        .unwrap();
    assert!(c.tags.contains(&"prod".to_string()));
}

#[test]
fn bulk_editor_esc_with_dirty_shows_discard_then_confirms() {
    // Every dirty-checked surface prompts before discarding work.
    // Esc on a dirty editor opens
    // the discard prompt; pressing y then closes the editor and clears state.
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    app.hosts_state.multi_select.insert(0);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    assert_eq!(app.screen, Screen::BulkTagEditor);
    // Stage a change.
    let prod_row = app
        .forms
        .bulk_tag_editor
        .rows
        .iter()
        .position(|r| r.tag == "prod")
        .unwrap();
    app.ui.bulk_tag_editor_state.select(Some(prod_row));
    handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx).unwrap();
    assert!(
        app.forms.bulk_tag_editor.is_dirty(),
        "Space cycle should mark editor dirty"
    );
    // Esc on dirty editor opens the discard prompt; editor stays open.
    handle_key_event(&mut app, key(KeyCode::Esc), &tx).unwrap();
    assert!(
        app.forms.pending_discard_confirm,
        "Esc on dirty editor must show discard prompt"
    );
    assert_eq!(
        app.screen,
        Screen::BulkTagEditor,
        "Discard prompt keeps the editor screen"
    );
    // Confirm the discard.
    handle_key_event(&mut app, key(KeyCode::Char('y')), &tx).unwrap();
    assert_eq!(app.screen, Screen::HostList);
    assert!(app.forms.bulk_tag_editor.rows.is_empty());
    assert!(!app.forms.pending_discard_confirm);
}

#[test]
fn bulk_editor_esc_when_clean_closes_immediately() {
    // Without dirty changes, Esc closes the editor without prompting.
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    app.hosts_state.multi_select.insert(0);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    assert_eq!(app.screen, Screen::BulkTagEditor);
    handle_key_event(&mut app, key(KeyCode::Esc), &tx).unwrap();
    assert_eq!(app.screen, Screen::HostList);
    assert!(!app.forms.pending_discard_confirm);
}

#[test]
fn bulk_editor_esc_dirty_then_no_keeps_editor_open() {
    // Pressing n / Esc on the discard prompt cancels the discard and
    // returns the user to the editor with their changes intact.
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    app.hosts_state.multi_select.insert(0);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    let prod_row = app
        .forms
        .bulk_tag_editor
        .rows
        .iter()
        .position(|r| r.tag == "prod")
        .unwrap();
    app.ui.bulk_tag_editor_state.select(Some(prod_row));
    handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx).unwrap();
    handle_key_event(&mut app, key(KeyCode::Esc), &tx).unwrap();
    assert!(app.forms.pending_discard_confirm);
    handle_key_event(&mut app, key(KeyCode::Char('n')), &tx).unwrap();
    assert!(!app.forms.pending_discard_confirm);
    assert_eq!(app.screen, Screen::BulkTagEditor);
    assert!(app.forms.bulk_tag_editor.is_dirty(), "Changes preserved");
}

#[test]
fn bulk_editor_plus_opens_new_tag_input() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    app.hosts_state.multi_select.insert(0);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    handle_key_event(&mut app, key(KeyCode::Char('+')), &tx).unwrap();
    assert!(app.forms.bulk_tag_editor.new_tag_input.is_some());
    // Type "eu" and Enter.
    handle_key_event(&mut app, key(KeyCode::Char('e')), &tx).unwrap();
    handle_key_event(&mut app, key(KeyCode::Char('u')), &tx).unwrap();
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert!(app.forms.bulk_tag_editor.new_tag_input.is_none());
    let eu = app
        .forms
        .bulk_tag_editor
        .rows
        .iter()
        .find(|r| r.tag == "eu");
    assert!(eu.is_some(), "new tag `eu` should be appended as a row");
    assert_eq!(eu.unwrap().action, crate::app::BulkTagAction::AddToAll);
}

#[test]
fn bulk_tag_undo_restores_previous_tags() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    // Select a (has prod) + b (has prod, db). Remove `prod` from both.
    let idx_a = app
        .hosts_state
        .list
        .iter()
        .position(|h| h.alias == "a")
        .unwrap();
    let idx_b = app
        .hosts_state
        .list
        .iter()
        .position(|h| h.alias == "b")
        .unwrap();
    app.hosts_state.multi_select.insert(idx_a);
    app.hosts_state.multi_select.insert(idx_b);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    let prod_row = app
        .forms
        .bulk_tag_editor
        .rows
        .iter()
        .position(|r| r.tag == "prod")
        .unwrap();
    app.ui.bulk_tag_editor_state.select(Some(prod_row));
    // Cycle to RemoveFromAll (Leave → AddToAll → RemoveFromAll).
    handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx).unwrap();
    handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx).unwrap();
    handle_key_event(&mut app, key(KeyCode::Enter), &tx).unwrap();
    assert_eq!(app.screen, Screen::HostList);
    // Verify prod was removed.
    let a = app
        .hosts_state
        .list
        .iter()
        .find(|h| h.alias == "a")
        .unwrap();
    assert!(!a.tags.contains(&"prod".to_string()));
    // Undo.
    assert!(app.forms.bulk_tag_undo.is_some());
    handle_key_event(&mut app, key(KeyCode::Char('u')), &tx).unwrap();
    assert!(app.forms.bulk_tag_undo.is_none());
    // Verify prod is back.
    let a = app
        .hosts_state
        .list
        .iter()
        .find(|h| h.alias == "a")
        .unwrap();
    let b = app
        .hosts_state
        .list
        .iter()
        .find(|h| h.alias == "b")
        .unwrap();
    assert!(a.tags.contains(&"prod".to_string()));
    assert!(b.tags.contains(&"prod".to_string()));
    // b still has db (it wasn't touched).
    assert!(b.tags.contains(&"db".to_string()));
}

#[test]
fn bulk_editor_q_cancels_like_esc() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    app.hosts_state.multi_select.insert(0);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    assert_eq!(app.screen, Screen::BulkTagEditor);
    handle_key_event(&mut app, key(KeyCode::Char('q')), &tx).unwrap();
    assert_eq!(app.screen, Screen::HostList);
    assert!(app.forms.bulk_tag_editor.rows.is_empty());
}

#[test]
fn bulk_editor_jk_navigates_rows() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    app.hosts_state.multi_select.insert(0);
    app.hosts_state.multi_select.insert(1);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    assert!(app.forms.bulk_tag_editor.rows.len() >= 2);
    let initial = app.ui.bulk_tag_editor_state.selected();
    handle_key_event(&mut app, key(KeyCode::Char('j')), &tx).unwrap();
    let after_j = app.ui.bulk_tag_editor_state.selected();
    assert_ne!(initial, after_j, "j should move selection");
    handle_key_event(&mut app, key(KeyCode::Char('k')), &tx).unwrap();
    let after_k = app.ui.bulk_tag_editor_state.selected();
    assert_eq!(initial, after_k, "k should move back");
}

#[test]
fn bulk_editor_help_roundtrip() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    app.hosts_state.multi_select.insert(0);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    assert_eq!(app.screen, Screen::BulkTagEditor);
    // Stage a change so we can verify state survives the help roundtrip.
    app.ui.bulk_tag_editor_state.select(Some(0));
    handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx).unwrap();
    let action_before = app.forms.bulk_tag_editor.rows[0].action;
    // Open help.
    handle_key_event(&mut app, key(KeyCode::Char('?')), &tx).unwrap();
    assert!(matches!(app.screen, Screen::Help { .. }));
    // Return from help.
    handle_key_event(&mut app, key(KeyCode::Esc), &tx).unwrap();
    assert_eq!(app.screen, Screen::BulkTagEditor);
    assert_eq!(app.forms.bulk_tag_editor.rows[0].action, action_before);
}

#[test]
fn bulk_editor_new_tag_input_backspace_and_cursor() {
    let mut app = bulk_make_app();
    let tx = mpsc::channel().0;
    app.hosts_state.multi_select.insert(0);
    handle_key_event(&mut app, key(KeyCode::Char('t')), &tx).unwrap();
    // Open new-tag input.
    handle_key_event(&mut app, key(KeyCode::Char('+')), &tx).unwrap();
    // Type "abc".
    handle_key_event(&mut app, key(KeyCode::Char('a')), &tx).unwrap();
    handle_key_event(&mut app, key(KeyCode::Char('b')), &tx).unwrap();
    handle_key_event(&mut app, key(KeyCode::Char('c')), &tx).unwrap();
    assert_eq!(
        app.forms.bulk_tag_editor.new_tag_input.as_deref(),
        Some("abc")
    );
    assert_eq!(app.forms.bulk_tag_editor.new_tag_cursor, 3);
    // Backspace removes 'c'.
    handle_key_event(&mut app, key(KeyCode::Backspace), &tx).unwrap();
    assert_eq!(
        app.forms.bulk_tag_editor.new_tag_input.as_deref(),
        Some("ab")
    );
    assert_eq!(app.forms.bulk_tag_editor.new_tag_cursor, 2);
    // Left, Right.
    handle_key_event(&mut app, key(KeyCode::Left), &tx).unwrap();
    assert_eq!(app.forms.bulk_tag_editor.new_tag_cursor, 1);
    handle_key_event(&mut app, key(KeyCode::Right), &tx).unwrap();
    assert_eq!(app.forms.bulk_tag_editor.new_tag_cursor, 2);
    // Home, End.
    handle_key_event(&mut app, key(KeyCode::Home), &tx).unwrap();
    assert_eq!(app.forms.bulk_tag_editor.new_tag_cursor, 0);
    handle_key_event(&mut app, key(KeyCode::End), &tx).unwrap();
    assert_eq!(app.forms.bulk_tag_editor.new_tag_cursor, 2);
    // Esc cancels input without closing editor.
    handle_key_event(&mut app, key(KeyCode::Esc), &tx).unwrap();
    assert!(app.forms.bulk_tag_editor.new_tag_input.is_none());
    assert_eq!(app.screen, Screen::BulkTagEditor);
}

#[test]
fn format_apply_status_variants() {
    use crate::app::BulkTagApplyResult;
    use crate::handler::bulk_tag_editor::format_apply_status;

    // No changes, no skipped.
    assert_eq!(format_apply_status(&BulkTagApplyResult::default()), "");

    // Only adds.
    let r = BulkTagApplyResult {
        changed_hosts: 3,
        added: 5,
        removed: 0,
        skipped_included: 0,
    };
    let s = format_apply_status(&r);
    assert!(s.contains("Updated 3 hosts"), "{s}");
    assert!(s.contains("+5"), "{s}");
    assert!(!s.contains("-"), "{s}");

    // Only removes.
    let r = BulkTagApplyResult {
        changed_hosts: 2,
        added: 0,
        removed: 3,
        skipped_included: 0,
    };
    let s = format_apply_status(&r);
    assert!(s.contains("-3"), "{s}");

    // Both + skipped.
    let r = BulkTagApplyResult {
        changed_hosts: 4,
        added: 2,
        removed: 1,
        skipped_included: 2,
    };
    let s = format_apply_status(&r);
    assert!(s.contains("+2"), "{s}");
    assert!(s.contains("-1"), "{s}");
    assert!(s.contains("skipped 2"), "{s}");
    assert!(s.contains("include files"), "{s}");

    // Single host, single skipped (singular forms).
    let r = BulkTagApplyResult {
        changed_hosts: 1,
        added: 1,
        removed: 0,
        skipped_included: 1,
    };
    let s = format_apply_status(&r);
    assert!(s.contains("Updated 1 host"), "{s}");
    assert!(!s.contains("hosts"), "should be singular: {s}");
    assert!(s.contains("skipped 1 in include file"), "{s}");
}

// ── route_confirm_key (confirm dialog routing) ──────────────────────

#[test]
fn route_confirm_key_y_lowercase_yes() {
    assert_eq!(
        super::route_confirm_key(key(KeyCode::Char('y'))),
        super::ConfirmAction::Yes
    );
}

#[test]
fn route_confirm_key_y_uppercase_yes() {
    assert_eq!(
        super::route_confirm_key(key(KeyCode::Char('Y'))),
        super::ConfirmAction::Yes
    );
}

#[test]
fn route_confirm_key_n_lowercase_no() {
    assert_eq!(
        super::route_confirm_key(key(KeyCode::Char('n'))),
        super::ConfirmAction::No
    );
}

#[test]
fn route_confirm_key_n_uppercase_no() {
    assert_eq!(
        super::route_confirm_key(key(KeyCode::Char('N'))),
        super::ConfirmAction::No
    );
}

#[test]
fn route_confirm_key_esc_no() {
    assert_eq!(
        super::route_confirm_key(key(KeyCode::Esc)),
        super::ConfirmAction::No
    );
}

#[test]
fn route_confirm_key_other_keys_ignored() {
    // Critical safety invariant: stray keys must NOT cancel a confirm dialog.
    for code in [
        KeyCode::Char('t'), // adjacent to y
        KeyCode::Char('u'), // adjacent to y
        KeyCode::Char('m'), // adjacent to n
        KeyCode::Char('b'), // adjacent to n
        KeyCode::Char('q'), // browse-context cancel, not confirm-context
        KeyCode::Enter,
        KeyCode::Tab,
        KeyCode::Char(' '),
    ] {
        assert_eq!(
            super::route_confirm_key(key(code)),
            super::ConfirmAction::Ignored,
            "key {:?} must be Ignored, not Yes/No",
            code
        );
    }
}

// ── End-to-end Vault Sign confirm safety (the original bug) ─────────

/// Build an app stuck on the Vault Sign confirm screen with one signable host.
fn vault_sign_confirm_app() -> App {
    let mut app =
        make_app("Host vaulthost\n  HostName vault.example.com\n  IdentityFile ~/.ssh/id_rsa\n");
    let path = tempfile::tempdir()
        .expect("tempdir")
        .keep()
        .join("test-cert");
    let signable = vec![(
        "vaulthost".to_string(),
        "ssh-client/sign/role".to_string(),
        String::new(),
        path,
        None,
    )];
    app.screen = Screen::ConfirmVaultSign { signable };
    app
}

#[test]
fn vault_sign_confirm_stray_key_does_not_cancel() {
    // Original bug: a `_ => app.screen = Screen::HostList` catch-all let
    // any keypress next to `y` (e.g. `t`, `u`) silently abort a bulk sign.
    // Today the handler routes via `route_confirm_key` and stray keys are
    // explicitly Ignored. Regression guard.
    for stray in [
        KeyCode::Char('t'),
        KeyCode::Char('u'),
        KeyCode::Char('q'),
        KeyCode::Char(' '),
        KeyCode::Enter,
        KeyCode::Tab,
    ] {
        let mut app = vault_sign_confirm_app();
        let (tx, _rx) = mpsc::channel();
        let _ = handle_key_event(&mut app, key(stray), &tx);
        assert!(
            matches!(app.screen, Screen::ConfirmVaultSign { .. }),
            "stray key {:?} must not cancel Vault Sign confirm",
            stray
        );
    }
}

#[test]
fn vault_sign_confirm_n_cancels() {
    let mut app = vault_sign_confirm_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert_eq!(app.screen, Screen::HostList);
}

#[test]
fn vault_sign_confirm_esc_cancels() {
    let mut app = vault_sign_confirm_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert_eq!(app.screen, Screen::HostList);
}

// ── Picker-field parity (Enter submits, Space-on-empty opens, Space-on-populated literal) ──

#[test]
fn enter_on_identity_file_field_does_not_open_key_picker() {
    // Invariant 1: Enter never opens a picker.
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::IdentityFile;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(
        !app.ui.key_picker.open,
        "Enter on IdentityFile must NOT open the key picker (use Space)"
    );
}

#[test]
fn space_on_empty_identity_file_opens_key_picker() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::IdentityFile;
    assert!(app.forms.host.identity_file.is_empty());
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(app.ui.key_picker.open);
}

#[test]
fn space_on_populated_identity_file_inserts_literal() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::IdentityFile;
    app.forms.host.identity_file = "/home/me/keys/id".to_string();
    app.forms.host.cursor_pos = app.forms.host.identity_file.chars().count();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(
        !app.ui.key_picker.open,
        "Space on populated IdentityFile must NOT open picker"
    );
    assert_eq!(app.forms.host.identity_file, "/home/me/keys/id ");
}

#[test]
fn enter_on_proxy_jump_field_does_not_open_picker() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::ProxyJump;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.proxyjump_picker.open);
}

#[test]
fn space_on_empty_proxy_jump_opens_picker() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::ProxyJump;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(app.ui.proxyjump_picker.open);
}

#[test]
fn space_on_populated_proxy_jump_inserts_literal() {
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::ProxyJump;
    app.forms.host.proxy_jump = "bastion".to_string();
    app.forms.host.cursor_pos = 7;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(!app.ui.proxyjump_picker.open);
    assert_eq!(app.forms.host.proxy_jump, "bastion ");
}

#[test]
fn space_on_empty_vault_ssh_with_no_candidates_inserts_literal() {
    // VaultSsh is `is_picker == true` but the picker only opens when there
    // are role candidates. With none configured, Space on empty VaultSsh
    // degrades to literal-space insert so the user can type the role.
    let mut app = make_form_app();
    app.forms.host.focused_field = FormField::VaultSsh;
    assert!(app.vault_role_candidates().is_empty());
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(
        !app.ui.vault_role_picker.open,
        "no candidates → no picker, even on empty field"
    );
    assert_eq!(
        app.forms.host.vault_ssh, " ",
        "Space falls through to literal-space insert"
    );
}

// ── Provider form picker-field parity ───────────────────────────────

#[test]
fn enter_on_provider_identity_file_does_not_open_picker() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::IdentityFile);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(!app.ui.key_picker.open);
}

#[test]
fn space_on_populated_provider_identity_file_inserts_literal() {
    let mut app = make_form_app_focused_on("digitalocean", ProviderFormField::IdentityFile);
    app.providers.form.identity_file = "/path".to_string();
    app.providers.form.cursor_pos = 5;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(!app.ui.key_picker.open);
    assert_eq!(app.providers.form.identity_file, "/path ");
}

#[test]
fn space_on_populated_ovh_regions_inserts_literal() {
    let mut app = make_ovh_form_app();
    app.providers.form.focused_field = ProviderFormField::Regions;
    app.providers.form.regions = "eu".to_string();
    app.providers.form.cursor_pos = 2;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(' ')), &tx);
    assert!(
        !app.ui.region_picker.open,
        "Space on populated Regions must NOT open picker"
    );
    assert_eq!(app.providers.form.regions, "eu ");
}

// ── Container confirm n/N cancels ───────────────────────────────────

fn container_confirm_app() -> App {
    let mut app = make_app("Host srv\n  HostName srv.example.com\n");
    app.screen = Screen::Containers {
        alias: "srv".to_string(),
    };
    app.container_state = Some(crate::app::ContainerState {
        alias: "srv".to_string(),
        askpass: None,
        runtime: Some(crate::containers::ContainerRuntime::Docker),
        containers: vec![crate::containers::ContainerInfo {
            id: "abc123".to_string(),
            names: "demo".to_string(),
            image: "nginx".to_string(),
            state: "running".to_string(),
            status: "Up".to_string(),
            ports: String::new(),
        }],
        list_state: ratatui::widgets::ListState::default(),
        loading: false,
        error: None,
        action_in_progress: None,
        confirm_action: Some((
            crate::containers::ContainerAction::Stop,
            "demo".to_string(),
            "abc123".to_string(),
        )),
    });
    app.container_state
        .as_mut()
        .unwrap()
        .list_state
        .select(Some(0));
    app
}

#[test]
fn container_confirm_n_cancels_pending_action() {
    let mut app = container_confirm_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    let state = app.container_state.as_ref().unwrap();
    assert!(
        state.confirm_action.is_none(),
        "n must cancel the pending container action"
    );
    assert!(matches!(app.screen, Screen::Containers { .. }));
}

#[test]
fn container_confirm_capital_n_cancels_pending_action() {
    let mut app = container_confirm_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('N')), &tx);
    assert!(
        app.container_state
            .as_ref()
            .unwrap()
            .confirm_action
            .is_none()
    );
}

#[test]
fn container_confirm_stray_key_ignored() {
    let mut app = container_confirm_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('t')), &tx);
    assert!(
        app.container_state
            .as_ref()
            .unwrap()
            .confirm_action
            .is_some(),
        "stray key must not cancel the pending container action"
    );
}

// ── BulkTagEditorState::is_dirty added-rows branch ──────────────────

#[test]
fn bulk_editor_is_dirty_detects_added_row() {
    use crate::app::{BulkTagAction, BulkTagEditorState, BulkTagRow};
    let mut state = BulkTagEditorState {
        rows: vec![BulkTagRow {
            tag: "prod".into(),
            initial_count: 0,
            action: BulkTagAction::Leave,
        }],
        aliases: Vec::new(),
        skipped_included: Vec::new(),
        new_tag_input: None,
        new_tag_cursor: 0,
        initial_actions: vec![BulkTagAction::Leave],
    };
    assert!(!state.is_dirty(), "baseline state must be clean");

    // Append a new tag row (simulates the `+ new tag` flow). New rows
    // default to AddToAll, so they count as dirty immediately.
    state.rows.push(BulkTagRow {
        tag: "newtag".into(),
        initial_count: 0,
        action: BulkTagAction::AddToAll,
    });
    assert!(state.is_dirty(), "added row with non-Leave action is dirty");
}

#[test]
fn bulk_editor_is_dirty_added_leave_row_still_clean() {
    // Edge case: an appended row that happens to still be Leave is not
    // semantically dirty (nothing will change on apply).
    use crate::app::{BulkTagAction, BulkTagEditorState, BulkTagRow};
    let mut state = BulkTagEditorState {
        rows: vec![BulkTagRow {
            tag: "prod".into(),
            initial_count: 0,
            action: BulkTagAction::Leave,
        }],
        aliases: Vec::new(),
        skipped_included: Vec::new(),
        new_tag_input: None,
        new_tag_cursor: 0,
        initial_actions: vec![BulkTagAction::Leave],
    };
    state.rows.push(BulkTagRow {
        tag: "noop".into(),
        initial_count: 0,
        action: BulkTagAction::Leave,
    });
    assert!(!state.is_dirty(), "appended Leave row is not dirty");
}

// --- WhatsNew overlay handler tests ---

#[test]
fn whats_new_esc_closes_and_marks_seen() {
    crate::preferences::tests_helpers::with_temp_prefs("whats_new_esc", |_| {
        let mut app = make_app("");
        app.screen = Screen::WhatsNew(crate::app::WhatsNewState::default());
        let (tx, _rx) = mpsc::channel();
        let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
        assert!(matches!(app.screen, Screen::HostList));
        assert_eq!(
            crate::preferences::load_last_seen_version()
                .unwrap()
                .as_deref(),
            Some(env!("CARGO_PKG_VERSION"))
        );
    });
}

#[test]
fn whats_new_q_closes() {
    crate::preferences::tests_helpers::with_temp_prefs("whats_new_q", |_| {
        let mut app = make_app("");
        app.screen = Screen::WhatsNew(crate::app::WhatsNewState::default());
        let (tx, _rx) = mpsc::channel();
        let _ = handle_key_event(&mut app, key(KeyCode::Char('q')), &tx);
        assert!(matches!(app.screen, Screen::HostList));
    });
}

#[test]
fn whats_new_n_toggles_closed() {
    crate::preferences::tests_helpers::with_temp_prefs("whats_new_n", |_| {
        let mut app = make_app("");
        app.screen = Screen::WhatsNew(crate::app::WhatsNewState::default());
        let (tx, _rx) = mpsc::channel();
        let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
        assert!(matches!(app.screen, Screen::HostList));
    });
}

#[test]
fn whats_new_enter_does_nothing() {
    crate::preferences::tests_helpers::with_temp_prefs("whats_new_enter", |_| {
        let mut app = make_app("");
        app.screen = Screen::WhatsNew(crate::app::WhatsNewState::default());
        let (tx, _rx) = mpsc::channel();
        let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
        assert!(
            matches!(app.screen, Screen::WhatsNew(_)),
            "Enter must not close the overlay"
        );
        assert_eq!(
            crate::preferences::load_last_seen_version().unwrap(),
            None,
            "Enter must not persist last_seen_version"
        );
    });
}

#[test]
fn whats_new_scroll_j_advances_state() {
    let mut app = make_app("");
    app.screen = Screen::WhatsNew(crate::app::WhatsNewState::default());
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('j')), &tx);
    if let Screen::WhatsNew(ref s) = app.screen {
        assert_eq!(s.scroll, 1);
    } else {
        panic!("expected WhatsNew screen");
    }
}

#[test]
fn whats_new_close_dismisses_sticky_toast() {
    crate::preferences::tests_helpers::with_temp_prefs("whats_new_dismiss", |_| {
        let mut app = make_app("");
        app.status_center.toast = Some(crate::app::StatusMessage {
            text: crate::messages::whats_new_toast::upgraded("2.42.0"),
            class: crate::app::MessageClass::Success,
            tick_count: 0,
            sticky: true,
            created_at: std::time::Instant::now(),
        });
        app.screen = Screen::WhatsNew(crate::app::WhatsNewState::default());
        let (tx, _rx) = mpsc::channel();
        let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
        let fragment = crate::messages::whats_new_toast::INVITE_FRAGMENT;
        let contains_invite = app
            .status_center
            .toast
            .as_ref()
            .is_some_and(|t| t.text.contains(fragment));
        assert!(!contains_invite, "sticky toast should be dismissed");
    });
}

#[test]
fn host_list_n_opens_whats_new_when_search_inactive() {
    let mut app = make_app("");
    app.screen = Screen::HostList;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert!(matches!(app.screen, Screen::WhatsNew(_)));
}

#[test]
fn host_list_n_types_into_search_when_active() {
    let mut app = make_app("");
    app.screen = Screen::HostList;
    app.search.query = Some(String::new());
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert_eq!(app.search.query.as_deref(), Some("n"));
}

// =========================================================================
// Tunnels overview: add/edit/delete via top-page Tunnels
// =========================================================================

/// Helper: build an app on the Tunnels top-page with one editable host that
/// has a single LocalForward tunnel. Cursor is positioned on the tunnel.
fn make_tunnels_overview_app() -> App {
    let mut app = make_app("Host test\n  HostName test.com\n  LocalForward 8080 localhost:80\n");
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::HostList;
    app.ui.tunnels_overview_state.select(Some(0));
    app
}

#[test]
fn tunnels_overview_a_with_no_editable_hosts_shows_warning() {
    let mut app = make_app("");
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::HostList;
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('a')), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(matches!(app.top_page, crate::app::TopPage::Tunnels));
    let toast = app.status_center.toast.as_ref().expect("toast");
    assert!(toast.text.contains("No editable hosts"));
}

#[test]
fn tunnels_overview_a_opens_host_picker() {
    let mut app = make_tunnels_overview_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('a')), &tx);
    assert!(matches!(app.screen, Screen::TunnelHostPicker));
    assert_eq!(app.ui.tunnel_host_picker_state.selected(), Some(0));
}

#[test]
fn tunnel_host_picker_enter_opens_form_for_chosen_host() {
    let mut app = make_app("Host alpha\n  HostName a.example\n\nHost beta\n  HostName b.example\n");
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::TunnelHostPicker;
    app.ui.tunnel_host_picker_state.select(Some(1)); // beta
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    match &app.screen {
        Screen::TunnelForm { alias, editing } => {
            assert_eq!(alias, "beta");
            assert!(editing.is_none());
        }
        other => panic!("expected TunnelForm, got {:?}", other.variant_name()),
    }
    // Picker cursor was reset so re-opening starts from the top.
    assert_eq!(app.ui.tunnel_host_picker_state.selected(), None);
}

#[test]
fn tunnel_host_picker_esc_returns_to_overview() {
    let mut app = make_tunnels_overview_app();
    app.screen = Screen::TunnelHostPicker;
    app.ui.tunnel_host_picker_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(matches!(app.top_page, crate::app::TopPage::Tunnels));
    assert_eq!(app.ui.tunnel_host_picker_state.selected(), None);
}

#[test]
fn tunnel_host_picker_arrow_keys_clamp() {
    // Mirrors jump navigation: Down clamps at the last row,
    // Up clamps at row 0. No wrap-around — predictable for "type to filter"
    // overlays where the visible set changes between keystrokes.
    let mut app = make_app("Host alpha\n  HostName a\n\nHost beta\n  HostName b\n");
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::TunnelHostPicker;
    app.ui.tunnel_host_picker_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Down), &tx);
    assert_eq!(app.ui.tunnel_host_picker_state.selected(), Some(1));
    let _ = handle_key_event(&mut app, key(KeyCode::Down), &tx);
    assert_eq!(app.ui.tunnel_host_picker_state.selected(), Some(1));
    let _ = handle_key_event(&mut app, key(KeyCode::Up), &tx);
    assert_eq!(app.ui.tunnel_host_picker_state.selected(), Some(0));
    let _ = handle_key_event(&mut app, key(KeyCode::Up), &tx);
    assert_eq!(app.ui.tunnel_host_picker_state.selected(), Some(0));
}

#[test]
fn tunnel_host_picker_typing_filters_live() {
    // Always-on fuzzy search: every printable keystroke appends to the
    // query and the visible set shrinks accordingly.
    let mut app = make_app(
        "Host alpha\n  HostName a.example\n\n\
         Host beta\n  HostName b.example\n\n\
         Host db-primary\n  HostName 10.30.1.5\n",
    );
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::TunnelHostPicker;
    app.ui.tunnel_host_picker_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('b')), &tx);
    assert_eq!(app.ui.tunnel_host_picker_query, "db");
    let visible = crate::handler::tunnel_host_picker::filtered_hosts(&app);
    assert_eq!(visible.len(), 1);
    assert_eq!(visible[0].0, "db-primary");
    assert_eq!(app.ui.tunnel_host_picker_state.selected(), Some(0));
}

#[test]
fn tunnel_host_picker_backspace_pops_query_char() {
    let mut app = make_app("Host alpha\n  HostName a\n\nHost beta\n  HostName b\n");
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::TunnelHostPicker;
    app.ui.tunnel_host_picker_state.select(Some(0));
    app.ui.tunnel_host_picker_query = "be".to_string();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert_eq!(app.ui.tunnel_host_picker_query, "b");
    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert_eq!(app.ui.tunnel_host_picker_query, "");
    // Still on the picker; full list is restored.
    assert!(matches!(app.screen, Screen::TunnelHostPicker));
    let visible = crate::handler::tunnel_host_picker::filtered_hosts(&app);
    assert_eq!(visible.len(), 2);
}

#[test]
fn tunnel_host_picker_backspace_on_empty_query_closes() {
    // Mirrors the jump: Backspace on an empty buffer cancels
    // the overlay so a single keystroke gets you out without reaching Esc.
    let mut app = make_app("Host alpha\n  HostName a\n");
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::TunnelHostPicker;
    app.ui.tunnel_host_picker_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Backspace), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(matches!(app.top_page, crate::app::TopPage::Tunnels));
}

#[test]
fn tunnel_host_picker_substring_match() {
    // Substring (not subsequence) keeps semantics in lock-step with the
    // jump: a contiguous run of query chars must appear in
    // either the alias or the hostname.
    let mut app = make_app(
        "Host db-primary\n  HostName a\n\
         Host other\n  HostName db.internal\n\
         Host nope\n  HostName 1.2.3.4\n",
    );
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::TunnelHostPicker;
    app.ui.tunnel_host_picker_query = "db".to_string();
    let visible = crate::handler::tunnel_host_picker::filtered_hosts(&app);
    let aliases: Vec<&str> = visible.iter().map(|(a, _)| a.as_str()).collect();
    assert_eq!(aliases, vec!["db-primary", "other"]);
}

#[test]
fn tunnel_host_picker_enter_uses_filtered_index() {
    // Enter must select the host at the cursor's position within the
    // filtered set, not the underlying full list.
    let mut app = make_app(
        "Host alpha\n  HostName a\n\n\
         Host beta\n  HostName b\n\n\
         Host db-primary\n  HostName 10.30.1.5\n",
    );
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::TunnelHostPicker;
    app.ui.tunnel_host_picker_query = "db".to_string();
    app.ui.tunnel_host_picker_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    match &app.screen {
        Screen::TunnelForm { alias, editing } => {
            assert_eq!(alias, "db-primary");
            assert!(editing.is_none());
        }
        other => panic!("expected TunnelForm, got {:?}", other.variant_name()),
    }
    assert!(app.ui.tunnel_host_picker_query.is_empty());
}

#[test]
fn tunnel_host_picker_no_match_blocks_enter() {
    // When the query filters out every host, the cursor is None and Enter
    // must be a no-op (no panic, no transition).
    let mut app = make_app("Host alpha\n  HostName a\n");
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::TunnelHostPicker;
    app.ui.tunnel_host_picker_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('z')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('z')), &tx);
    assert_eq!(app.ui.tunnel_host_picker_state.selected(), None);
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(matches!(app.screen, Screen::TunnelHostPicker));
}

#[test]
fn tunnel_host_picker_query_matches_hostname_too() {
    // Substring match runs on both alias and hostname so users can find a
    // host by IP fragment when they don't remember the alias.
    let mut app = make_app(
        "Host bastion\n  HostName 140.82.121.3\n\n\
         Host db\n  HostName 10.30.1.5\n",
    );
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::TunnelHostPicker;
    app.ui.tunnel_host_picker_query = "10.30".to_string();
    let visible = crate::handler::tunnel_host_picker::filtered_hosts(&app);
    assert_eq!(visible.len(), 1);
    assert_eq!(visible[0].0, "db");
}

#[test]
fn tunnels_overview_e_opens_form_for_selected_row() {
    let mut app = make_tunnels_overview_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    match &app.screen {
        Screen::TunnelForm { alias, editing } => {
            assert_eq!(alias, "test");
            assert_eq!(*editing, Some(0));
        }
        other => panic!("expected TunnelForm, got {:?}", other.variant_name()),
    }
    // Form should be pre-populated with the row's tunnel data.
    assert_eq!(app.tunnels.form.bind_port, "8080");
    assert_eq!(app.tunnels.form.remote_host, "localhost");
    assert_eq!(app.tunnels.form.remote_port, "80");
}

#[test]
fn tunnels_overview_d_sets_pending_delete() {
    let mut app = make_tunnels_overview_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    assert_eq!(app.tunnels.pending_delete, Some(0));
    assert!(matches!(app.screen, Screen::HostList));
}

#[test]
fn tunnels_overview_pending_delete_y_removes_tunnel_and_returns_to_overview() {
    let mut app = make_tunnels_overview_app();
    app.tunnels.pending_delete = Some(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    assert_eq!(app.tunnels.pending_delete, None);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(matches!(app.top_page, crate::app::TopPage::Tunnels));
    // Tunnel directive must be gone from the in-memory ssh_config.
    let rules = app.hosts_state.ssh_config.find_tunnel_directives("test");
    assert!(rules.is_empty(), "tunnel should have been removed");
}

#[test]
fn tunnels_overview_pending_delete_n_cancels() {
    let mut app = make_tunnels_overview_app();
    app.tunnels.pending_delete = Some(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert_eq!(app.tunnels.pending_delete, None);
    let rules = app.hosts_state.ssh_config.find_tunnel_directives("test");
    assert_eq!(rules.len(), 1, "tunnel must still be present after cancel");
}

fn make_multi_tunnel_overview_app() -> App {
    let mut app = make_app(
        "Host alpha\n  HostName a.example\n  LocalForward 8080 localhost:80\n\
         Host beta\n  HostName b.example\n  LocalForward 9090 localhost:90\n\
         Host gamma\n  HostName c.example\n  LocalForward 7070 localhost:70\n",
    );
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::HostList;
    app.ui.tunnels_overview_state.select(Some(0));
    app
}

#[test]
fn tunnels_overview_search_down_navigates_filtered_list_without_leaving_input_mode() {
    // While typing into the tunnels-tab search box, ↓/Tab must move the
    // cursor through the filtered set without dismissing the input —
    // mirroring host-list search ergonomics so the muscle memory is
    // shared across tabs.
    let mut app = make_multi_tunnel_overview_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('/')), &tx);
    assert!(app.search.query.is_some(), "/ enters search mode");
    let _ = handle_key_event(&mut app, key(KeyCode::Down), &tx);
    assert!(
        app.search.query.is_some(),
        "Down must NOT exit search input mode"
    );
    assert_eq!(app.ui.tunnels_overview_state.selected(), Some(1));
}

#[test]
fn tunnels_overview_search_enter_acts_on_highlighted_row_and_dismisses_input() {
    // Enter while searching should behave like Enter outside search:
    // act on the highlighted tunnel (toggle start/stop) and clear the
    // query so subsequent keys navigate normally. Demo mode would
    // forbid the actual ssh spawn, so we just check the input was
    // dismissed and the screen stayed put.
    let mut app = make_tunnels_overview_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('/')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(
        app.search.query.is_none(),
        "Enter must dismiss the search input"
    );
}

#[test]
fn tunnels_overview_search_esc_clears_query_and_resets_cursor() {
    let mut app = make_multi_tunnel_overview_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('/')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('b')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(app.search.query.is_none());
    assert_eq!(app.ui.tunnels_overview_state.selected(), Some(0));
}

#[test]
fn tunnels_overview_n_opens_whats_new() {
    // The tunnels tab should reach the What's New overlay with the
    // same single-key shortcut as the host list — discoverable from
    // either main tab.
    let mut app = make_tunnels_overview_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('n')), &tx);
    assert!(matches!(app.screen, Screen::WhatsNew(_)));
}

#[test]
fn tunnels_overview_pending_delete_esc_cancels() {
    let mut app = make_tunnels_overview_app();
    app.tunnels.pending_delete = Some(0);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert_eq!(app.tunnels.pending_delete, None);
    let rules = app.hosts_state.ssh_config.find_tunnel_directives("test");
    assert_eq!(rules.len(), 1);
}

#[test]
fn tunnels_overview_pending_delete_other_key_ignored() {
    let mut app = make_tunnels_overview_app();
    app.tunnels.pending_delete = Some(0);
    let (tx, _rx) = mpsc::channel();
    // A stray character must not silently transition state — pending_delete
    // stays armed, the tunnel stays in the config, and no toast fires.
    let _ = handle_key_event(&mut app, key(KeyCode::Char('t')), &tx);
    assert_eq!(app.tunnels.pending_delete, Some(0));
    let rules = app.hosts_state.ssh_config.find_tunnel_directives("test");
    assert_eq!(rules.len(), 1);
}

#[test]
fn tunnel_form_esc_from_tunnels_overview_returns_to_overview() {
    // When the form was opened from the Tunnels-tab overview the cancel
    // path must hop back to the overview, not to the per-host TunnelList.
    let mut app = make_tunnels_overview_app();
    app.screen = Screen::TunnelForm {
        alias: "test".to_string(),
        editing: None,
    };
    app.tunnels.form = crate::app::TunnelForm::new();
    app.capture_tunnel_form_baseline();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(matches!(app.top_page, crate::app::TopPage::Tunnels));
}

#[test]
fn tunnel_form_submit_from_tunnels_overview_returns_to_overview() {
    let mut app = make_tunnels_overview_app();
    app.screen = Screen::TunnelForm {
        alias: "test".to_string(),
        editing: None,
    };
    app.tunnels.form = crate::app::TunnelForm::new();
    app.tunnels.form.bind_port = "9090".to_string();
    app.tunnels.form.remote_host = "internal.corp".to_string();
    app.tunnels.form.remote_port = "443".to_string();
    app.capture_form_mtime();
    app.capture_tunnel_form_baseline();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(matches!(app.screen, Screen::HostList));
    assert!(matches!(app.top_page, crate::app::TopPage::Tunnels));
    let rules = app.hosts_state.ssh_config.find_tunnel_directives("test");
    assert_eq!(rules.len(), 2, "new tunnel should be persisted");
}

#[test]
fn tunnel_form_esc_from_host_detail_still_returns_to_tunnel_list() {
    // Regression guard: the existing host-detail flow must keep returning
    // to TunnelList. Only top_page == Tunnels redirects to the overview.
    let mut app = make_app("Host test\n  HostName test.com\n");
    app.top_page = crate::app::TopPage::Hosts;
    app.screen = Screen::TunnelForm {
        alias: "test".to_string(),
        editing: None,
    };
    app.tunnels.form = crate::app::TunnelForm::new();
    app.capture_tunnel_form_baseline();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(matches!(app.screen, Screen::TunnelList { .. }));
}

// =========================================================================
// Tunnels overview: sort cycling and jump
// =========================================================================

#[test]
fn tunnels_overview_s_cycles_sort_mode() {
    use crate::app::TunnelSortMode;
    let mut app = make_tunnels_overview_app();
    assert_eq!(app.tunnels.sort_mode, TunnelSortMode::MostRecent);
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('s')), &tx);
    assert_eq!(app.tunnels.sort_mode, TunnelSortMode::AlphaHostname);
    let toast = app.status_center.toast.as_ref().expect("toast");
    assert!(toast.text.contains("A-Z hostname"));
    let _ = handle_key_event(&mut app, key(KeyCode::Char('s')), &tx);
    assert_eq!(app.tunnels.sort_mode, TunnelSortMode::MostRecent);
    let toast = app.status_center.toast.as_ref().expect("toast");
    assert!(toast.text.contains("most recent"));
}

#[test]
fn tunnels_overview_colon_opens_jump_in_tunnels_mode() {
    let mut app = make_tunnels_overview_app();
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char(':')), &tx);
    let jump = app.jump.as_ref().expect("jump bar open");
    assert_eq!(jump.mode, crate::app::JumpMode::Tunnels);
    let cmds = jump.filtered_commands();
    assert!(
        cmds.iter()
            .any(|c| c.key == 's' && c.label.contains("Tunnels: Sort")),
        "tunnels jump bar must include the Tunnels: Sort action"
    );
    // The unified jump bar shows host-actions on every tab now too.
    assert!(
        cmds.iter().any(|c| c.label.contains("Hosts: Ping host")),
        "Hosts: Ping host must be reachable from the tunnels-tab jump bar"
    );
}

#[test]
fn tunnels_overview_jump_enter_dispatches_sort() {
    let mut app = make_tunnels_overview_app();
    app.jump = Some(crate::app::JumpState::for_mode(
        crate::app::JumpMode::Tunnels,
    ));
    app.jump.as_mut().unwrap().push_query('s');
    app.jump.as_mut().unwrap().push_query('o');
    app.jump.as_mut().unwrap().push_query('r');
    app.jump.as_mut().unwrap().push_query('t');
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(app.jump.is_none(), "jump bar should close after Enter");
    assert_eq!(
        app.tunnels.sort_mode,
        crate::app::TunnelSortMode::AlphaHostname,
        "sort cycled via jump dispatch"
    );
}

#[test]
fn tunnels_overview_jump_dispatches_to_tunnels_handler() {
    // Sanity: 'a' in Tunnels-mode jump bar routes through the tunnels handler
    // (which opens the host picker), not the host-list handler. The
    // mode-match boost ensures the tunnel-targeted action wins on a
    // single-char `a` query against the unified action set.
    let mut app = make_tunnels_overview_app();
    app.jump = Some(crate::app::JumpState::for_mode(
        crate::app::JumpMode::Tunnels,
    ));
    app.jump.as_mut().unwrap().push_query('a');
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Enter), &tx);
    assert!(matches!(app.screen, Screen::TunnelHostPicker));
}

#[test]
fn tunnels_overview_edit_after_sort_targets_visible_row() {
    // Regression: with two hosts in storage order zzz, aaa and AlphaHostname
    // sort active, the row at cursor 0 visually is `aaa`. Pressing 'e' must
    // open the form for `aaa`, not `zzz`. Before the visible_pairs refactor
    // the handler walked raw storage order and would target `zzz`.
    let mut app = make_app(
        "Host zzz\n  HostName z.example\n  LocalForward 9090 localhost:90\n\
         Host aaa\n  HostName a.example\n  LocalForward 8080 localhost:80\n",
    );
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::HostList;
    app.tunnels.sort_mode = crate::app::TunnelSortMode::AlphaHostname;
    app.ui.tunnels_overview_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('e')), &tx);
    match &app.screen {
        Screen::TunnelForm { alias, editing } => {
            assert_eq!(
                alias, "aaa",
                "edit must target visible row, not storage row"
            );
            assert!(editing.is_some());
        }
        other => panic!("expected TunnelForm, got {:?}", other.variant_name()),
    }
}

#[test]
fn tunnels_overview_reposition_cursor_follows_row_to_new_index() {
    // Regression: when MostRecent reorders rows after a tunnel toggle, the
    // cursor must follow the row the user acted on, not stay at the old
    // visual index (which would now point at a different host).
    use crate::tunnel::{TunnelRule, TunnelType};
    let mut app = make_app(
        "Host aaa\n  HostName a.example\n  LocalForward 8080 localhost:80\n\
         Host zzz\n  HostName z.example\n  LocalForward 9090 localhost:90\n",
    );
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::HostList;
    app.tunnels.sort_mode = crate::app::TunnelSortMode::MostRecent;
    app.ui.tunnels_overview_state.select(Some(1));
    let zzz_rule = TunnelRule {
        tunnel_type: TunnelType::Local,
        bind_address: String::new(),
        bind_port: 9090,
        remote_host: "localhost".to_string(),
        remote_port: 90,
    };
    // Simulate the sort-key change a tunnel start would cause: zzz becomes
    // the most-recently-connected host, jumping from index 1 to index 0.
    app.history.record("zzz");
    crate::handler::tunnels_overview::reposition_cursor_on(&mut app, "zzz", &zzz_rule);
    let pairs = crate::ui::tunnels_overview::visible_pairs(&app);
    let zzz_idx = pairs.iter().position(|(a, _)| a == "zzz").expect("zzz row");
    assert_eq!(
        app.ui.tunnels_overview_state.selected(),
        Some(zzz_idx),
        "cursor must follow zzz to its new sorted position"
    );
}

#[test]
fn tunnels_overview_delete_after_sort_targets_visible_row() {
    // Regression: pending_delete index must resolve through the visible
    // (filtered + sorted) sequence, not raw storage order.
    let mut app = make_app(
        "Host zzz\n  HostName z.example\n  LocalForward 9090 localhost:90\n\
         Host aaa\n  HostName a.example\n  LocalForward 8080 localhost:80\n",
    );
    app.top_page = crate::app::TopPage::Tunnels;
    app.screen = Screen::HostList;
    app.tunnels.sort_mode = crate::app::TunnelSortMode::AlphaHostname;
    app.ui.tunnels_overview_state.select(Some(0));
    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Char('d')), &tx);
    let _ = handle_key_event(&mut app, key(KeyCode::Char('y')), &tx);
    // After deletion of `aaa`'s tunnel, only `zzz`'s tunnel should remain.
    let aaa_rules = app.hosts_state.ssh_config.find_tunnel_directives("aaa");
    let zzz_rules = app.hosts_state.ssh_config.find_tunnel_directives("zzz");
    assert!(
        aaa_rules.is_empty(),
        "aaa's tunnel should have been removed"
    );
    assert_eq!(zzz_rules.len(), 1, "zzz's tunnel must be untouched");
}

#[test]
fn esc_on_tunnels_overview_does_not_quit_first_press_shows_hint() {
    let mut app = make_tunnels_overview_app();
    assert!(app.running);
    assert!(!app.esc_quit_hint_shown);

    let (tx, _rx) = mpsc::channel();
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);

    assert!(app.running, "Esc on idle tunnels overview must not quit");
    assert!(app.esc_quit_hint_shown);
    let toast = app
        .status_center
        .toast
        .as_ref()
        .expect("first idle Esc must surface a toast");
    assert_eq!(toast.text, crate::messages::ESC_QUIT_HINT);
}

#[test]
fn esc_on_tunnels_overview_second_press_silent_noop() {
    let mut app = make_tunnels_overview_app();
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    app.status_center.toast = None;

    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);

    assert!(app.running);
    assert!(
        app.status_center.toast.is_none(),
        "second idle Esc must stay silent"
    );
}

#[test]
fn q_on_tunnels_overview_still_quits() {
    let mut app = make_tunnels_overview_app();
    let (tx, _rx) = mpsc::channel();

    let _ = handle_key_event(&mut app, key(KeyCode::Char('q')), &tx);

    assert!(!app.running, "q must always quit on tunnels overview");
}

#[test]
fn esc_hint_does_not_displace_active_sticky_error_toast() {
    let mut app = make_app("Host test\n  HostName test.com\n");
    let (tx, _rx) = mpsc::channel();

    // notify_error surfaces a sticky Error-class toast — exactly the kind of
    // message that must not be silently clobbered by an informational hint.
    app.notify_error("provider sync failed");
    let sticky = app
        .status_center
        .toast
        .as_ref()
        .expect("notify_error must land in the toast slot");
    assert!(sticky.sticky, "Error toasts are sticky by default");
    let sticky_text = sticky.text.clone();

    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);

    assert!(app.running, "Esc must not quit");
    assert!(
        !app.esc_quit_hint_shown,
        "flag must stay unset when the hint was suppressed by a sticky toast"
    );
    assert_eq!(
        app.status_center
            .toast
            .as_ref()
            .map(|t| t.text.as_str())
            .unwrap_or(""),
        sticky_text,
        "sticky Error toast must remain visible, hint must not displace it"
    );

    // Once the sticky toast is cleared, a later idle Esc surfaces the hint as
    // designed and arms the one-shot flag.
    app.status_center.toast = None;
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(app.esc_quit_hint_shown);
    assert_eq!(
        app.status_center.toast.as_ref().map(|t| t.text.as_str()),
        Some(crate::messages::ESC_QUIT_HINT)
    );
}

#[test]
fn esc_hint_flag_is_shared_between_host_list_and_tunnels_overview() {
    let mut app = make_app("Host test\n  HostName test.com\n  LocalForward 8080 localhost:80\n");
    let (tx, _rx) = mpsc::channel();

    // First idle Esc on host list arms the hint flag.
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);
    assert!(app.esc_quit_hint_shown);
    app.status_center.toast = None;

    // Switch to tunnels overview and press Esc again. The shared flag means
    // the hint stays silent — the user already learned about `q`.
    app.top_page = crate::app::TopPage::Tunnels;
    app.ui.tunnels_overview_state.select(Some(0));
    let _ = handle_key_event(&mut app, key(KeyCode::Esc), &tx);

    assert!(app.running);
    assert!(
        app.status_center.toast.is_none(),
        "second-tab idle Esc must not re-surface the hint"
    );
}
