//! CLI subcommand handlers. Each function handles one clap subcommand
//! (provider, tunnel, password, snippet, add, import, sync, logs, theme,
//! vault sign) and runs outside the TUI in a non-interactive terminal context.

use anyhow::{Context, Result};
use std::path::Path;

use crate::providers;
use crate::providers::ProviderKind;
use crate::snippet;
use crate::ssh_config::model::{HostEntry, SshConfigFile};
use crate::vault_ssh;

use super::cli_args::{
    PasswordCommands, ProviderCommands, SnippetCommands, ThemeCommands, TunnelCommands,
};
use super::{askpass, import, logging, preferences, quick_add, should_write_certificate_file, ui};

pub fn handle_quick_add(
    mut config: SshConfigFile,
    target: &str,
    alias: Option<&str>,
    key: Option<&str>,
) -> Result<()> {
    log::info!(
        "[purple] cli add: target={} alias={:?} key={:?}",
        target,
        alias,
        key
    );
    let parsed = quick_add::parse_target(target).map_err(|e| anyhow::anyhow!(e))?;

    let alias_str = alias.map(|a| a.to_string()).unwrap_or_else(|| {
        parsed
            .hostname
            .split('.')
            .next()
            .unwrap_or(&parsed.hostname)
            .to_string()
    });

    if alias_str.trim().is_empty() {
        eprintln!("{}", crate::messages::cli::ALIAS_EMPTY);
        std::process::exit(1);
    }
    if alias_str.contains(char::is_whitespace) {
        eprintln!("{}", crate::messages::cli::ALIAS_WHITESPACE);
        std::process::exit(1);
    }
    if crate::ssh_config::model::is_host_pattern(&alias_str) {
        eprintln!("{}", crate::messages::cli::ALIAS_PATTERN_CHARS);
        std::process::exit(1);
    }

    // Reject control characters in alias, hostname, user and key
    let key_val = key.unwrap_or("").to_string();
    for (value, name) in [
        (&alias_str, "Alias"),
        (&parsed.hostname, "Hostname"),
        (&parsed.user, "User"),
        (&key_val, "Identity file"),
    ] {
        if value.chars().any(|c| c.is_control()) {
            eprintln!("{}", crate::messages::cli::control_chars(name));
            std::process::exit(1);
        }
    }

    // Reject whitespace in hostname and user (matches TUI validation)
    if parsed.hostname.contains(char::is_whitespace) {
        eprintln!("{}", crate::messages::cli::HOSTNAME_WHITESPACE);
        std::process::exit(1);
    }
    if parsed.user.contains(char::is_whitespace) {
        eprintln!("{}", crate::messages::cli::USER_WHITESPACE);
        std::process::exit(1);
    }

    if config.has_host(&alias_str) {
        eprintln!("{}", crate::messages::cli::alias_already_exists(&alias_str));
        std::process::exit(1);
    }

    let entry = HostEntry {
        alias: alias_str.clone(),
        hostname: parsed.hostname,
        user: parsed.user,
        port: parsed.port,
        identity_file: key_val,
        ..Default::default()
    };

    config.add_host(&entry);
    log::debug!("[config] cli add: writing ssh config (alias={})", alias_str);
    config.write()?;
    log::info!("[purple] cli add: host added alias={}", alias_str);
    println!("{}", crate::messages::cli::welcome(&alias_str));
    Ok(())
}

pub fn handle_import(
    mut config: SshConfigFile,
    file: Option<&str>,
    known_hosts: bool,
    group: Option<&str>,
) -> Result<()> {
    log::info!(
        "[purple] cli import: source={} group={:?}",
        if known_hosts {
            "known_hosts".to_string()
        } else {
            file.unwrap_or("(missing)").to_string()
        },
        group
    );
    let result = if known_hosts {
        import::import_from_known_hosts(&mut config, group)
    } else if let Some(path) = file {
        let resolved = super::resolve_config_path(path)?;
        import::import_from_file(&mut config, &resolved, group)
    } else {
        eprintln!("{}", crate::messages::cli::IMPORT_NO_FILE);
        std::process::exit(1);
    };

    match result {
        Ok((imported, skipped, parse_failures, read_errors)) => {
            if imported > 0 {
                log::debug!(
                    "[config] cli import: writing ssh config ({} new hosts)",
                    imported
                );
                config.write()?;
            }
            log::info!(
                "[purple] cli import: imported={} skipped={} parse_failures={} read_errors={}",
                imported,
                skipped,
                parse_failures,
                read_errors
            );
            println!("{}", crate::messages::imported_hosts(imported, skipped));
            if parse_failures > 0 {
                eprintln!(
                    "{}",
                    crate::messages::cli::import_parse_failures(parse_failures)
                );
            }
            if read_errors > 0 {
                eprintln!("{}", crate::messages::cli::import_read_errors(read_errors));
            }
            Ok(())
        }
        Err(e) => {
            eprintln!("{}", e);
            std::process::exit(1);
        }
    }
}

pub fn handle_sync(
    mut config: SshConfigFile,
    provider_name: Option<&str>,
    dry_run: bool,
    remove: bool,
) -> Result<()> {
    log::info!(
        "[purple] cli sync: provider={:?} dry_run={} remove={}",
        provider_name,
        dry_run,
        remove
    );
    let provider_config = providers::config::ProviderConfig::load();
    // The positional argument accepts either a bare provider name (sync ALL
    // configs of that provider) or a labeled identifier `provider:label`
    // (sync exactly that one config). No explicit flag form.
    let sections: Vec<&providers::config::ProviderSection> = if let Some(arg) = provider_name {
        let id: providers::config::ProviderConfigId = match arg.parse() {
            Ok(id) => id,
            Err(e) => {
                eprintln!("{}: {}", arg, e);
                std::process::exit(1);
            }
        };
        if providers::get_provider(&id.provider).is_none() {
            eprintln!("{}", crate::messages::cli::unknown_provider(&id.provider));
            std::process::exit(1);
        }
        let matched: Vec<&providers::config::ProviderSection> = match &id.label {
            Some(_) => provider_config.section_by_id(&id).into_iter().collect(),
            None => provider_config.sections_for_provider(&id.provider),
        };
        if matched.is_empty() {
            eprintln!("{}", crate::messages::cli::no_config_for(arg));
            std::process::exit(1);
        }
        matched
    } else {
        let configured = provider_config.configured_providers();
        if configured.is_empty() {
            eprintln!("{}", crate::messages::cli::NO_PROVIDERS);
            std::process::exit(1);
        }
        configured.iter().collect()
    };

    let mut any_changes = false;
    let mut any_failures = false;
    let mut any_hard_failures = false;
    let mut all_renames: Vec<(String, String)> = Vec::new();

    for section in &sections {
        let provider = match providers::get_provider_with_config(section) {
            Some(p) => p,
            None => {
                log::warn!(
                    "[config] cli sync: skipping unknown provider '{}'",
                    section.provider()
                );
                eprintln!(
                    "{}",
                    crate::messages::cli::skipping_unknown_provider(section.provider())
                );
                any_failures = true;
                // Not a hard failure: unknown provider contributes no changes,
                // so other providers' successful results should still be written.
                continue;
            }
        };
        let display_name = providers::provider_display_name(section.provider());
        log::debug!(
            "[external] cli sync: starting provider={} label={:?}",
            section.provider(),
            section.id.label
        );
        let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
        print!("{}", crate::messages::cli::syncing_start(display_name));
        let _ = std::io::Write::flush(&mut std::io::stdout());

        let last_summary = std::cell::RefCell::new(String::new());
        let progress = |msg: &str| {
            *last_summary.borrow_mut() = msg.to_string();
            if is_tty {
                print!("{}", crate::messages::cli::syncing(display_name, msg));
                let _ = std::io::Write::flush(&mut std::io::stdout());
            }
        };
        let fetch_result = provider.fetch_hosts_with_progress(
            &section.token,
            &std::sync::atomic::AtomicBool::new(false),
            &progress,
        );
        let summary = last_summary.into_inner();
        // Complete the Syncing line: TTY overwrites with summary; non-TTY appends.
        if is_tty {
            if summary.is_empty() {
                print!("{}", crate::messages::cli::syncing(display_name, ""));
            } else {
                println!("{}", crate::messages::cli::syncing(display_name, &summary));
            }
            let _ = std::io::Write::flush(&mut std::io::stdout());
        } else if !summary.is_empty() {
            println!("{}", summary);
        }
        let (hosts, suppress_remove) = match fetch_result {
            Ok(hosts) => (hosts, false),
            Err(providers::ProviderError::PartialResult {
                hosts,
                failures,
                total,
            }) => {
                println!(
                    "{}",
                    crate::messages::cli::servers_found_with_failures(hosts.len(), failures, total)
                );
                if remove {
                    eprintln!("{}", crate::messages::cli::sync_skip_remove(display_name));
                }
                any_failures = true;
                (hosts, true)
            }
            Err(e) => {
                println!("{}", crate::messages::cli::SYNC_FAILED);
                eprintln!("{}", crate::messages::cli::sync_error(display_name, &e));
                any_failures = true;
                any_hard_failures = true;
                continue;
            }
        };
        if !suppress_remove {
            println!("{}", crate::messages::cli::servers_found(hosts.len()));
        }
        let effective_remove = remove && !suppress_remove;
        let result = providers::sync::sync_provider(
            &mut config,
            &*provider,
            &hosts,
            section,
            effective_remove,
            suppress_remove, // suppress stale marking when partial failures occurred
            dry_run,
        );
        let prefix = if dry_run {
            crate::messages::cli::SYNC_RESULT_PREFIX_DRY_RUN
        } else {
            crate::messages::cli::SYNC_RESULT_PREFIX_LIVE
        };
        println!(
            "{}",
            crate::messages::cli::sync_result(
                prefix,
                result.added,
                result.updated,
                result.unchanged
            )
        );
        if result.removed > 0 {
            println!("{}", crate::messages::cli::sync_removed(result.removed));
        }
        if result.stale > 0 {
            println!("{}", crate::messages::cli::sync_stale(result.stale));
        }
        if result.added > 0 || result.updated > 0 || result.removed > 0 || result.stale > 0 {
            any_changes = true;
        }
        if !dry_run {
            all_renames.extend(result.renames);
        }
    }

    if any_changes && !dry_run {
        if any_hard_failures {
            log::warn!("[config] cli sync: skipping ssh config write due to hard failures");
            eprintln!("{}", crate::messages::cli::SYNC_SKIP_WRITE);
        } else {
            log::debug!("[config] cli sync: writing ssh config");
            config.write()?;
            log::info!("[purple] cli sync: ssh config written");
            // Migrate per-host state keyed by alias for every host the
            // sync renamed. Tied to the successful config write: a
            // skipped or failed write must not move history/recents
            // to a new alias that did not land in `~/.ssh/config`.
            if !all_renames.is_empty() {
                log::info!(
                    "[purple] cli sync: migrating per-host state for {} rename(s)",
                    all_renames.len()
                );
                crate::app::migrate_renames_persistent_state(&all_renames);
            }
        }
    }

    if any_failures {
        log::warn!("[purple] cli sync: completed with failures (exit 1)");
        std::process::exit(1);
    }

    log::info!("[purple] cli sync: completed successfully");
    Ok(())
}

pub fn handle_provider_command(command: ProviderCommands) -> Result<()> {
    log::info!("[purple] cli provider: dispatch");
    match command {
        ProviderCommands::Add {
            provider,
            token,
            token_stdin,
            mut prefix,
            mut user,
            mut key,
            url,
            mut profile,
            mut regions,
            mut project,
            mut compartment,
            no_verify_tls,
            verify_tls,
            auto_sync,
            no_auto_sync,
            label,
        } => {
            let p = match providers::get_provider(&provider) {
                Some(p) => p,
                None => {
                    eprintln!(
                        "Never heard of '{}'. Try: digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, scaleway, gcp, azure, tailscale, oracle, ovh, leaseweb, i3d, transip.",
                        provider
                    );
                    std::process::exit(1);
                }
            };
            // provider is validated above, so from_str always returns Some here.
            let kind = provider.parse::<ProviderKind>().ok();

            // --url, --no-verify-tls and --verify-tls are Proxmox-only; clear them for other providers
            let mut token = token;
            let mut url = url;
            let mut no_verify_tls = no_verify_tls;
            let mut verify_tls = verify_tls;
            if kind != Some(ProviderKind::Proxmox) {
                if url.is_some() {
                    eprintln!("{}", crate::messages::cli::WARN_URL_NOT_USED);
                    url = None;
                }
                if no_verify_tls {
                    eprintln!("{}", crate::messages::cli::WARN_NO_VERIFY_TLS_NOT_USED);
                    no_verify_tls = false;
                }
                if verify_tls {
                    eprintln!("{}", crate::messages::cli::WARN_VERIFY_TLS_NOT_USED);
                    verify_tls = false;
                }
            }
            // --profile is AWS-only, --regions is AWS/Scaleway/GCP/Azure, --project is GCP-only
            if kind != Some(ProviderKind::Aws) && profile.is_some() {
                eprintln!("{}", crate::messages::cli::WARN_PROFILE_NOT_USED);
                profile = None;
            }
            if !kind.is_some_and(ProviderKind::accepts_cli_regions) && regions.is_some() {
                eprintln!("{}", crate::messages::cli::WARN_REGIONS_NOT_USED);
                regions = None;
            }
            if kind != Some(ProviderKind::Gcp) && project.is_some() {
                eprintln!("{}", crate::messages::cli::WARN_PROJECT_NOT_USED);
                project = None;
            }
            if kind != Some(ProviderKind::Oracle) && compartment.is_some() {
                eprintln!("{}", crate::messages::cli::WARN_COMPARTMENT_NOT_USED);
                compartment = None;
            }

            // When updating an existing section, fall back to stored values for fields not supplied
            let existing_section = providers::config::ProviderConfig::load()
                .section(&provider)
                .cloned();

            if let Some(ref existing) = existing_section {
                // URL fallback only applies to Proxmox (only provider that uses the url field)
                if kind == Some(ProviderKind::Proxmox) && url.is_none() && !existing.url.is_empty()
                {
                    url = Some(existing.url.clone());
                }
                if token.is_none()
                    && !token_stdin
                    && std::env::var("PURPLE_TOKEN").is_err()
                    && !existing.token.is_empty()
                {
                    token = Some(existing.token.clone());
                }
                if prefix.is_none() {
                    prefix = Some(existing.alias_prefix.clone());
                }
                if user.is_none() {
                    user = Some(existing.user.clone());
                }
                if key.is_none() && !existing.identity_file.is_empty() {
                    key = Some(existing.identity_file.clone());
                }
                // Preserve verify_tls=false unless the user explicitly overrides it either way
                if !no_verify_tls && !verify_tls && !existing.verify_tls {
                    no_verify_tls = true;
                }
                // AWS: fall back to stored profile/regions
                if kind == Some(ProviderKind::Aws)
                    && profile.is_none()
                    && !existing.profile.is_empty()
                {
                    profile = Some(existing.profile.clone());
                }
                // Providers that accept --regions: fall back to stored regions
                if kind.is_some_and(ProviderKind::accepts_cli_regions)
                    && regions.is_none()
                    && !existing.regions.is_empty()
                {
                    regions = Some(existing.regions.clone());
                }
                // GCP: fall back to stored project
                if kind == Some(ProviderKind::Gcp)
                    && project.is_none()
                    && !existing.project.is_empty()
                {
                    project = Some(existing.project.clone());
                }
                // Oracle: fall back to stored compartment
                if kind == Some(ProviderKind::Oracle)
                    && compartment.is_none()
                    && !existing.compartment.is_empty()
                {
                    compartment = Some(existing.compartment.clone());
                }
            }

            // Proxmox requires --url
            if kind == Some(ProviderKind::Proxmox) {
                if url.is_none() || url.as_deref().unwrap_or("").trim().is_empty() {
                    eprintln!("{}", crate::messages::cli::PROXMOX_URL_REQUIRED);
                    std::process::exit(1);
                }
                let u = url.as_deref().unwrap();
                if !u.to_ascii_lowercase().starts_with("https://") {
                    eprintln!("{}", crate::messages::cli::PROVIDER_URL_REQUIRES_HTTPS);
                    std::process::exit(1);
                }
            }

            // AWS allows empty token when --profile is set
            let aws_has_profile = kind == Some(ProviderKind::Aws)
                && profile.as_deref().is_some_and(|p| !p.trim().is_empty());
            let token = if aws_has_profile
                && token.is_none()
                && !token_stdin
                && std::env::var("PURPLE_TOKEN").is_err()
            {
                String::new()
            } else {
                match super::resolve_token(token, token_stdin) {
                    Ok(t) => t,
                    Err(e) => {
                        eprintln!("{}", e);
                        std::process::exit(1);
                    }
                }
            };

            if token.trim().is_empty() && !aws_has_profile && kind != Some(ProviderKind::Tailscale)
            {
                if kind == Some(ProviderKind::Gcp) {
                    eprintln!("{}", crate::messages::cli::PROVIDER_TOKEN_REQUIRED_GCP);
                } else if kind == Some(ProviderKind::Oracle) {
                    eprintln!("{}", crate::messages::cli::PROVIDER_TOKEN_REQUIRED_ORACLE);
                } else {
                    eprintln!(
                        "{}",
                        crate::messages::cli::provider_token_required(
                            providers::provider_display_name(&provider)
                        )
                    );
                }
                std::process::exit(1);
            }

            let alias_prefix = prefix.unwrap_or_else(|| p.short_label().to_string());
            if crate::ssh_config::model::is_host_pattern(&alias_prefix) {
                eprintln!("{}", crate::messages::cli::ALIAS_PREFIX_INVALID);
                std::process::exit(1);
            }

            let user = user.unwrap_or_else(|| "root".to_string());
            let identity_file = key.unwrap_or_default();

            // Reject control characters in all fields (prevents INI injection)
            let url_value = url.clone().unwrap_or_default();
            let profile_value = profile.clone().unwrap_or_default();
            let regions_value = regions.clone().unwrap_or_default();
            let project_value = project.clone().unwrap_or_default();
            let compartment_value = compartment.clone().unwrap_or_default();
            for (value, name) in [
                (&url_value, "URL"),
                (&token, "Token"),
                (&alias_prefix, "Alias prefix"),
                (&user, "User"),
                (&identity_file, "Identity file"),
                (&profile_value, "Profile"),
                (&project_value, "Project"),
                (&regions_value, "Regions"),
                (&compartment_value, "Compartment"),
            ] {
                if value.chars().any(|c| c.is_control()) {
                    eprintln!("{}", crate::messages::cli::control_chars(name));
                    std::process::exit(1);
                }
            }
            if user.contains(char::is_whitespace) {
                eprintln!("{}", crate::messages::cli::USER_WHITESPACE);
                std::process::exit(1);
            }

            // Resolve auto_sync: explicit flags > existing config > provider default
            let resolved_auto_sync = if auto_sync {
                true
            } else if no_auto_sync {
                false
            } else if let Some(ref existing) = existing_section {
                existing.auto_sync
            } else {
                kind != Some(ProviderKind::Proxmox)
            };

            let resolved_profile = profile.unwrap_or_default();
            let resolved_regions = regions.unwrap_or_default();
            let resolved_project = project.unwrap_or_default();
            let resolved_compartment = compartment.unwrap_or_default();

            // AWS/Scaleway/Azure requires at least one region/zone/subscription
            if kind == Some(ProviderKind::Aws) && resolved_regions.trim().is_empty() {
                eprintln!("{}", crate::messages::cli::AWS_REGIONS_REQUIRED);
                std::process::exit(1);
            }
            if kind == Some(ProviderKind::Scaleway) && resolved_regions.trim().is_empty() {
                eprintln!("{}", crate::messages::cli::SCALEWAY_REGIONS_REQUIRED);
                std::process::exit(1);
            }
            if kind == Some(ProviderKind::Azure) {
                if resolved_regions.trim().is_empty() {
                    eprintln!("{}", crate::messages::cli::AZURE_REGIONS_REQUIRED);
                    std::process::exit(1);
                }
                for sub in resolved_regions
                    .split(',')
                    .map(|s| s.trim())
                    .filter(|s| !s.is_empty())
                {
                    if !providers::azure::is_valid_subscription_id(sub) {
                        eprintln!(
                            "{}",
                            crate::messages::cli::azure_subscription_id_invalid(sub)
                        );
                        std::process::exit(1);
                    }
                }
            }
            // GCP requires --project
            if kind == Some(ProviderKind::Gcp) && resolved_project.trim().is_empty() {
                eprintln!("{}", crate::messages::cli::GCP_PROJECT_REQUIRED);
                std::process::exit(1);
            }
            // Oracle requires --compartment
            if kind == Some(ProviderKind::Oracle) && resolved_compartment.trim().is_empty() {
                eprintln!("{}", crate::messages::cli::ORACLE_COMPARTMENT_REQUIRED);
                std::process::exit(1);
            }

            let mut config = providers::config::ProviderConfig::load();

            // Resolve the target ProviderConfigId given --label and the
            // provider's existing config layout. Rules:
            //   --label X:    add/update [provider:X]; refuse mix with bare
            //   no --label:   single bare config OR the only labeled config
            //                 (if 2+ labeled exist, error: ambiguous)
            let id: providers::config::ProviderConfigId = match label.as_deref() {
                Some(l) => {
                    if let Err(e) = providers::config::validate_label(l) {
                        eprintln!("{}", crate::messages::cli::invalid_label_flag(&e));
                        std::process::exit(1);
                    }
                    providers::config::ProviderConfigId::labeled(provider.clone(), l)
                }
                None => providers::config::ProviderConfigId::bare(provider.clone()),
            };

            // Refuse to mix bare and labeled configs for the same provider:
            // mirrors the parser invariant.
            let existing = config.sections_for_provider(&provider);
            let has_bare = existing.iter().any(|s| s.id.label.is_none());
            let has_labeled = existing.iter().any(|s| s.id.label.is_some());
            if id.label.is_none() && has_labeled {
                eprintln!("{}", crate::messages::cli::add_requires_label(&provider));
                std::process::exit(1);
            }
            if id.label.is_some() && has_bare {
                eprintln!(
                    "{}",
                    crate::messages::cli::add_label_collides_with_bare(&provider)
                );
                std::process::exit(1);
            }

            let section = providers::config::ProviderSection {
                id: id.clone(),
                token,
                alias_prefix,
                user,
                identity_file,
                url: url.unwrap_or_default(),
                verify_tls: !no_verify_tls,
                auto_sync: resolved_auto_sync,
                profile: resolved_profile,
                regions: resolved_regions,
                project: resolved_project,
                compartment: resolved_compartment,
                vault_role: String::new(),
                vault_addr: String::new(),
            };

            config.set_section(section);
            config
                .save()
                .map_err(|e| anyhow::anyhow!("Failed to save: {}", e))?;
            println!("{}", crate::messages::cli::saved_config(&id.to_string()));
            Ok(())
        }
        ProviderCommands::List => {
            let config = providers::config::ProviderConfig::load();
            let sections = config.configured_providers();
            if sections.is_empty() {
                println!("{}", crate::messages::cli::NO_PROVIDERS);
            } else {
                for s in sections {
                    let display_name = providers::provider_display_name(s.provider());
                    let label_suffix = match &s.id.label {
                        Some(l) => format!(" ({})", l),
                        None => String::new(),
                    };
                    println!(
                        "  {:<24} {}-*{:>8}",
                        format!("{}{}", display_name, label_suffix),
                        s.alias_prefix,
                        s.user
                    );
                }
            }
            Ok(())
        }
        ProviderCommands::Remove { provider } => {
            // Accept either `digitalocean` (remove all configs of that
            // provider) or `digitalocean:work` (remove only that one).
            let id: providers::config::ProviderConfigId = match provider.parse() {
                Ok(id) => id,
                Err(e) => {
                    eprintln!("{}: {}", provider, e);
                    std::process::exit(1);
                }
            };
            let mut config = providers::config::ProviderConfig::load();
            let removed = match &id.label {
                Some(_) => {
                    if config.section_by_id(&id).is_none() {
                        eprintln!("{}", crate::messages::cli::no_config_to_remove(&provider));
                        std::process::exit(1);
                    }
                    config.remove_section_by_id(&id);
                    1
                }
                None => {
                    let count = config.sections_for_provider(&id.provider).len();
                    if count == 0 {
                        eprintln!("{}", crate::messages::cli::no_config_to_remove(&provider));
                        std::process::exit(1);
                    }
                    config.remove_section(&id.provider);
                    count
                }
            };
            config
                .save()
                .map_err(|e| anyhow::anyhow!("Failed to save: {}", e))?;
            if removed == 1 {
                println!("{}", crate::messages::cli::removed_config(&provider));
            } else {
                println!(
                    "{}",
                    crate::messages::cli::removed_configs(&provider, removed)
                );
            }
            Ok(())
        }
    }
}

pub fn handle_tunnel_command(mut config: SshConfigFile, command: TunnelCommands) -> Result<()> {
    log::info!("[purple] cli tunnel: dispatch");
    match command {
        TunnelCommands::List { alias } => {
            if let Some(alias) = alias {
                // Show tunnels for a specific host
                if !config.has_host(&alias) {
                    eprintln!("{}", crate::messages::cli::host_not_found(&alias));
                    std::process::exit(1);
                }
                let rules = config.find_tunnel_directives(&alias);
                if rules.is_empty() {
                    println!("{}", crate::messages::cli::no_tunnels_for(&alias));
                } else {
                    println!("{}", crate::messages::cli::tunnels_for(&alias));
                    for rule in &rules {
                        println!("  {}", rule.display());
                    }
                }
            } else {
                // Show all hosts with tunnels
                let entries = config.host_entries();
                let with_tunnels: Vec<_> = entries.iter().filter(|e| e.tunnel_count > 0).collect();
                if with_tunnels.is_empty() {
                    println!("{}", crate::messages::cli::NO_TUNNELS);
                } else {
                    for (i, host) in with_tunnels.iter().enumerate() {
                        if i > 0 {
                            println!();
                        }
                        println!("{}:", host.alias);
                        for rule in config.find_tunnel_directives(&host.alias) {
                            println!("  {}", rule.display());
                        }
                    }
                }
            }
            Ok(())
        }
        TunnelCommands::Add { alias, forward } => {
            if !config.has_host(&alias) {
                eprintln!("{}", crate::messages::cli::host_not_found(&alias));
                std::process::exit(1);
            }
            if config.is_included_host(&alias) {
                eprintln!("{}", crate::messages::cli::included_host_read_only(&alias));
                std::process::exit(1);
            }
            let rule = crate::tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
                eprintln!("{}", e);
                std::process::exit(1);
            });
            let key = rule.tunnel_type.directive_key();
            let value = rule.to_directive_value();
            // Check for duplicate forward
            if config.has_forward(&alias, key, &value) {
                eprintln!("{}", crate::messages::cli::forward_exists(&forward, &alias));
                std::process::exit(1);
            }
            config.add_forward(&alias, key, &value);
            log::debug!(
                "[config] cli tunnel add: writing ssh config (alias={})",
                alias
            );
            if let Err(e) = config.write() {
                log::warn!("[config] cli tunnel add: write failed: {}", e);
                eprintln!("{}", crate::messages::cli::save_config_failed(&e));
                std::process::exit(1);
            }
            log::info!(
                "[purple] cli tunnel add: forward={} alias={}",
                forward,
                alias
            );
            println!("{}", crate::messages::cli::added_forward(&forward, &alias));
            Ok(())
        }
        TunnelCommands::Remove { alias, forward } => {
            if !config.has_host(&alias) {
                eprintln!("{}", crate::messages::cli::host_not_found(&alias));
                std::process::exit(1);
            }
            if config.is_included_host(&alias) {
                eprintln!("{}", crate::messages::cli::included_host_read_only(&alias));
                std::process::exit(1);
            }
            let rule = crate::tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
                eprintln!("{}", e);
                std::process::exit(1);
            });
            let key = rule.tunnel_type.directive_key();
            let value = rule.to_directive_value();
            let removed = config.remove_forward(&alias, key, &value);
            if !removed {
                eprintln!(
                    "{}",
                    crate::messages::cli::forward_not_found(&forward, &alias)
                );
                std::process::exit(1);
            }
            log::debug!(
                "[config] cli tunnel remove: writing ssh config (alias={})",
                alias
            );
            if let Err(e) = config.write() {
                log::warn!("[config] cli tunnel remove: write failed: {}", e);
                eprintln!("{}", crate::messages::cli::save_config_failed(&e));
                std::process::exit(1);
            }
            log::info!(
                "[purple] cli tunnel remove: forward={} alias={}",
                forward,
                alias
            );
            println!(
                "{}",
                crate::messages::cli::removed_forward(&forward, &alias)
            );
            Ok(())
        }
        TunnelCommands::Start { alias } => {
            log::info!("[purple] cli tunnel start: alias={}", alias);
            if !config.has_host(&alias) {
                eprintln!("{}", crate::messages::cli::host_not_found(&alias));
                std::process::exit(1);
            }
            let tunnels = config.find_tunnel_directives(&alias);
            if tunnels.is_empty() {
                log::warn!("[purple] cli tunnel start: no forwards for alias={}", alias);
                eprintln!("{}", crate::messages::cli::no_forwards(&alias));
                std::process::exit(1);
            }
            println!("{}", crate::messages::cli::starting_tunnel(&alias));
            // Run ssh -N in foreground with inherited stdio
            let status = std::process::Command::new("ssh")
                .arg("-F")
                .arg(&config.path)
                .arg("-N")
                .arg("--")
                .arg(&alias)
                .status()
                .map_err(|e| anyhow::anyhow!("Failed to start ssh: {}", e))?;
            let code = status.code().unwrap_or(1);
            std::process::exit(code);
        }
    }
}

/// Read a line of input with echo disabled. Returns None if the user presses Esc.
pub fn prompt_hidden_input(prompt: &str) -> Result<Option<String>> {
    eprint!("{}", prompt);
    crossterm::terminal::enable_raw_mode()?;
    let mut input = String::new();
    loop {
        if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
            match key.code {
                crossterm::event::KeyCode::Enter => break,
                crossterm::event::KeyCode::Char(c) => {
                    input.push(c);
                    eprint!("*");
                }
                crossterm::event::KeyCode::Backspace if input.pop().is_some() => {
                    eprint!("\x08 \x08");
                }
                crossterm::event::KeyCode::Esc => {
                    crossterm::terminal::disable_raw_mode()?;
                    eprintln!();
                    return Ok(None);
                }
                _ => {}
            }
        }
    }
    crossterm::terminal::disable_raw_mode()?;
    eprintln!();
    Ok(Some(input))
}

/// Resolve the current on-disk mtime of a host's Vault SSH certificate.
///
/// Used by the `CertCheckResult` handler so every cache entry carries a
/// mtime alongside its status, enabling mtime-based lazy invalidation when
/// an external actor (CLI, another purple instance) rewrites the cert.
pub fn handle_password_command(command: PasswordCommands) -> Result<()> {
    log::info!("[purple] cli password: dispatch");
    match command {
        PasswordCommands::Set { alias } => {
            let password =
                match prompt_hidden_input(&crate::messages::askpass::password_prompt(&alias))? {
                    Some(p) if !p.is_empty() => p,
                    Some(_) => {
                        eprintln!("{}", crate::messages::cli::PASSWORD_EMPTY);
                        std::process::exit(1);
                    }
                    None => {
                        eprintln!("{}", crate::messages::cli::CANCELLED);
                        std::process::exit(1);
                    }
                };

            askpass::store_in_keychain(&alias, &password)?;
            println!(
                "Password stored for {}. Set 'keychain' as password source to use it.",
                alias
            );
            Ok(())
        }
        PasswordCommands::Remove { alias } => {
            askpass::remove_from_keychain(&alias)?;
            println!("{}", crate::messages::cli::password_removed(&alias));
            Ok(())
        }
    }
}

pub fn handle_snippet_command(
    config: SshConfigFile,
    command: SnippetCommands,
    config_path: &Path,
) -> Result<()> {
    log::info!("[purple] cli snippet: dispatch");
    match command {
        SnippetCommands::List => {
            let store = snippet::SnippetStore::load();
            if store.snippets.is_empty() {
                println!("{}", crate::messages::cli::NO_SNIPPETS);
            } else {
                for s in &store.snippets {
                    if s.description.is_empty() {
                        println!("  {}  {}", s.name, s.command);
                    } else {
                        println!("  {}  {}  ({})", s.name, s.command, s.description);
                    }
                }
            }
            Ok(())
        }
        SnippetCommands::Add {
            name,
            command,
            description,
        } => {
            if let Err(e) = snippet::validate_name(&name) {
                eprintln!("{}", e);
                std::process::exit(1);
            }
            if let Err(e) = snippet::validate_command(&command) {
                eprintln!("{}", e);
                std::process::exit(1);
            }
            if let Some(ref desc) = description {
                if desc.contains(|c: char| c.is_control()) {
                    eprintln!("{}", crate::messages::cli::DESCRIPTION_CONTROL_CHARS);
                    std::process::exit(1);
                }
            }
            let mut store = snippet::SnippetStore::load();
            let is_update = store.get(&name).is_some();
            store.set(snippet::Snippet {
                name: name.clone(),
                command,
                description: description.unwrap_or_default(),
            });
            store.save()?;
            if is_update {
                println!("{}", crate::messages::cli::snippet_updated(&name));
            } else {
                println!("{}", crate::messages::cli::snippet_added(&name));
            }
            Ok(())
        }
        SnippetCommands::Remove { name } => {
            let mut store = snippet::SnippetStore::load();
            if store.get(&name).is_none() {
                eprintln!("{}", crate::messages::cli::snippet_not_found(&name));
                std::process::exit(1);
            }
            store.remove(&name);
            store.save()?;
            println!("{}", crate::messages::cli::snippet_removed(&name));
            Ok(())
        }
        SnippetCommands::Run {
            name,
            alias,
            tag,
            all,
            parallel,
        } => {
            let store = snippet::SnippetStore::load();
            let snip = match store.get(&name) {
                Some(s) => s.clone(),
                None => {
                    eprintln!("{}", crate::messages::cli::snippet_not_found(&name));
                    std::process::exit(1);
                }
            };

            let entries = config.host_entries();

            // Determine target hosts
            let targets: Vec<&HostEntry> = if let Some(ref alias) = alias {
                match entries.iter().find(|h| h.alias == *alias) {
                    Some(h) => vec![h],
                    None => {
                        eprintln!("{}", crate::messages::cli::host_not_found(alias));
                        std::process::exit(1);
                    }
                }
            } else if let Some(ref tag_filter) = tag {
                let matched: Vec<_> = entries
                    .iter()
                    .filter(|h| h.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_filter)))
                    .collect();
                if matched.is_empty() {
                    eprintln!("{}", crate::messages::cli::no_hosts_with_tag(tag_filter));
                    std::process::exit(1);
                }
                matched
            } else if all {
                entries.iter().collect()
            } else {
                eprintln!("{}", crate::messages::cli::SPECIFY_TARGET);
                std::process::exit(1);
            };

            if targets.len() == 1 {
                // Single host: run directly
                let host = targets[0];
                let askpass = host
                    .askpass
                    .clone()
                    .or_else(preferences::load_askpass_default);
                super::ensure_proton_login(askpass.as_deref());
                let bw_session = super::ensure_bw_session(None, askpass.as_deref());
                super::ensure_keychain_password(&host.alias, askpass.as_deref());
                match snippet::run_snippet(
                    &host.alias,
                    config_path,
                    &snip.command,
                    askpass.as_deref(),
                    bw_session.as_deref(),
                    false,
                    false,
                ) {
                    Ok(r) => {
                        if !r.status.success() {
                            std::process::exit(r.status.code().unwrap_or(1));
                        }
                    }
                    Err(e) => {
                        eprintln!("{}", crate::messages::cli::operation_failed(&e));
                        std::process::exit(1);
                    }
                }
            } else if parallel {
                // Multi-host parallel
                use std::sync::mpsc;
                use std::thread;
                let (tx, rx) = mpsc::channel();
                let max_concurrent: usize = 20;
                let (slot_tx, slot_rx) = mpsc::channel();
                for _ in 0..max_concurrent {
                    let _ = slot_tx.send(());
                }
                let config_path = config_path.to_path_buf();
                // Resolve BW session if any target uses Bitwarden
                let any_bw = targets.iter().any(|h| {
                    let askpass = h.askpass.clone().or_else(preferences::load_askpass_default);
                    askpass.as_deref().unwrap_or("").starts_with("bw:")
                });
                let bw_session = if any_bw {
                    let bw_askpass = targets
                        .iter()
                        .find_map(|h| h.askpass.as_ref().filter(|a| a.starts_with("bw:")))
                        .cloned()
                        .or_else(preferences::load_askpass_default);
                    super::ensure_bw_session(None, bw_askpass.as_deref())
                } else {
                    None
                };
                // Resolve Proton Pass login if any target uses it. Proton Pass
                // persists its session on disk; we do not propagate a token, we
                // only ensure the user is logged in once before the batch starts.
                let target_askpass: Vec<Option<String>> =
                    targets.iter().map(|h| h.askpass.clone()).collect();
                if let Some(askpass) =
                    select_proton_askpass(&target_askpass, preferences::load_askpass_default())
                {
                    super::ensure_proton_login(Some(&askpass));
                }
                let targets_info: Vec<_> = targets
                    .iter()
                    .map(|h| {
                        let askpass = h.askpass.clone().or_else(preferences::load_askpass_default);
                        super::ensure_keychain_password(&h.alias, askpass.as_deref());
                        (h.alias.clone(), askpass)
                    })
                    .collect();
                let command = snip.command.clone();
                thread::spawn(move || {
                    for (alias, askpass) in targets_info {
                        let _ = slot_rx.recv();
                        let slot_tx = slot_tx.clone();
                        let tx = tx.clone();
                        let config_path = config_path.clone();
                        let command = command.clone();
                        let bw_session = bw_session.clone();
                        thread::spawn(move || {
                            let result = snippet::run_snippet(
                                &alias,
                                &config_path,
                                &command,
                                askpass.as_deref(),
                                bw_session.as_deref(),
                                true,
                                false,
                            );
                            let _ = tx.send((alias, result));
                            let _ = slot_tx.send(());
                        });
                    }
                });

                let host_count = targets.len();
                for _ in 0..host_count {
                    if let Ok((alias, result)) = rx.recv() {
                        match result {
                            Ok(r) => {
                                for line in r.stdout.lines() {
                                    println!("[{}] {}", alias, line);
                                }
                                for line in r.stderr.lines() {
                                    eprintln!("[{}] {}", alias, line);
                                }
                            }
                            Err(e) => {
                                eprintln!("{}", crate::messages::cli::host_failed(&alias, &e))
                            }
                        }
                    }
                }
            } else {
                // Multi-host sequential
                let mut bw_session: Option<String> = None;
                for host in &targets {
                    let askpass = host
                        .askpass
                        .clone()
                        .or_else(preferences::load_askpass_default);
                    super::ensure_proton_login(askpass.as_deref());
                    if let Some(token) =
                        super::ensure_bw_session(bw_session.as_deref(), askpass.as_deref())
                    {
                        bw_session = Some(token);
                    }
                    super::ensure_keychain_password(&host.alias, askpass.as_deref());
                    println!("{}", crate::messages::cli::host_separator(&host.alias));
                    match snippet::run_snippet(
                        &host.alias,
                        config_path,
                        &snip.command,
                        askpass.as_deref(),
                        bw_session.as_deref(),
                        false,
                        false,
                    ) {
                        Ok(r) => {
                            if !r.status.success() {
                                eprintln!(
                                    "{}",
                                    crate::messages::cli::exited_with_code(
                                        r.status.code().unwrap_or(1)
                                    )
                                );
                            }
                        }
                        Err(e) => {
                            eprintln!("{}", crate::messages::cli::host_failed(&host.alias, &e))
                        }
                    }
                    println!();
                }
            }
            Ok(())
        }
    }
}

pub fn handle_logs_command(tail: bool, clear: bool) -> Result<()> {
    let path = logging::log_path().context("Could not determine log path")?;
    if clear {
        if path.exists() {
            std::fs::remove_file(&path)?;
            println!("{}", crate::messages::cli::log_deleted(&path.display()));
        } else {
            println!("{}", crate::messages::cli::no_log_file(&path.display()));
        }
    } else if tail {
        let status = std::process::Command::new("tail")
            .args(["-f", &path.to_string_lossy()])
            .status()
            .context("Failed to run tail")?;
        std::process::exit(status.code().unwrap_or(1));
    } else {
        println!("{}", path.display());
    }
    Ok(())
}

pub fn handle_theme_command(command: ThemeCommands) -> Result<()> {
    log::info!("[purple] cli theme: dispatch");
    match command {
        ThemeCommands::List => {
            let current = preferences::load_theme().unwrap_or_else(|| "Purple".to_string());
            println!("{}", crate::messages::cli::BUILTIN_THEMES);
            for theme in ui::theme::ThemeDef::builtins() {
                let marker = if theme.name.eq_ignore_ascii_case(&current) {
                    "*"
                } else {
                    " "
                };
                println!("  {} {}", marker, theme.name);
            }
            let custom = ui::theme::ThemeDef::load_custom();
            if !custom.is_empty() {
                println!("{}", crate::messages::cli::CUSTOM_THEMES);
                for theme in &custom {
                    let marker = if theme.name.eq_ignore_ascii_case(&current) {
                        "*"
                    } else {
                        " "
                    };
                    println!("  {} {}", marker, theme.name);
                }
            }
        }
        ThemeCommands::Set { name } => {
            let found = ui::theme::ThemeDef::find_builtin(&name).or_else(|| {
                ui::theme::ThemeDef::load_custom()
                    .into_iter()
                    .find(|t| t.name.eq_ignore_ascii_case(&name))
            });
            match found {
                Some(theme) => {
                    preferences::save_theme(&theme.name)?;
                    println!("{}", crate::messages::cli::theme_set(&theme.name));
                }
                None => {
                    anyhow::bail!("Unknown theme: {}", name);
                }
            }
        }
    }
    Ok(())
}

pub fn handle_vault_sign_command(
    mut config: SshConfigFile,
    alias: Option<String>,
    all: bool,
    cli_vault_addr: Option<String>,
) -> Result<()> {
    log::info!(
        "[purple] cli vault sign: alias={:?} all={} vault_addr={:?}",
        alias,
        all,
        cli_vault_addr
    );
    if let Some(ref addr) = cli_vault_addr {
        if !vault_ssh::is_valid_vault_addr(addr) {
            anyhow::bail!(
                "Invalid --vault-addr value. Must be non-empty, no whitespace or control chars."
            );
        }
    }
    let provider_config = providers::config::ProviderConfig::load();
    let entries = config.host_entries();

    if all {
        let mut signed = 0u32;
        let mut failed = 0u32;
        let mut skipped = 0u32;

        for entry in &entries {
            let role = match vault_ssh::resolve_vault_role(
                entry.vault_ssh.as_deref(),
                entry.provider.as_deref(),
                entry.provider_label.as_deref(),
                &provider_config,
            ) {
                Some(r) => r,
                None => {
                    skipped += 1;
                    continue;
                }
            };

            let pubkey = match vault_ssh::resolve_pubkey_path(&entry.identity_file) {
                Ok(p) => p,
                Err(e) => {
                    println!("{}", crate::messages::cli::skipping_host(&entry.alias, &e));
                    failed += 1;
                    continue;
                }
            };
            let cert_path = vault_ssh::resolve_cert_path(&entry.alias, &entry.certificate_file)?;
            let status = vault_ssh::check_cert_validity(&cert_path);

            if !vault_ssh::needs_renewal(&status) {
                skipped += 1;
                continue;
            }

            // Flag beats per-host beats provider default.
            let resolved_addr = cli_vault_addr.clone().or_else(|| {
                vault_ssh::resolve_vault_addr(
                    entry.vault_addr.as_deref(),
                    entry.provider.as_deref(),
                    entry.provider_label.as_deref(),
                    &provider_config,
                )
            });
            print!("{}", crate::messages::cli::vault_signing_host(&entry.alias));
            match vault_ssh::sign_certificate(
                &role,
                &pubkey,
                &entry.alias,
                resolved_addr.as_deref(),
            ) {
                Ok(result) => {
                    println!("\u{2713}");
                    // Honor the same invariant as the TUI paths: never
                    // overwrite a user-set CertificateFile.
                    if should_write_certificate_file(&entry.certificate_file) {
                        let updated = config.set_host_certificate_file(
                            &entry.alias,
                            &result.cert_path.to_string_lossy(),
                        );
                        if !updated {
                            eprintln!(
                                "{}",
                                crate::messages::cli::vault_sign_host_block_gone(&entry.alias)
                            );
                        }
                    }
                    signed += 1;
                }
                Err(e) => {
                    println!("{}", crate::messages::cli::vault_sign_failed(&e));
                    failed += 1;
                }
            }
        }
        if signed > 0 {
            if let Err(e) = config.write() {
                eprintln!("{}", crate::messages::cli::vault_config_update_warning(&e));
            }
        }
        println!(
            "\nSigned: {}, failed: {}, skipped (valid): {}",
            signed, failed, skipped
        );
        if failed > 0 {
            std::process::exit(1);
        }
    } else if let Some(alias) = alias {
        let entry = entries
            .iter()
            .find(|h| h.alias == alias)
            .with_context(|| format!("Host '{}' not found", alias))?;

        let role = vault_ssh::resolve_vault_role(
            entry.vault_ssh.as_deref(),
            entry.provider.as_deref(),
            entry.provider_label.as_deref(),
            &provider_config,
        )
        .with_context(|| crate::messages::cli::vault_no_role(&alias))?;

        let pubkey = vault_ssh::resolve_pubkey_path(&entry.identity_file)?;
        let resolved_addr = cli_vault_addr.clone().or_else(|| {
            vault_ssh::resolve_vault_addr(
                entry.vault_addr.as_deref(),
                entry.provider.as_deref(),
                entry.provider_label.as_deref(),
                &provider_config,
            )
        });
        let result = vault_ssh::sign_certificate(&role, &pubkey, &alias, resolved_addr.as_deref())?;
        // Honor the same invariant as the TUI paths: never overwrite a
        // user-set CertificateFile. Only write the directive (and the
        // SSH config) when the host has none yet.
        if should_write_certificate_file(&entry.certificate_file) {
            let updated =
                config.set_host_certificate_file(&alias, &result.cert_path.to_string_lossy());
            if !updated {
                // Host disappeared between the `entries` snapshot and
                // the config mutation. In the single-host CLI path
                // both reads happen back-to-back in the same process,
                // so this is effectively unreachable — but surface it
                // loudly if the invariant ever breaks instead of
                // silently writing a cert nobody references.
                anyhow::bail!(
                    "Host '{}' disappeared from ssh config before CertificateFile could be written. Cert saved to {}.",
                    alias,
                    result.cert_path.display()
                );
            }
            config
                .write()
                .with_context(|| "Failed to update SSH config with CertificateFile")?;
        }
        println!(
            "{}",
            crate::messages::cli::vault_cert_signed(&result.cert_path.display())
        );
    } else {
        anyhow::bail!("Provide a host alias or use --all");
    }
    Ok(())
}

/// Pick the askpass value that drives a single Proton Pass pre-flight call for
/// a batch of hosts. Returns `Some(value)` if any host uses a `proton:` source
/// (per-host override OR the global default), preferring the first `proton:`
/// value by position in the slice before falling back to the default. Returns
/// `None` when no host in the batch uses Proton Pass.
pub fn select_proton_askpass(
    target_askpass: &[Option<String>],
    default: Option<String>,
) -> Option<String> {
    let any_proton = target_askpass.iter().any(|a| {
        let resolved = a.clone().or_else(|| default.clone());
        resolved.as_deref().unwrap_or("").starts_with("proton:")
    });
    if !any_proton {
        return None;
    }
    target_askpass
        .iter()
        .find_map(|a| a.as_ref().filter(|s| s.starts_with("proton:")))
        .cloned()
        .or(default)
}

pub fn run_whats_new(since: Option<&str>) -> Result<String> {
    use crate::changelog::{self, EntryKind};
    use semver::Version;

    let current = Version::parse(env!("CARGO_PKG_VERSION"))
        .with_context(|| "failed to parse current version")?;
    let last = match since {
        Some(s) => Some(Version::parse(s).with_context(|| format!("invalid --since version {s}"))?),
        None => None,
    };

    let sections = changelog::cached();
    let shown = changelog::versions_to_show(sections, last.as_ref(), &current, sections.len());

    let mut out = String::new();
    out.push_str(crate::messages::cli::whats_new::HEADER);
    out.push_str("\n\n");
    for section in shown {
        out.push_str(&format!("## {}", section.version));
        if let Some(date) = &section.date {
            out.push_str(&format!(" - {}", date));
        }
        out.push('\n');
        for entry in &section.entries {
            let prefix = match entry.kind {
                EntryKind::Feature => "+ ",
                EntryKind::Change => "~ ",
                EntryKind::Fix => "! ",
            };
            out.push_str(prefix);
            out.push_str(&entry.text);
            out.push('\n');
        }
        out.push('\n');
    }
    Ok(out)
}

#[cfg(test)]
mod whats_new_tests {
    use super::*;

    #[test]
    fn whats_new_cli_outputs_header() {
        let output = run_whats_new(None).unwrap();
        assert!(output.contains("purple release notes"));
    }

    #[test]
    fn whats_new_cli_filters_by_since() {
        let output = run_whats_new(Some("999.0.0")).unwrap();
        assert!(!output.contains("## "));
    }

    #[test]
    fn whats_new_cli_returns_error_on_bad_version() {
        let result = run_whats_new(Some("not-a-version"));
        assert!(result.is_err());
    }
}

#[cfg(test)]
mod select_proton_askpass_tests {
    use super::*;

    #[test]
    fn returns_none_when_no_target_uses_proton_and_no_default() {
        let targets = vec![
            Some("bw:foo".to_string()),
            Some("keychain".to_string()),
            None,
        ];
        assert_eq!(select_proton_askpass(&targets, None), None);
    }

    #[test]
    fn returns_none_when_no_target_uses_proton_and_default_is_not_proton() {
        let targets = vec![Some("bw:foo".to_string()), None];
        let default = Some("keychain".to_string());
        assert_eq!(select_proton_askpass(&targets, default), None);
    }

    #[test]
    fn returns_proton_value_when_one_target_uses_proton() {
        let targets = vec![
            Some("bw:other".to_string()),
            Some("proton:Vault/Item/p".to_string()),
            None,
        ];
        assert_eq!(
            select_proton_askpass(&targets, None),
            Some("proton:Vault/Item/p".to_string())
        );
    }

    #[test]
    fn prefers_first_per_host_proton_value_over_default() {
        let targets = vec![
            Some("proton:First/Item/p".to_string()),
            Some("proton:Second/Item/p".to_string()),
        ];
        let default = Some("proton:Default/Item/p".to_string());
        assert_eq!(
            select_proton_askpass(&targets, default),
            Some("proton:First/Item/p".to_string())
        );
    }

    #[test]
    fn falls_back_to_default_when_no_per_host_proton_value_but_default_is_proton() {
        let targets = vec![None, Some("bw:foo".to_string()), None];
        let default = Some("proton:Default/Item/p".to_string());
        assert_eq!(
            select_proton_askpass(&targets, default),
            Some("proton:Default/Item/p".to_string())
        );
    }

    #[test]
    fn handles_all_proton_targets() {
        let targets = vec![
            Some("proton:A/x/p".to_string()),
            Some("proton:B/y/p".to_string()),
        ];
        assert_eq!(
            select_proton_askpass(&targets, None),
            Some("proton:A/x/p".to_string())
        );
    }

    #[test]
    fn handles_empty_target_list_with_proton_default() {
        let default = Some("proton:Default/Item/p".to_string());
        assert_eq!(select_proton_askpass(&[], default), None);
    }

    #[test]
    fn handles_empty_target_list_with_no_default() {
        assert_eq!(select_proton_askpass(&[], None), None);
    }

    #[test]
    fn empty_string_askpass_does_not_match_proton() {
        let targets = vec![Some(String::new()), Some("bw:foo".to_string())];
        assert_eq!(select_proton_askpass(&targets, None), None);
    }
}
