use crate::cli_input::Input;
use anyhow::{Context, Result, bail};
use console::style;
use dialoguer::{Confirm, Select};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::fs;
use zeroclaw_config::schema::{
    AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
    HeartbeatConfig, IMessageConfig, LarkConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
    RuntimeConfig, SecretsConfig, SlackConfig, StorageConfig, TelegramConfig, WebhookConfig,
};
use zeroclaw_config::schema::{
    DingTalkConfig, IrcConfig, LarkReceiveMode, LinqConfig, NextcloudTalkConfig, QQConfig,
    SignalConfig, StreamMode, WhatsAppConfig,
};
use zeroclaw_config::schema::{HardwareConfig, HardwareTransport};
#[cfg(feature = "channel-nostr")]
use zeroclaw_config::schema::{NostrConfig, default_nostr_relays};
use zeroclaw_memory::{
    default_memory_backend_key, memory_backend_profile, selectable_memory_backends,
};
use zeroclaw_providers::{
    canonical_china_provider_name, is_glm_alias, is_glm_cn_alias, is_minimax_alias,
    is_moonshot_alias, is_qianfan_alias, is_qwen_alias, is_qwen_oauth_alias, is_zai_alias,
    is_zai_cn_alias,
};

// ── Wizard callbacks for cross-crate functionality ─────────────

/// Callback type for Nostr key validation: accepts a key string, returns public key hex.
#[cfg(feature = "channel-nostr")]
pub type NostrKeyValidator = Box<dyn Fn(&str) -> Result<String>>;

/// Callbacks injected by the binary crate to wire wizard sections whose
/// implementations live in downstream crates (zeroclaw-hardware, zeroclaw-channels).
///
/// NOTE: Transitional bridge — see RFC #5574 Phase 2 D4. This struct will be
/// replaced when `zeroclaw onboard` integrates with `PluginRegistry::install`.
#[derive(Default)]
pub struct WizardCallbacks {
    /// Full interactive hardware setup flow. When `Some`, the wizard runs
    /// hardware discovery and configuration; when `None`, hardware is skipped
    /// and `HardwareConfig::default()` is used.
    pub hardware_setup: Option<Box<dyn Fn() -> Result<HardwareConfig>>>,

    /// Validate a Nostr private key string (hex or nsec) and return the
    /// public key hex on success. Requires `nostr-sdk` which lives in
    /// `zeroclaw-channels`.
    #[cfg(feature = "channel-nostr")]
    pub nostr_validate_key: Option<NostrKeyValidator>,

    /// Whether the `whatsapp-web` feature is compiled in. When `true`, the
    /// wizard shows the WhatsApp Web option without a missing-feature warning.
    pub whatsapp_web_available: bool,
}

// ── Container detection for Docker/Kubernetes environments ───────

/// Detected container runtime type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ContainerRuntime {
    /// Not running in a container
    None,
    /// Docker
    Docker,
    /// Podman
    Podman,
    /// containerd (no reliable host alias)
    Containerd,
    /// Kubernetes (no reliable host alias)
    Kubernetes,
    /// Unknown container runtime (no reliable host alias)
    Unknown,
}

/// Input signals for container runtime detection (used for deterministic testing).
#[derive(Default)]
struct ContainerRuntimeInputs {
    kubernetes_service_host_set: bool,
    cgroup_contents: Option<String>,
    containerenv_exists: bool,
    dockerenv_exists: bool,
    container_env_var: Option<String>,
}

/// Pure helper to detect container runtime from explicit inputs (for testability).
fn detect_container_runtime_from_inputs(inputs: &ContainerRuntimeInputs) -> ContainerRuntime {
    // Check for Kubernetes first (most specific)
    if inputs.kubernetes_service_host_set {
        return ContainerRuntime::Kubernetes;
    }

    // Check cgroup for container runtime signatures
    if let Some(cgroup) = &inputs.cgroup_contents {
        let cgroup_lower = cgroup.to_lowercase();
        if cgroup_lower.contains("kubepods") {
            return ContainerRuntime::Kubernetes;
        }
        if cgroup_lower.contains("podman") {
            return ContainerRuntime::Podman;
        }
        if cgroup_lower.contains("containerd") {
            return ContainerRuntime::Containerd;
        }
        if cgroup_lower.contains("docker") {
            return ContainerRuntime::Docker;
        }
    }

    // Check for Podman-specific marker
    if inputs.containerenv_exists {
        return ContainerRuntime::Podman;
    }

    // Check for Docker marker file
    if inputs.dockerenv_exists {
        return ContainerRuntime::Docker;
    }

    // Check for generic container env var
    if let Some(val) = &inputs.container_env_var {
        let val_lower = val.to_lowercase();
        if val_lower.contains("podman") {
            return ContainerRuntime::Podman;
        }
        if val_lower.contains("docker") {
            return ContainerRuntime::Docker;
        }
        // Unknown container runtime - require explicit URL input
        return ContainerRuntime::Unknown;
    }

    ContainerRuntime::None
}

/// Detects which container runtime (if any) the process is running inside.
fn detect_container_runtime() -> ContainerRuntime {
    let inputs = ContainerRuntimeInputs {
        kubernetes_service_host_set: std::env::var("KUBERNETES_SERVICE_HOST").is_ok(),
        cgroup_contents: std::fs::read_to_string("/proc/1/cgroup").ok(),
        containerenv_exists: Path::new("/run/.containerenv").exists(),
        dockerenv_exists: Path::new("/.dockerenv").exists(),
        container_env_var: std::env::var("container").ok(),
    };
    detect_container_runtime_from_inputs(&inputs)
}

/// Returns the appropriate hostname for accessing services on the host machine.
/// Returns `None` for runtimes without a reliable host alias (user must configure manually).
fn default_local_host() -> Option<&'static str> {
    match detect_container_runtime() {
        ContainerRuntime::None => Some("localhost"),
        ContainerRuntime::Docker => Some("host.docker.internal"),
        ContainerRuntime::Podman => Some("host.containers.internal"),
        ContainerRuntime::Containerd | ContainerRuntime::Kubernetes | ContainerRuntime::Unknown => {
            None
        }
    }
}

/// Builds a default URL for a local service, using container-aware hostname.
/// Returns `None` for Kubernetes where no reliable host alias exists.
fn default_local_url(port: u16, path: &str) -> Option<String> {
    default_local_host().map(|host| format!("http://{}:{}{}", host, port, path))
}

/// Configuration for prompting a local provider endpoint URL.
struct LocalProviderPromptConfig<'a> {
    /// Display name for the provider (e.g., "llama.cpp", "vLLM")
    display_name: &'a str,
    /// Default port for the service
    port: u16,
    /// Default path suffix (e.g., "/v1")
    path: &'a str,
    /// Environment variable name for the API key (e.g., "LLAMACPP_API_KEY")
    api_key_env_var: &'a str,
}

/// Prompts for a local provider endpoint URL with container-aware defaults,
/// validates the URL, and optionally prompts for an API key.
/// Returns (normalized_url, api_key).
fn prompt_local_provider_endpoint(config: &LocalProviderPromptConfig) -> Result<(String, String)> {
    // For Kubernetes/containerd/unknown, don't provide a misleading localhost default
    let raw_url: String = if let Some(default_url) = default_local_url(config.port, config.path) {
        Input::new()
            .with_prompt(format!("  {} server endpoint URL", config.display_name))
            .default(default_url)
            .interact_text()?
    } else {
        print_bullet(&format!(
            "Running in a container without a known host alias. {} may not resolve to the host.",
            style("localhost").yellow()
        ));
        Input::new()
            .with_prompt(format!(
                "  {} server endpoint URL (e.g., http://<service-name>:{}{})",
                config.display_name, config.port, config.path
            ))
            .interact_text()?
    };

    let normalized_url = raw_url.trim().trim_end_matches('/').to_string();
    if normalized_url.is_empty() {
        anyhow::bail!("{} endpoint URL cannot be empty.", config.display_name);
    }

    // Validate URL format
    let parsed = reqwest::Url::parse(&normalized_url).with_context(|| {
        format!(
            "{} endpoint URL must be a valid URL (e.g., http://service:{}{})",
            config.display_name, config.port, config.path
        )
    })?;
    if !matches!(parsed.scheme(), "http" | "https") {
        anyhow::bail!(
            "{} endpoint URL must use http:// or https://",
            config.display_name
        );
    }

    print_bullet(&format!(
        "Using {} server endpoint: {}",
        config.display_name,
        style(&normalized_url).cyan()
    ));
    print_bullet(&format!(
        "No API key needed unless your {} server requires authentication.",
        config.display_name
    ));

    let key: String = Input::new()
        .with_prompt(format!(
            "  API key for {} server (or Enter to skip)",
            config.display_name
        ))
        .allow_empty(true)
        .interact_text()?;

    if key.trim().is_empty() {
        print_bullet(&format!(
            "No API key provided. Set {} later only if your server requires authentication.",
            style(config.api_key_env_var).yellow()
        ));
    }

    Ok((normalized_url, key))
}

// ── Project context collected during wizard ──────────────────────

/// User-provided personalization baked into workspace MD files.
#[derive(Debug, Clone, Default)]
pub struct ProjectContext {
    pub user_name: String,
    pub timezone: String,
    pub agent_name: String,
    pub communication_style: String,
}

// ── Banner ───────────────────────────────────────────────────────

const BANNER: &str = r"
    ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡

    ███████╗███████╗██████╗  ██████╗  ██████╗██╗      █████╗ ██╗    ██╗
    ╚══███╔╝██╔════╝██╔══██╗██╔═══██╗██╔════╝██║     ██╔══██╗██║    ██║
      ███╔╝ █████╗  ██████╔╝██║   ██║██║     ██║     ███████║██║ █╗ ██║
     ███╔╝  ██╔══╝  ██╔══██╗██║   ██║██║     ██║     ██╔══██║██║███╗██║
    ███████╗███████╗██║  ██║╚██████╔╝╚██████╗███████╗██║  ██║╚███╔███╔╝
    ╚══════╝╚══════╝╚═╝  ╚═╝ ╚═════╝  ╚═════╝╚══════╝╚═╝  ╚═╝ ╚══╝╚══╝

    Zero overhead. Zero compromise. 100% Rust. 100% Agnostic.

    ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡
";

const LIVE_MODEL_MAX_OPTIONS: usize = 120;
const MODEL_PREVIEW_LIMIT: usize = 20;
const MODEL_CACHE_FILE: &str = "models_cache.json";
const MODEL_CACHE_TTL_SECS: u64 = 12 * 60 * 60;
const CUSTOM_MODEL_SENTINEL: &str = "__custom_model__";

fn has_launchable_channels(channels: &ChannelsConfig) -> bool {
    channels.channels().iter().any(|(_, ok)| *ok)
}

// ── Main wizard entry point ──────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InteractiveOnboardingMode {
    FullOnboarding,
    UpdateProviderOnly,
}

pub async fn run_wizard(force: bool, callbacks: WizardCallbacks) -> Result<Config> {
    println!("{}", style(BANNER).cyan().bold());

    println!(
        "  {}",
        style("Welcome to KylinBot — the fastest, smallest AI assistant.")
            .white()
            .bold()
    );
    println!(
        "  {}",
        style("This wizard will configure your agent in under 60 seconds.").dim()
    );
    println!();

    print_step(1, 9, "Workspace Setup");
    let (workspace_dir, config_path) = setup_workspace().await?;
    match resolve_interactive_onboarding_mode(&config_path, force)? {
        InteractiveOnboardingMode::FullOnboarding => {}
        InteractiveOnboardingMode::UpdateProviderOnly => {
            return Box::pin(run_provider_update_wizard(&workspace_dir, &config_path)).await;
        }
    }

    print_step(2, 9, "AI Provider & API Key");
    let (provider, api_key, model, provider_api_url) = setup_provider(&workspace_dir).await?;

    print_step(3, 9, "Channels (How You Talk to KylinBot)");
    let channels = setup_channels(None, &callbacks)?;

    print_step(4, 9, "Tunnel (Expose to Internet)");
    let tunnel_config = setup_tunnel()?;

    print_step(5, 9, "Tool Mode & Security");
    let (composio_config, secrets_config) = setup_tool_mode()?;

    print_step(6, 9, "Hardware (Physical World)");
    let hardware_config = if let Some(ref hw_setup) = callbacks.hardware_setup {
        hw_setup()?
    } else {
        HardwareConfig::default()
    };

    print_step(7, 9, "Memory Configuration");
    let memory_config = setup_memory()?;

    print_step(8, 9, "Project Context (Personalize Your Agent)");
    let project_ctx = setup_project_context()?;

    print_step(9, 9, "Workspace Files");
    scaffold_workspace(&workspace_dir, &project_ctx, &memory_config.backend).await?;

    // ── Build config ──
    // Defaults: SQLite memory, supervised autonomy, workspace-scoped, native runtime
    let config = Config {
        workspace_dir: workspace_dir.clone(),
        config_path: config_path.clone(),
        schema_version: zeroclaw_config::migration::CURRENT_SCHEMA_VERSION,
        providers: {
            let entry = zeroclaw_config::schema::ModelProviderConfig {
                api_key: if api_key.is_empty() {
                    None
                } else {
                    Some(api_key)
                },
                base_url: provider_api_url,
                model: Some(model),
                temperature: Some(0.7),
                timeout_secs: Some(120),
                ..Default::default()
            };
            let mut p = zeroclaw_config::providers::ProvidersConfig::default();
            p.models.insert(provider.clone(), entry);
            p.fallback = Some(provider);
            p
        },
        observability: ObservabilityConfig::default(),
        autonomy: AutonomyConfig::default(),
        trust: crate::trust::TrustConfig::default(),
        backup: zeroclaw_config::schema::BackupConfig::default(),
        data_retention: zeroclaw_config::schema::DataRetentionConfig::default(),
        cloud_ops: zeroclaw_config::schema::CloudOpsConfig::default(),
        conversational_ai: zeroclaw_config::schema::ConversationalAiConfig::default(),
        security: zeroclaw_config::schema::SecurityConfig::default(),
        security_ops: zeroclaw_config::schema::SecurityOpsConfig::default(),
        runtime: RuntimeConfig::default(),
        reliability: zeroclaw_config::schema::ReliabilityConfig::default(),
        scheduler: zeroclaw_config::schema::SchedulerConfig::default(),
        agent: zeroclaw_config::schema::AgentConfig::default(),
        pacing: zeroclaw_config::schema::PacingConfig::default(),
        skills: zeroclaw_config::schema::SkillsConfig::default(),
        pipeline: zeroclaw_config::schema::PipelineConfig::default(),
        heartbeat: HeartbeatConfig::default(),
        cron: zeroclaw_config::schema::CronConfig::default(),
        channels,
        memory: memory_config, // User-selected memory backend
        storage: StorageConfig::default(),
        tunnel: tunnel_config,
        gateway: zeroclaw_config::schema::GatewayConfig::default(),
        composio: composio_config,
        microsoft365: zeroclaw_config::schema::Microsoft365Config::default(),
        secrets: secrets_config,
        browser: BrowserConfig::default(),
        browser_delegate: zeroclaw_tools::browser_delegate::BrowserDelegateConfig::default(),
        http_request: zeroclaw_config::schema::HttpRequestConfig::default(),
        multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
        media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
        web_fetch: zeroclaw_config::schema::WebFetchConfig::default(),
        link_enricher: zeroclaw_config::schema::LinkEnricherConfig::default(),
        text_browser: zeroclaw_config::schema::TextBrowserConfig::default(),
        web_search: zeroclaw_config::schema::WebSearchConfig::default(),
        project_intel: zeroclaw_config::schema::ProjectIntelConfig::default(),
        google_workspace: zeroclaw_config::schema::GoogleWorkspaceConfig::default(),
        proxy: zeroclaw_config::schema::ProxyConfig::default(),
        identity: zeroclaw_config::schema::IdentityConfig::default(),
        cost: zeroclaw_config::schema::CostConfig::default(),
        peripherals: zeroclaw_config::schema::PeripheralsConfig::default(),
        delegate: zeroclaw_config::schema::DelegateToolConfig::default(),
        agents: std::collections::HashMap::new(),
        swarms: std::collections::HashMap::new(),
        hooks: zeroclaw_config::schema::HooksConfig::default(),
        hardware: hardware_config,
        query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
        transcription: zeroclaw_config::schema::TranscriptionConfig::default(),
        tts: zeroclaw_config::schema::TtsConfig::default(),
        mcp: zeroclaw_config::schema::McpConfig::default(),
        nodes: zeroclaw_config::schema::NodesConfig::default(),
        workspace: zeroclaw_config::schema::WorkspaceConfig::default(),
        notion: zeroclaw_config::schema::NotionConfig::default(),
        jira: zeroclaw_config::schema::JiraConfig::default(),
        node_transport: zeroclaw_config::schema::NodeTransportConfig::default(),
        knowledge: zeroclaw_config::schema::KnowledgeConfig::default(),
        linkedin: zeroclaw_config::schema::LinkedInConfig::default(),
        image_gen: zeroclaw_config::schema::ImageGenConfig::default(),
        plugins: zeroclaw_config::schema::PluginsConfig::default(),
        locale: None,
        verifiable_intent: zeroclaw_config::schema::VerifiableIntentConfig::default(),
        claude_code: zeroclaw_config::schema::ClaudeCodeConfig::default(),
        claude_code_runner: zeroclaw_config::schema::ClaudeCodeRunnerConfig::default(),
        codex_cli: zeroclaw_config::schema::CodexCliConfig::default(),
        gemini_cli: zeroclaw_config::schema::GeminiCliConfig::default(),
        opencode_cli: zeroclaw_config::schema::OpenCodeCliConfig::default(),
        sop: zeroclaw_config::schema::SopConfig::default(),
        shell_tool: zeroclaw_config::schema::ShellToolConfig::default(),
    };

    println!(
        "  {} Security: {} | workspace-scoped",
        style("✓").green().bold(),
        style("Supervised").green()
    );
    println!(
        "  {} Memory: {} (auto-save: {})",
        style("✓").green().bold(),
        style(&config.memory.backend).green(),
        if config.memory.auto_save { "on" } else { "off" }
    );

    config.save().await?;
    persist_workspace_selection(&config.config_path).await?;

    // ── Final summary ────────────────────────────────────────────
    print_summary(&config);

    // ── Offer to launch channels immediately ─────────────────────
    let has_channels = has_launchable_channels(&config.channels);

    if has_channels
        && config
            .providers
            .fallback_provider()
            .and_then(|e| e.api_key.as_deref())
            .is_some()
    {
        let launch: bool = Confirm::new()
            .with_prompt(format!(
                "  {} Launch channels now? (connected channels → AI → reply)",
                style("🚀").cyan()
            ))
            .default(true)
            .interact()?;

        if launch {
            println!();
            println!(
                "  {} {}",
                style("⚡").cyan(),
                style("Starting channel server...").white().bold()
            );
            println!();
            // Signal to main.rs to call start_channels after wizard returns
            // SAFETY: called during single-threaded onboarding wizard before async runtime.
            unsafe { std::env::set_var("ZEROCLAW_AUTOSTART_CHANNELS", "1") };
        }
    }

    Ok(config)
}

/// Interactive repair flow: rerun channel setup only without redoing full onboarding.
pub async fn run_channels_repair_wizard(callbacks: WizardCallbacks) -> Result<Config> {
    println!("{}", style(BANNER).cyan().bold());
    println!(
        "  {}",
        style("Channels Repair — update channel tokens and allowlists only")
            .white()
            .bold()
    );
    println!();

    let mut config = Box::pin(Config::load_or_init()).await?;

    print_step(1, 1, "Channels (How You Talk to KylinBot)");
    config.channels = setup_channels(Some(config.channels.clone()), &callbacks)?;
    config.save().await?;
    persist_workspace_selection(&config.config_path).await?;

    println!();
    println!(
        "  {} Channel config saved: {}",
        style("✓").green().bold(),
        style(config.config_path.display()).green()
    );

    let has_channels = has_launchable_channels(&config.channels);

    if has_channels
        && config
            .providers
            .fallback_provider()
            .and_then(|e| e.api_key.as_deref())
            .is_some()
    {
        let launch: bool = Confirm::new()
            .with_prompt(format!(
                "  {} Launch channels now? (connected channels → AI → reply)",
                style("🚀").cyan()
            ))
            .default(true)
            .interact()?;

        if launch {
            println!();
            println!(
                "  {} {}",
                style("⚡").cyan(),
                style("Starting channel server...").white().bold()
            );
            println!();
            // Signal to main.rs to call start_channels after wizard returns
            // SAFETY: called during single-threaded onboarding wizard before async runtime.
            unsafe { std::env::set_var("ZEROCLAW_AUTOSTART_CHANNELS", "1") };
        }
    }

    Ok(config)
}

/// Interactive flow: update only provider/model/api key while preserving existing config.
async fn run_provider_update_wizard(workspace_dir: &Path, config_path: &Path) -> Result<Config> {
    println!();
    println!(
        "  {} Existing config detected. Running provider-only update mode (preserving channels, memory, tunnel, hooks, and other settings).",
        style("↻").cyan().bold()
    );

    let raw = fs::read_to_string(config_path).await.with_context(|| {
        format!(
            "Failed to read existing config at {}",
            config_path.display()
        )
    })?;
    let mut config: Config = toml::from_str(&raw).with_context(|| {
        format!(
            "Failed to parse existing config at {}",
            config_path.display()
        )
    })?;
    config.workspace_dir = workspace_dir.to_path_buf();
    config.config_path = config_path.to_path_buf();

    print_step(1, 1, "AI Provider & API Key");
    let (provider, api_key, model, provider_api_url) = setup_provider(workspace_dir).await?;
    apply_provider_update(&mut config, provider, api_key, model, provider_api_url);

    config.save().await?;
    persist_workspace_selection(&config.config_path).await?;

    println!(
        "  {} Provider settings updated at {}",
        style("✓").green().bold(),
        style(config.config_path.display()).green()
    );
    print_summary(&config);

    let has_channels = has_launchable_channels(&config.channels);
    if has_channels
        && config
            .providers
            .fallback_provider()
            .and_then(|e| e.api_key.as_deref())
            .is_some()
    {
        let launch: bool = Confirm::new()
            .with_prompt(format!(
                "  {} Launch channels now? (connected channels → AI → reply)",
                style("🚀").cyan()
            ))
            .default(true)
            .interact()?;

        if launch {
            println!();
            println!(
                "  {} {}",
                style("⚡").cyan(),
                style("Starting channel server...").white().bold()
            );
            println!();
            // SAFETY: called during single-threaded onboarding wizard before async runtime.
            unsafe { std::env::set_var("ZEROCLAW_AUTOSTART_CHANNELS", "1") };
        }
    }

    Ok(config)
}

fn apply_provider_update(
    config: &mut Config,
    provider: String,
    api_key: String,
    model: String,
    provider_api_url: Option<String>,
) {
    let entry = config.providers.models.entry(provider.clone()).or_default();
    entry.model = Some(model);
    entry.base_url = provider_api_url;
    entry.api_key = if api_key.trim().is_empty() {
        None
    } else {
        Some(api_key)
    };
    config.providers.fallback = Some(provider);
}

// ── Quick setup (zero prompts) ───────────────────────────────────

/// Non-interactive setup: generates a sensible default config instantly.
/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite|lucid`.
fn backend_key_from_choice(choice: usize) -> &'static str {
    selectable_memory_backends()
        .get(choice)
        .map_or(default_memory_backend_key(), |backend| backend.key)
}

fn memory_config_defaults_for_backend(backend: &str) -> MemoryConfig {
    let profile = memory_backend_profile(backend);

    MemoryConfig {
        backend: backend.to_string(),
        auto_save: profile.auto_save_default,
        hygiene_enabled: profile.uses_sqlite_hygiene,
        archive_after_days: if profile.uses_sqlite_hygiene { 7 } else { 0 },
        purge_after_days: if profile.uses_sqlite_hygiene { 30 } else { 0 },
        conversation_retention_days: 30,
        embedding_provider: "none".to_string(),
        embedding_model: "text-embedding-3-small".to_string(),
        embedding_dimensions: 1536,
        vector_weight: 0.7,
        keyword_weight: 0.3,
        search_mode: zeroclaw_config::schema::SearchMode::default(),
        min_relevance_score: 0.4,
        embedding_cache_size: if profile.uses_sqlite_hygiene {
            10000
        } else {
            0
        },
        chunk_max_tokens: 512,
        response_cache_enabled: false,
        response_cache_ttl_minutes: 60,
        response_cache_max_entries: 5_000,
        response_cache_hot_entries: 256,
        snapshot_enabled: false,
        snapshot_on_hygiene: false,
        auto_hydrate: true,
        retrieval_stages: vec!["cache".into(), "fts".into(), "vector".into()],
        rerank_enabled: false,
        rerank_threshold: 5,
        fts_early_return_score: 0.85,
        default_namespace: "default".into(),
        conflict_threshold: 0.85,
        audit_enabled: false,
        audit_retention_days: 30,
        policy: zeroclaw_config::schema::MemoryPolicyConfig::default(),
        sqlite_open_timeout_secs: None,
        qdrant: zeroclaw_config::schema::QdrantConfig::default(),
        postgres: zeroclaw_config::schema::PostgresMemoryConfig::default(),
    }
}

#[allow(clippy::too_many_lines)]
pub async fn run_quick_setup(
    credential_override: Option<&str>,
    provider: Option<&str>,
    model_override: Option<&str>,
    memory_backend: Option<&str>,
    force: bool,
) -> Result<Config> {
    let home = directories::UserDirs::new()
        .map(|u| u.home_dir().to_path_buf())
        .context("Could not find home directory")?;

    Box::pin(run_quick_setup_with_home(
        credential_override,
        provider,
        model_override,
        memory_backend,
        force,
        &home,
    ))
    .await
}

fn resolve_quick_setup_dirs_with_home(home: &Path) -> (PathBuf, PathBuf) {
    if let Ok(custom_config_dir) = std::env::var("ZEROCLAW_CONFIG_DIR") {
        let trimmed = custom_config_dir.trim();
        if !trimmed.is_empty() {
            let config_dir = PathBuf::from(shellexpand::tilde(trimmed).as_ref());
            return (config_dir.clone(), config_dir.join("workspace"));
        }
    }

    if let Ok(custom_workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
        let trimmed = custom_workspace.trim();
        if !trimmed.is_empty() {
            let expanded = shellexpand::tilde(trimmed);
            return zeroclaw_config::schema::resolve_config_dir_for_workspace(&PathBuf::from(
                expanded.as_ref(),
            ));
        }
    }

    // If the binary was installed via Homebrew, use the Homebrew var path
    // instead of ~/.zeroclaw so the Homebrew service finds the same config.
    if let Some(prefix) = std::env::current_exe()
        .ok()
        .as_deref()
        .and_then(homebrew_prefix_for_exe)
    {
        let config_dir = PathBuf::from(prefix).join("var").join("zeroclaw");
        return (config_dir.clone(), config_dir.join("workspace"));
    }

    let config_dir = home.join(".zeroclaw");
    (config_dir.clone(), config_dir.join("workspace"))
}

fn homebrew_prefix_for_exe(exe: &Path) -> Option<&'static str> {
    let exe = exe.to_string_lossy();
    if exe == "/opt/homebrew/bin/zeroclaw"
        || exe.starts_with("/opt/homebrew/Cellar/zeroclaw/")
        || exe.starts_with("/opt/homebrew/opt/zeroclaw/")
    {
        return Some("/opt/homebrew");
    }

    if exe == "/usr/local/bin/zeroclaw"
        || exe.starts_with("/usr/local/Cellar/zeroclaw/")
        || exe.starts_with("/usr/local/opt/zeroclaw/")
    {
        return Some("/usr/local");
    }

    None
}

fn quick_setup_homebrew_service_note(
    config_path: &Path,
    workspace_dir: &Path,
    exe: &Path,
) -> Option<String> {
    let prefix = homebrew_prefix_for_exe(exe)?;
    let service_root = Path::new(prefix).join("var").join("zeroclaw");
    let service_config = service_root.join("config.toml");
    let service_workspace = service_root.join("workspace");

    if config_path == service_config || workspace_dir == service_workspace {
        return None;
    }

    Some(format!(
        "Homebrew service note: `brew services` uses {} (config {}) by default. Your onboarding just wrote {}. If you plan to run KylinBot as a service, copy or link this workspace first.",
        service_workspace.display(),
        service_config.display(),
        config_path.display(),
    ))
}

#[allow(clippy::too_many_lines)]
async fn run_quick_setup_with_home(
    credential_override: Option<&str>,
    provider: Option<&str>,
    model_override: Option<&str>,
    memory_backend: Option<&str>,
    force: bool,
    home: &Path,
) -> Result<Config> {
    println!("{}", style(BANNER).cyan().bold());
    println!(
        "  {}",
        style("Quick Setup — generating config with sensible defaults...")
            .white()
            .bold()
    );
    println!();

    let (zeroclaw_dir, workspace_dir) = resolve_quick_setup_dirs_with_home(home);
    let config_path = zeroclaw_dir.join("config.toml");

    ensure_onboard_overwrite_allowed(&config_path, force)?;
    fs::create_dir_all(&workspace_dir)
        .await
        .context("Failed to create workspace directory")?;

    let provider_name = provider.unwrap_or("openrouter").to_string();
    let model = model_override
        .map(str::to_string)
        .unwrap_or_else(|| default_model_for_provider(&provider_name));
    let memory_backend_name = memory_backend
        .unwrap_or(default_memory_backend_key())
        .to_string();

    // Create memory config based on backend choice
    let memory_config = memory_config_defaults_for_backend(&memory_backend_name);

    let config = Config {
        workspace_dir: workspace_dir.clone(),
        config_path: config_path.clone(),
        schema_version: zeroclaw_config::migration::CURRENT_SCHEMA_VERSION,
        providers: {
            let entry = zeroclaw_config::schema::ModelProviderConfig {
                api_key: credential_override.map(|c| {
                    let mut s = String::with_capacity(c.len());
                    s.push_str(c);
                    s
                }),
                model: Some(model.clone()),
                temperature: Some(0.7),
                timeout_secs: Some(120),
                ..Default::default()
            };
            let mut p = zeroclaw_config::providers::ProvidersConfig::default();
            p.models.insert(provider_name.clone(), entry);
            p.fallback = Some(provider_name.clone());
            p
        },
        observability: ObservabilityConfig::default(),
        autonomy: AutonomyConfig::default(),
        trust: crate::trust::TrustConfig::default(),
        backup: zeroclaw_config::schema::BackupConfig::default(),
        data_retention: zeroclaw_config::schema::DataRetentionConfig::default(),
        cloud_ops: zeroclaw_config::schema::CloudOpsConfig::default(),
        conversational_ai: zeroclaw_config::schema::ConversationalAiConfig::default(),
        security: zeroclaw_config::schema::SecurityConfig::default(),
        security_ops: zeroclaw_config::schema::SecurityOpsConfig::default(),
        runtime: RuntimeConfig::default(),
        reliability: zeroclaw_config::schema::ReliabilityConfig::default(),
        scheduler: zeroclaw_config::schema::SchedulerConfig::default(),
        agent: zeroclaw_config::schema::AgentConfig::default(),
        pacing: zeroclaw_config::schema::PacingConfig::default(),
        skills: zeroclaw_config::schema::SkillsConfig::default(),
        pipeline: zeroclaw_config::schema::PipelineConfig::default(),
        heartbeat: HeartbeatConfig::default(),
        cron: zeroclaw_config::schema::CronConfig::default(),
        channels: ChannelsConfig::default(),
        memory: memory_config,
        storage: StorageConfig::default(),
        tunnel: zeroclaw_config::schema::TunnelConfig::default(),
        gateway: zeroclaw_config::schema::GatewayConfig::default(),
        composio: ComposioConfig::default(),
        microsoft365: zeroclaw_config::schema::Microsoft365Config::default(),
        secrets: SecretsConfig::default(),
        browser: BrowserConfig::default(),
        browser_delegate: zeroclaw_tools::browser_delegate::BrowserDelegateConfig::default(),
        http_request: zeroclaw_config::schema::HttpRequestConfig::default(),
        multimodal: zeroclaw_config::schema::MultimodalConfig::default(),
        media_pipeline: zeroclaw_config::schema::MediaPipelineConfig::default(),
        web_fetch: zeroclaw_config::schema::WebFetchConfig::default(),
        link_enricher: zeroclaw_config::schema::LinkEnricherConfig::default(),
        text_browser: zeroclaw_config::schema::TextBrowserConfig::default(),
        web_search: zeroclaw_config::schema::WebSearchConfig::default(),
        project_intel: zeroclaw_config::schema::ProjectIntelConfig::default(),
        google_workspace: zeroclaw_config::schema::GoogleWorkspaceConfig::default(),
        proxy: zeroclaw_config::schema::ProxyConfig::default(),
        identity: zeroclaw_config::schema::IdentityConfig::default(),
        cost: zeroclaw_config::schema::CostConfig::default(),
        peripherals: zeroclaw_config::schema::PeripheralsConfig::default(),
        delegate: zeroclaw_config::schema::DelegateToolConfig::default(),
        agents: std::collections::HashMap::new(),
        swarms: std::collections::HashMap::new(),
        hooks: zeroclaw_config::schema::HooksConfig::default(),
        hardware: zeroclaw_config::schema::HardwareConfig::default(),
        query_classification: zeroclaw_config::schema::QueryClassificationConfig::default(),
        transcription: zeroclaw_config::schema::TranscriptionConfig::default(),
        tts: zeroclaw_config::schema::TtsConfig::default(),
        mcp: zeroclaw_config::schema::McpConfig::default(),
        nodes: zeroclaw_config::schema::NodesConfig::default(),
        workspace: zeroclaw_config::schema::WorkspaceConfig::default(),
        notion: zeroclaw_config::schema::NotionConfig::default(),
        jira: zeroclaw_config::schema::JiraConfig::default(),
        node_transport: zeroclaw_config::schema::NodeTransportConfig::default(),
        knowledge: zeroclaw_config::schema::KnowledgeConfig::default(),
        linkedin: zeroclaw_config::schema::LinkedInConfig::default(),
        image_gen: zeroclaw_config::schema::ImageGenConfig::default(),
        plugins: zeroclaw_config::schema::PluginsConfig::default(),
        locale: None,
        verifiable_intent: zeroclaw_config::schema::VerifiableIntentConfig::default(),
        claude_code: zeroclaw_config::schema::ClaudeCodeConfig::default(),
        claude_code_runner: zeroclaw_config::schema::ClaudeCodeRunnerConfig::default(),
        codex_cli: zeroclaw_config::schema::CodexCliConfig::default(),
        gemini_cli: zeroclaw_config::schema::GeminiCliConfig::default(),
        opencode_cli: zeroclaw_config::schema::OpenCodeCliConfig::default(),
        sop: zeroclaw_config::schema::SopConfig::default(),
        shell_tool: zeroclaw_config::schema::ShellToolConfig::default(),
    };

    config.save().await?;
    persist_workspace_selection(&config.config_path).await?;

    // Scaffold minimal workspace files
    let default_ctx = ProjectContext {
        user_name: std::env::var("USER").unwrap_or_else(|_| "User".into()),
        timezone: "UTC".into(),
        agent_name: "KylinBot".into(),
        communication_style:
            "Be warm, natural, and clear. Use occasional relevant emojis (1-2 max) and avoid robotic phrasing."
                .into(),
    };
    scaffold_workspace(&workspace_dir, &default_ctx, &memory_backend_name).await?;

    println!(
        "  {} Workspace:  {}",
        style("✓").green().bold(),
        style(workspace_dir.display()).green()
    );
    println!(
        "  {} Provider:   {}",
        style("✓").green().bold(),
        style(&provider_name).green()
    );
    println!(
        "  {} Model:      {}",
        style("✓").green().bold(),
        style(&model).green()
    );
    println!(
        "  {} API Key:    {}",
        style("✓").green().bold(),
        if credential_override.is_some() {
            style("set").green()
        } else {
            style("not set (use --api-key or edit config.toml)").yellow()
        }
    );
    println!(
        "  {} Security:   {}",
        style("✓").green().bold(),
        style("Supervised (workspace-scoped)").green()
    );
    println!(
        "  {} Memory:     {} (auto-save: {})",
        style("✓").green().bold(),
        style(&memory_backend_name).green(),
        if memory_backend_name == "none" {
            "off"
        } else {
            "on"
        }
    );
    println!(
        "  {} Secrets:    {}",
        style("✓").green().bold(),
        style("encrypted").green()
    );
    println!(
        "  {} Gateway:    {}",
        style("✓").green().bold(),
        style("pairing required (127.0.0.1:8080)").green()
    );
    println!(
        "  {} Tunnel:     {}",
        style("✓").green().bold(),
        style("none (local only)").dim()
    );
    println!(
        "  {} Composio:   {}",
        style("✓").green().bold(),
        style("disabled (sovereign mode)").dim()
    );
    println!();
    println!(
        "  {} {}",
        style("Config saved:").white().bold(),
        style(config_path.display()).green()
    );
    if cfg!(target_os = "macos")
        && let Ok(exe) = std::env::current_exe()
        && let Some(note) = quick_setup_homebrew_service_note(&config_path, &workspace_dir, &exe)
    {
        println!();
        println!("  {}", style(note).yellow());
    }
    println!();
    println!("  {}", style("Next steps:").white().bold());
    if credential_override.is_none() {
        if provider_supports_keyless_local_usage(&provider_name) {
            println!("    1. Chat:     zeroclaw agent -m \"Hello!\"");
            println!("    2. Gateway:  zeroclaw gateway");
            println!("    3. Status:   zeroclaw status");
        } else if provider_supports_device_flow(&provider_name) {
            if canonical_provider_name(&provider_name) == "copilot" {
                println!("    1. Chat:              zeroclaw agent -m \"Hello!\"");
                println!("       (device / OAuth auth will prompt on first run)");
                println!("    2. Gateway:           zeroclaw gateway");
                println!("    3. Status:            zeroclaw status");
            } else {
                println!(
                    "    1. Login:             zeroclaw auth login --provider {}",
                    provider_name
                );
                println!("    2. Chat:              zeroclaw agent -m \"Hello!\"");
                println!("    3. Gateway:           zeroclaw gateway");
                println!("    4. Status:            zeroclaw status");
            }
        } else {
            let env_var = provider_env_var(&provider_name);
            println!("    1. Set your API key:  export {env_var}=\"sk-...\"");
            println!("    2. Or edit:           ~/.zeroclaw/config.toml");
            println!("    3. Chat:              zeroclaw agent -m \"Hello!\"");
            println!("    4. Gateway:           zeroclaw gateway");
        }
    } else {
        println!("    1. Chat:     zeroclaw agent -m \"Hello!\"");
        println!("    2. Gateway:  zeroclaw gateway");
        println!("    3. Status:   zeroclaw status");
    }
    println!();

    Ok(config)
}

fn canonical_provider_name(provider_name: &str) -> &str {
    if is_qwen_oauth_alias(provider_name) {
        return "qwen-code";
    }

    if let Some(canonical) = canonical_china_provider_name(provider_name) {
        return canonical;
    }

    match provider_name {
        "grok" => "xai",
        "together" => "together-ai",
        "google" | "google-gemini" => "gemini",
        "github-copilot" => "copilot",
        "openai_codex" | "codex" => "openai-codex",
        "kimi_coding" | "kimi_for_coding" => "kimi-code",
        "nvidia-nim" | "build.nvidia.com" => "nvidia",
        "aws-bedrock" => "bedrock",
        "llama.cpp" => "llamacpp",
        _ => provider_name,
    }
}

fn allows_unauthenticated_model_fetch(provider_name: &str) -> bool {
    matches!(
        canonical_provider_name(provider_name),
        "openrouter"
            | "ollama"
            | "llamacpp"
            | "sglang"
            | "vllm"
            | "osaurus"
            | "venice"
            | "astrai"
            | "nvidia"
    )
}

/// Pick a sensible default model for the given provider.
fn default_model_for_provider(provider: &str) -> String {
    match canonical_provider_name(provider) {
        "anthropic" => "claude-sonnet-4-5-20250929".into(),
        "openai" => "gpt-5.2".into(),
        "openai-codex" => "gpt-5-codex".into(),
        "venice" => "zai-org-glm-5".into(),
        "groq" => "llama-3.3-70b-versatile".into(),
        "mistral" => "mistral-large-latest".into(),
        "deepseek" => "deepseek-chat".into(),
        "xai" => "grok-4-1-fast-reasoning".into(),
        "perplexity" => "sonar-pro".into(),
        "fireworks" => "accounts/fireworks/models/llama-v3p3-70b-instruct".into(),
        "novita" => "minimax/minimax-m2.7".into(),
        "together-ai" => "meta-llama/Llama-3.3-70B-Instruct-Turbo".into(),
        "cohere" => "command-a-03-2025".into(),
        "moonshot" => "kimi-k2.5".into(),
        "glm" | "zai" => "glm-5".into(),
        "minimax" => "MiniMax-M2.7".into(),
        "qwen" => "qwen-plus".into(),
        "qwen-code" => "qwen3-coder-plus".into(),
        "ollama" => "llama3.2".into(),
        "llamacpp" => "ggml-org/gpt-oss-20b-GGUF".into(),
        "sglang" | "vllm" | "osaurus" | "opencode-go" => "default".into(),
        "gemini" => "gemini-2.5-pro".into(),
        "kimi-code" => "kimi-for-coding".into(),
        "bedrock" => "anthropic.claude-sonnet-4-5-20250929-v1:0".into(),
        "nvidia" => "meta/llama-3.3-70b-instruct".into(),
        "avian" => "deepseek/deepseek-v3.2".into(),
        "copilot" => "gpt-4o".into(),
        _ => "anthropic/claude-sonnet-4.6".into(),
    }
}

fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> {
    match canonical_provider_name(provider_name) {
        "openrouter" => vec![
            (
                "anthropic/claude-sonnet-4.6".to_string(),
                "Claude Sonnet 4.6 (balanced, recommended)".to_string(),
            ),
            (
                "openai/gpt-5.2".to_string(),
                "GPT-5.2 (latest flagship)".to_string(),
            ),
            (
                "openai/gpt-5-mini".to_string(),
                "GPT-5 mini (fast, cost-efficient)".to_string(),
            ),
            (
                "google/gemini-3-pro-preview".to_string(),
                "Gemini 3 Pro Preview (frontier reasoning)".to_string(),
            ),
            (
                "x-ai/grok-4.1-fast".to_string(),
                "Grok 4.1 Fast (reasoning + speed)".to_string(),
            ),
            (
                "deepseek/deepseek-v3.2".to_string(),
                "DeepSeek V3.2 (agentic + affordable)".to_string(),
            ),
            (
                "meta-llama/llama-4-maverick".to_string(),
                "Llama 4 Maverick (open model)".to_string(),
            ),
        ],
        "anthropic" => vec![
            (
                "claude-sonnet-4-5-20250929".to_string(),
                "Claude Sonnet 4.5 (balanced, recommended)".to_string(),
            ),
            (
                "claude-opus-4-6".to_string(),
                "Claude Opus 4.6 (best quality)".to_string(),
            ),
            (
                "claude-haiku-4-5-20251001".to_string(),
                "Claude Haiku 4.5 (fastest, cheapest)".to_string(),
            ),
        ],
        "openai" => vec![
            (
                "gpt-5.2".to_string(),
                "GPT-5.2 (latest coding/agentic flagship)".to_string(),
            ),
            (
                "gpt-5-mini".to_string(),
                "GPT-5 mini (faster, cheaper)".to_string(),
            ),
            (
                "gpt-5-nano".to_string(),
                "GPT-5 nano (lowest latency/cost)".to_string(),
            ),
            (
                "gpt-5.2-codex".to_string(),
                "GPT-5.2 Codex (agentic coding)".to_string(),
            ),
        ],
        "openai-codex" => vec![
            (
                "gpt-5-codex".to_string(),
                "GPT-5 Codex (recommended)".to_string(),
            ),
            (
                "gpt-5.2-codex".to_string(),
                "GPT-5.2 Codex (agentic coding)".to_string(),
            ),
            ("o4-mini".to_string(), "o4-mini (fallback)".to_string()),
        ],
        "copilot" => vec![
            ("gpt-4o".to_string(), "GPT-4o".to_string()),
            ("gpt-4.1".to_string(), "GPT-4.1".to_string()),
            ("gpt-5-mini".to_string(), "GPT-5 mini".to_string()),
            (
                "claude-sonnet-4.6".to_string(),
                "Claude Sonnet 4.6".to_string(),
            ),
            ("gpt-5.3-codex".to_string(), "GPT-5.3 Codex".to_string()),
            ("claude-opus-4.6".to_string(), "Claude Opus 4.6".to_string()),
        ],
        "venice" => vec![
            (
                "zai-org-glm-5".to_string(),
                "GLM-5 via Venice (agentic flagship)".to_string(),
            ),
            (
                "claude-sonnet-4-6".to_string(),
                "Claude Sonnet 4.6 via Venice (best quality)".to_string(),
            ),
            (
                "deepseek-v3.2".to_string(),
                "DeepSeek V3.2 via Venice (strong value)".to_string(),
            ),
            (
                "grok-41-fast".to_string(),
                "Grok 4.1 Fast via Venice (low latency)".to_string(),
            ),
        ],
        "groq" => vec![
            (
                "llama-3.3-70b-versatile".to_string(),
                "Llama 3.3 70B (fast, recommended)".to_string(),
            ),
            (
                "openai/gpt-oss-120b".to_string(),
                "GPT-OSS 120B (strong open-weight)".to_string(),
            ),
            (
                "openai/gpt-oss-20b".to_string(),
                "GPT-OSS 20B (cost-efficient open-weight)".to_string(),
            ),
        ],
        "mistral" => vec![
            (
                "mistral-large-latest".to_string(),
                "Mistral Large (latest flagship)".to_string(),
            ),
            (
                "mistral-medium-latest".to_string(),
                "Mistral Medium (balanced)".to_string(),
            ),
            (
                "codestral-latest".to_string(),
                "Codestral (code-focused)".to_string(),
            ),
            (
                "devstral-latest".to_string(),
                "Devstral (software engineering specialist)".to_string(),
            ),
        ],
        "deepseek" => vec![
            (
                "deepseek-chat".to_string(),
                "DeepSeek Chat (mapped to V3.2 non-thinking)".to_string(),
            ),
            (
                "deepseek-reasoner".to_string(),
                "DeepSeek Reasoner (mapped to V3.2 thinking)".to_string(),
            ),
        ],
        "xai" => vec![
            (
                "grok-4-1-fast-reasoning".to_string(),
                "Grok 4.1 Fast Reasoning (recommended)".to_string(),
            ),
            (
                "grok-4-1-fast-non-reasoning".to_string(),
                "Grok 4.1 Fast Non-Reasoning (low latency)".to_string(),
            ),
            (
                "grok-code-fast-1".to_string(),
                "Grok Code Fast 1 (coding specialist)".to_string(),
            ),
            ("grok-4".to_string(), "Grok 4 (max quality)".to_string()),
        ],
        "perplexity" => vec![
            (
                "sonar-pro".to_string(),
                "Sonar Pro (flagship web-grounded model)".to_string(),
            ),
            (
                "sonar-reasoning-pro".to_string(),
                "Sonar Reasoning Pro (complex multi-step reasoning)".to_string(),
            ),
            (
                "sonar-deep-research".to_string(),
                "Sonar Deep Research (long-form research)".to_string(),
            ),
            ("sonar".to_string(), "Sonar (search, fast)".to_string()),
        ],
        "fireworks" => vec![
            (
                "accounts/fireworks/models/llama-v3p3-70b-instruct".to_string(),
                "Llama 3.3 70B".to_string(),
            ),
            (
                "accounts/fireworks/models/mixtral-8x22b-instruct".to_string(),
                "Mixtral 8x22B".to_string(),
            ),
        ],
        "novita" => vec![
            (
                "minimax/minimax-m2.7".to_string(),
                "MiniMax M2.7 (latest flagship)".to_string(),
            ),
            (
                "minimax/minimax-m2.5".to_string(),
                "MiniMax M2.5".to_string(),
            ),
        ],
        "together-ai" => vec![
            (
                "meta-llama/Llama-3.3-70B-Instruct-Turbo".to_string(),
                "Llama 3.3 70B Instruct Turbo (recommended)".to_string(),
            ),
            (
                "moonshotai/Kimi-K2.5".to_string(),
                "Kimi K2.5 (reasoning + coding)".to_string(),
            ),
            (
                "deepseek-ai/DeepSeek-V3.1".to_string(),
                "DeepSeek V3.1 (strong value)".to_string(),
            ),
        ],
        "cohere" => vec![
            (
                "command-a-03-2025".to_string(),
                "Command A (flagship enterprise model)".to_string(),
            ),
            (
                "command-a-reasoning-08-2025".to_string(),
                "Command A Reasoning (agentic reasoning)".to_string(),
            ),
            (
                "command-r-08-2024".to_string(),
                "Command R (stable fast baseline)".to_string(),
            ),
        ],
        "kimi-code" => vec![
            (
                "kimi-for-coding".to_string(),
                "Kimi for Coding (official coding-agent model)".to_string(),
            ),
            (
                "kimi-k2.5".to_string(),
                "Kimi K2.5 (general coding endpoint model)".to_string(),
            ),
        ],
        "moonshot" => vec![
            (
                "kimi-k2.5".to_string(),
                "Kimi K2.5 (latest flagship, recommended)".to_string(),
            ),
            (
                "kimi-k2-thinking".to_string(),
                "Kimi K2 Thinking (deep reasoning + tool use)".to_string(),
            ),
            (
                "kimi-k2-0905-preview".to_string(),
                "Kimi K2 0905 Preview (strong coding)".to_string(),
            ),
        ],
        "glm" | "zai" => vec![
            ("glm-5".to_string(), "GLM-5 (high reasoning)".to_string()),
            (
                "glm-4.7".to_string(),
                "GLM-4.7 (strong general-purpose quality)".to_string(),
            ),
            (
                "glm-4.5-air".to_string(),
                "GLM-4.5 Air (lower latency)".to_string(),
            ),
        ],
        "minimax" => vec![
            (
                "MiniMax-M2.7".to_string(),
                "MiniMax M2.7 (latest flagship)".to_string(),
            ),
            (
                "MiniMax-M2.7-highspeed".to_string(),
                "MiniMax M2.7 High-Speed (fast)".to_string(),
            ),
            (
                "MiniMax-M2.5".to_string(),
                "MiniMax M2.5 (stable)".to_string(),
            ),
            (
                "MiniMax-M2.5-highspeed".to_string(),
                "MiniMax M2.5 High-Speed (fast)".to_string(),
            ),
            (
                "MiniMax-M2.1".to_string(),
                "MiniMax M2.1 (previous gen)".to_string(),
            ),
        ],
        "qwen" => vec![
            (
                "qwen-max".to_string(),
                "Qwen Max (highest quality)".to_string(),
            ),
            (
                "qwen-plus".to_string(),
                "Qwen Plus (balanced default)".to_string(),
            ),
            (
                "qwen-turbo".to_string(),
                "Qwen Turbo (fast and cost-efficient)".to_string(),
            ),
        ],
        "qwen-code" => vec![
            (
                "qwen3-coder-plus".to_string(),
                "Qwen3 Coder Plus (recommended for coding workflows)".to_string(),
            ),
            (
                "qwen3.5-plus".to_string(),
                "Qwen3.5 Plus (reasoning + coding)".to_string(),
            ),
            (
                "qwen3-max-2026-01-23".to_string(),
                "Qwen3 Max (high-capability coding model)".to_string(),
            ),
        ],
        "nvidia" => vec![
            (
                "meta/llama-3.3-70b-instruct".to_string(),
                "Llama 3.3 70B Instruct (balanced default)".to_string(),
            ),
            (
                "deepseek-ai/deepseek-v3.2".to_string(),
                "DeepSeek V3.2 (advanced reasoning + coding)".to_string(),
            ),
            (
                "nvidia/llama-3.3-nemotron-super-49b-v1.5".to_string(),
                "Llama 3.3 Nemotron Super 49B v1.5 (NVIDIA-tuned)".to_string(),
            ),
            (
                "nvidia/llama-3.1-nemotron-ultra-253b-v1".to_string(),
                "Llama 3.1 Nemotron Ultra 253B v1 (max quality)".to_string(),
            ),
        ],
        "astrai" => vec![
            (
                "anthropic/claude-sonnet-4.6".to_string(),
                "Claude Sonnet 4.6 (balanced default)".to_string(),
            ),
            (
                "openai/gpt-5.2".to_string(),
                "GPT-5.2 (latest flagship)".to_string(),
            ),
            (
                "deepseek/deepseek-v3.2".to_string(),
                "DeepSeek V3.2 (agentic + affordable)".to_string(),
            ),
            (
                "z-ai/glm-5".to_string(),
                "GLM-5 (high reasoning)".to_string(),
            ),
        ],
        "avian" => vec![
            (
                "deepseek/deepseek-v3.2".to_string(),
                "DeepSeek V3.2 (164K context, recommended)".to_string(),
            ),
            (
                "moonshotai/kimi-k2.5".to_string(),
                "Kimi K2.5 (131K context)".to_string(),
            ),
            ("z-ai/glm-5".to_string(), "GLM-5 (131K context)".to_string()),
            (
                "minimax/minimax-m2.5".to_string(),
                "MiniMax M2.5 (1M context)".to_string(),
            ),
        ],
        "ollama" => vec![
            (
                "llama3.2".to_string(),
                "Llama 3.2 (recommended local)".to_string(),
            ),
            ("mistral".to_string(), "Mistral 7B".to_string()),
            ("codellama".to_string(), "Code Llama".to_string()),
            ("phi3".to_string(), "Phi-3 (small, fast)".to_string()),
        ],
        "llamacpp" => vec![
            (
                "ggml-org/gpt-oss-20b-GGUF".to_string(),
                "GPT-OSS 20B GGUF (llama.cpp server example)".to_string(),
            ),
            (
                "bartowski/Llama-3.3-70B-Instruct-GGUF".to_string(),
                "Llama 3.3 70B GGUF (high quality)".to_string(),
            ),
            (
                "Qwen/Qwen2.5-Coder-7B-Instruct-GGUF".to_string(),
                "Qwen2.5 Coder 7B GGUF (coding-focused)".to_string(),
            ),
        ],
        "sglang" | "vllm" => vec![
            (
                "meta-llama/Llama-3.1-8B-Instruct".to_string(),
                "Llama 3.1 8B Instruct (popular, fast)".to_string(),
            ),
            (
                "meta-llama/Llama-3.1-70B-Instruct".to_string(),
                "Llama 3.1 70B Instruct (high quality)".to_string(),
            ),
            (
                "Qwen/Qwen2.5-Coder-7B-Instruct".to_string(),
                "Qwen2.5 Coder 7B Instruct (coding-focused)".to_string(),
            ),
        ],
        "osaurus" => vec![
            (
                "qwen3-30b-a3b-8bit".to_string(),
                "Qwen3 30B A3B (local, balanced)".to_string(),
            ),
            (
                "gemma-3n-e4b-it-lm-4bit".to_string(),
                "Gemma 3N E4B (local, efficient)".to_string(),
            ),
            (
                "phi-4-mini-reasoning-mlx-4bit".to_string(),
                "Phi-4 Mini Reasoning (local, fast reasoning)".to_string(),
            ),
        ],
        "bedrock" => vec![
            (
                "anthropic.claude-sonnet-4-6".to_string(),
                "Claude Sonnet 4.6 (latest, recommended)".to_string(),
            ),
            (
                "anthropic.claude-opus-4-6-v1".to_string(),
                "Claude Opus 4.6 (strongest)".to_string(),
            ),
            (
                "anthropic.claude-haiku-4-5-20251001-v1:0".to_string(),
                "Claude Haiku 4.5 (fastest, cheapest)".to_string(),
            ),
            (
                "anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(),
                "Claude Sonnet 4.5".to_string(),
            ),
        ],
        "gemini" => vec![
            (
                "gemini-3-pro-preview".to_string(),
                "Gemini 3 Pro Preview (latest frontier reasoning)".to_string(),
            ),
            (
                "gemini-2.5-pro".to_string(),
                "Gemini 2.5 Pro (stable reasoning)".to_string(),
            ),
            (
                "gemini-2.5-flash".to_string(),
                "Gemini 2.5 Flash (best price/performance)".to_string(),
            ),
            (
                "gemini-2.5-flash-lite".to_string(),
                "Gemini 2.5 Flash-Lite (lowest cost)".to_string(),
            ),
        ],
        _ => vec![("default".to_string(), "Default model".to_string())],
    }
}

fn supports_live_model_fetch(provider_name: &str) -> bool {
    if provider_name.trim().starts_with("custom:") {
        return true;
    }

    matches!(
        canonical_provider_name(provider_name),
        "openrouter"
            | "openai-codex"
            | "openai"
            | "anthropic"
            | "groq"
            | "mistral"
            | "deepseek"
            | "xai"
            | "together-ai"
            | "gemini"
            | "ollama"
            | "llamacpp"
            | "sglang"
            | "vllm"
            | "osaurus"
            | "astrai"
            | "avian"
            | "venice"
            | "fireworks"
            | "novita"
            | "cohere"
            | "moonshot"
            | "glm"
            | "zai"
            | "qwen"
            | "nvidia"
            | "opencode-go"
    )
}

fn models_endpoint_for_provider(provider_name: &str) -> Option<&'static str> {
    match provider_name {
        "qwen-intl" => Some("https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models"),
        "dashscope-us" => Some("https://dashscope-us.aliyuncs.com/compatible-mode/v1/models"),
        "moonshot-cn" | "kimi-cn" => Some("https://api.moonshot.cn/v1/models"),
        "glm-cn" | "bigmodel" => Some("https://open.bigmodel.cn/api/paas/v4/models"),
        "zai-cn" | "z.ai-cn" => Some("https://open.bigmodel.cn/api/coding/paas/v4/models"),
        _ => match canonical_provider_name(provider_name) {
            "openai-codex" | "openai" => Some("https://api.openai.com/v1/models"),
            "venice" => Some("https://api.venice.ai/api/v1/models"),
            "groq" => Some("https://api.groq.com/openai/v1/models"),
            "mistral" => Some("https://api.mistral.ai/v1/models"),
            "deepseek" => Some("https://api.deepseek.com/v1/models"),
            "xai" => Some("https://api.x.ai/v1/models"),
            "together-ai" => Some("https://api.together.xyz/v1/models"),
            "fireworks" => Some("https://api.fireworks.ai/inference/v1/models"),
            "novita" => Some("https://api.novita.ai/openai/v1/models"),
            "cohere" => Some("https://api.cohere.com/compatibility/v1/models"),
            "moonshot" => Some("https://api.moonshot.ai/v1/models"),
            "glm" => Some("https://api.z.ai/api/paas/v4/models"),
            "zai" => Some("https://api.z.ai/api/coding/paas/v4/models"),
            "qwen" => Some("https://dashscope.aliyuncs.com/compatible-mode/v1/models"),
            "nvidia" => Some("https://integrate.api.nvidia.com/v1/models"),
            "astrai" => Some("https://as-trai.com/v1/models"),
            "avian" => Some("https://api.avian.io/v1/models"),
            "llamacpp" => Some("http://localhost:8080/v1/models"),
            "sglang" => Some("http://localhost:30000/v1/models"),
            "vllm" => Some("http://localhost:8000/v1/models"),
            "osaurus" => Some("http://localhost:1337/v1/models"),
            "opencode-go" => Some("https://opencode.ai/zen/go/v1/models"),
            _ => None,
        },
    }
}

fn build_model_fetch_client() -> Result<reqwest::Client> {
    reqwest::Client::builder()
        .timeout(Duration::from_secs(8))
        .connect_timeout(Duration::from_secs(4))
        .build()
        .context("failed to build model-fetch HTTP client")
}

fn normalize_model_ids(ids: Vec<String>) -> Vec<String> {
    let mut unique = BTreeMap::new();
    for id in ids {
        let trimmed = id.trim();
        if !trimmed.is_empty() {
            unique
                .entry(trimmed.to_ascii_lowercase())
                .or_insert_with(|| trimmed.to_string());
        }
    }
    unique.into_values().collect()
}

fn parse_openai_compatible_model_ids(payload: &Value) -> Vec<String> {
    let mut models = Vec::new();

    if let Some(data) = payload.get("data").and_then(Value::as_array) {
        for model in data {
            if let Some(id) = model.get("id").and_then(Value::as_str) {
                models.push(id.to_string());
            }
        }
    } else if let Some(data) = payload.as_array() {
        for model in data {
            if let Some(id) = model.get("id").and_then(Value::as_str) {
                models.push(id.to_string());
            }
        }
    }

    normalize_model_ids(models)
}

fn parse_gemini_model_ids(payload: &Value) -> Vec<String> {
    let Some(models) = payload.get("models").and_then(Value::as_array) else {
        return Vec::new();
    };

    let mut ids = Vec::new();
    for model in models {
        let supports_generate_content = model
            .get("supportedGenerationMethods")
            .and_then(Value::as_array)
            .is_none_or(|methods| {
                methods
                    .iter()
                    .any(|method| method.as_str() == Some("generateContent"))
            });

        if !supports_generate_content {
            continue;
        }

        if let Some(name) = model.get("name").and_then(Value::as_str) {
            ids.push(name.trim_start_matches("models/").to_string());
        }
    }

    normalize_model_ids(ids)
}

fn parse_ollama_model_ids(payload: &Value) -> Vec<String> {
    let Some(models) = payload.get("models").and_then(Value::as_array) else {
        return Vec::new();
    };

    let mut ids = Vec::new();
    for model in models {
        if let Some(name) = model.get("name").and_then(Value::as_str) {
            ids.push(name.to_string());
        }
    }

    normalize_model_ids(ids)
}

async fn fetch_openai_compatible_models(
    endpoint: &str,
    api_key: Option<&str>,
    allow_unauthenticated: bool,
) -> Result<Vec<String>> {
    let client = build_model_fetch_client()?;
    let mut request = client.get(endpoint);

    if let Some(api_key) = api_key {
        request = request.bearer_auth(api_key);
    } else if !allow_unauthenticated {
        bail!("model fetch requires API key for endpoint {endpoint}");
    }

    let payload: Value = request
        .send()
        .await
        .and_then(reqwest::Response::error_for_status)
        .with_context(|| format!("model fetch failed: GET {endpoint}"))?
        .json()
        .await
        .context("failed to parse model list response")?;

    Ok(parse_openai_compatible_model_ids(&payload))
}

async fn fetch_openrouter_models(api_key: Option<&str>) -> Result<Vec<String>> {
    let client = build_model_fetch_client()?;
    let mut request = client.get("https://openrouter.ai/api/v1/models");
    if let Some(api_key) = api_key {
        request = request.bearer_auth(api_key);
    }

    let payload: Value = request
        .send()
        .await
        .and_then(reqwest::Response::error_for_status)
        .context("model fetch failed: GET https://openrouter.ai/api/v1/models")?
        .json()
        .await
        .context("failed to parse OpenRouter model list response")?;

    Ok(parse_openai_compatible_model_ids(&payload))
}

async fn fetch_anthropic_models(api_key: Option<&str>) -> Result<Vec<String>> {
    let Some(api_key) = api_key else {
        bail!("Anthropic model fetch requires API key or OAuth token");
    };

    let client = build_model_fetch_client()?;
    let mut request = client
        .get("https://api.anthropic.com/v1/models")
        .header("anthropic-version", "2023-06-01");

    if api_key.starts_with("sk-ant-oat01-") {
        request = request
            .header("Authorization", format!("Bearer {api_key}"))
            .header("anthropic-beta", "oauth-2025-04-20");
    } else {
        request = request.header("x-api-key", api_key);
    }

    let response = request
        .send()
        .await
        .context("model fetch failed: GET https://api.anthropic.com/v1/models")?;

    let status = response.status();
    if !status.is_success() {
        let body = response.text().await.unwrap_or_default();
        bail!("Anthropic model list request failed (HTTP {status}): {body}");
    }

    let payload: Value = response
        .json()
        .await
        .context("failed to parse Anthropic model list response")?;

    Ok(parse_openai_compatible_model_ids(&payload))
}

async fn fetch_gemini_models(api_key: Option<&str>) -> Result<Vec<String>> {
    let Some(api_key) = api_key else {
        bail!("Gemini model fetch requires API key");
    };

    let client = build_model_fetch_client()?;
    let payload: Value = client
        .get("https://generativelanguage.googleapis.com/v1beta/models")
        .query(&[("key", api_key), ("pageSize", "200")])
        .send()
        .await
        .and_then(reqwest::Response::error_for_status)
        .context("model fetch failed: GET Gemini models")?
        .json()
        .await
        .context("failed to parse Gemini model list response")?;

    Ok(parse_gemini_model_ids(&payload))
}

async fn fetch_ollama_models(endpoint_override: Option<&str>) -> Result<Vec<String>> {
    let client = build_model_fetch_client()?;
    let endpoint = match endpoint_override {
        Some(base) => {
            let normalized = normalize_ollama_endpoint_url(base);
            if normalized.is_empty() {
                default_local_url(11434, "/api/tags").ok_or_else(|| {
                    anyhow::anyhow!(
                        "Ollama endpoint URL is required (no safe default for this container runtime)"
                    )
                })?
            } else {
                format!("{normalized}/api/tags")
            }
        }
        None => default_local_url(11434, "/api/tags").ok_or_else(|| {
            anyhow::anyhow!(
                "Ollama endpoint URL is required (no safe default for this container runtime)"
            )
        })?,
    };
    let payload: Value = client
        .get(&endpoint)
        .send()
        .await
        .and_then(reqwest::Response::error_for_status)
        .with_context(|| format!("model fetch failed: GET {endpoint}"))?
        .json()
        .await
        .context("failed to parse Ollama model list response")?;

    Ok(parse_ollama_model_ids(&payload))
}

fn normalize_ollama_endpoint_url(raw_url: &str) -> String {
    let trimmed = raw_url.trim().trim_end_matches('/');
    if trimmed.is_empty() {
        return String::new();
    }
    trimmed
        .strip_suffix("/api")
        .unwrap_or(trimmed)
        .trim_end_matches('/')
        .to_string()
}

fn ollama_endpoint_is_local(endpoint_url: &str) -> bool {
    let Ok(url) = reqwest::Url::parse(endpoint_url) else {
        return false;
    };

    let Some(host) = url.host_str().map(|h| h.to_ascii_lowercase()) else {
        return false;
    };

    // Strip brackets from IPv6 addresses for comparison
    let host_clean = host.trim_start_matches('[').trim_end_matches(']');

    // Explicit loopback and container host aliases - always local regardless of scheme
    if matches!(
        host_clean,
        "localhost"
            | "127.0.0.1"
            | "::1"
            | "0.0.0.0"
            | "host.docker.internal"
            | "host.containers.internal"
    ) {
        return true;
    }

    // For K8s service names, only treat as local if using HTTP (not HTTPS).
    // Remote cloud endpoints typically use HTTPS, so this avoids misclassifying
    // a user's explicit "remote Ollama" choice as local.
    if url.scheme() != "http" {
        return false;
    }

    // Kubernetes/internal service names (HTTP only):
    // e.g., "ollama", "ollama.default.svc", "ollama.default.svc.cluster.local"
    //
    // Any dotless hostname over HTTP is treated as a local K8s/internal
    // service name. This classification only affects model-list filtering
    // (the `:cloud` suffix visibility) and is not used as a security
    // boundary. HTTPS with bare hostnames is deliberately excluded above
    // to preserve remote-Ollama semantics.
    if !host_clean.contains('.') {
        return true; // Simple service name like "ollama"
    }

    // Common internal/cluster suffixes (HTTP only)
    if host_clean.ends_with(".svc")
        || host_clean.ends_with(".svc.cluster.local")
        || host_clean.ends_with(".local")
        || host_clean.ends_with(".internal")
    {
        return true;
    }

    false
}

fn ollama_uses_remote_endpoint(provider_api_url: Option<&str>) -> bool {
    let Some(endpoint) = provider_api_url else {
        return false;
    };

    let normalized = normalize_ollama_endpoint_url(endpoint);
    if normalized.is_empty() {
        return false;
    }

    !ollama_endpoint_is_local(&normalized)
}

fn resolve_live_models_endpoint(
    provider_name: &str,
    provider_api_url: Option<&str>,
) -> Option<String> {
    if let Some(raw_base) = provider_name.strip_prefix("custom:") {
        let normalized = raw_base.trim().trim_end_matches('/');
        if normalized.is_empty() {
            return None;
        }
        if normalized.ends_with("/models") {
            return Some(normalized.to_string());
        }
        return Some(format!("{normalized}/models"));
    }

    if matches!(
        canonical_provider_name(provider_name),
        "llamacpp" | "sglang" | "vllm" | "osaurus"
    ) && let Some(url) = provider_api_url
        .map(str::trim)
        .filter(|url| !url.is_empty())
    {
        let normalized = url.trim_end_matches('/');
        if normalized.ends_with("/models") {
            return Some(normalized.to_string());
        }
        return Some(format!("{normalized}/models"));
    }

    if canonical_provider_name(provider_name) == "openai-codex"
        && let Some(url) = provider_api_url
            .map(str::trim)
            .filter(|url| !url.is_empty())
    {
        let normalized = url.trim_end_matches('/');
        if normalized.ends_with("/models") {
            return Some(normalized.to_string());
        }
        return Some(format!("{normalized}/models"));
    }

    models_endpoint_for_provider(provider_name).map(str::to_string)
}

async fn fetch_live_models_for_provider(
    provider_name: &str,
    api_key: &str,
    provider_api_url: Option<&str>,
) -> Result<Vec<String>> {
    let requested_provider_name = provider_name;
    let provider_name = canonical_provider_name(provider_name);
    let ollama_remote = provider_name == "ollama" && ollama_uses_remote_endpoint(provider_api_url);
    let api_key = if api_key.trim().is_empty() {
        if provider_name == "ollama" && !ollama_remote {
            None
        } else {
            std::env::var(provider_env_var(provider_name))
                .ok()
                .or_else(|| {
                    // Anthropic also accepts OAuth setup-tokens via ANTHROPIC_OAUTH_TOKEN
                    if provider_name == "anthropic" {
                        std::env::var("ANTHROPIC_OAUTH_TOKEN").ok()
                    } else if provider_name == "minimax" {
                        std::env::var("MINIMAX_OAUTH_TOKEN").ok()
                    } else {
                        None
                    }
                })
                .map(|value| value.trim().to_string())
                .filter(|value| !value.is_empty())
        }
    } else {
        Some(api_key.trim().to_string())
    };

    let models = match provider_name {
        "openrouter" => fetch_openrouter_models(api_key.as_deref()).await?,
        "anthropic" => fetch_anthropic_models(api_key.as_deref()).await?,
        "gemini" => fetch_gemini_models(api_key.as_deref()).await?,
        "ollama" => {
            if ollama_remote {
                // Remote Ollama endpoints can serve cloud-routed models.
                // Keep this curated list aligned with current Ollama cloud catalog.
                vec![
                    "glm-5:cloud".to_string(),
                    "glm-4.7:cloud".to_string(),
                    "gpt-oss:20b:cloud".to_string(),
                    "gpt-oss:120b:cloud".to_string(),
                    "gemini-3-flash-preview:cloud".to_string(),
                    "qwen3-coder-next:cloud".to_string(),
                    "qwen3-coder:480b:cloud".to_string(),
                    "kimi-k2.5:cloud".to_string(),
                    "minimax-m2.7:cloud".to_string(),
                    "deepseek-v3.1:671b:cloud".to_string(),
                ]
            } else {
                // Local endpoints should not surface cloud-only suffixes.
                // Pass the configured endpoint URL (may be container-aware like host.docker.internal)
                fetch_ollama_models(provider_api_url)
                    .await?
                    .into_iter()
                    .filter(|model_id| !model_id.ends_with(":cloud"))
                    .collect()
            }
        }
        _ => {
            if let Some(endpoint) =
                resolve_live_models_endpoint(requested_provider_name, provider_api_url)
            {
                let allow_unauthenticated =
                    allows_unauthenticated_model_fetch(requested_provider_name);
                fetch_openai_compatible_models(&endpoint, api_key.as_deref(), allow_unauthenticated)
                    .await?
            } else {
                Vec::new()
            }
        }
    };

    Ok(models)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct ModelCacheEntry {
    provider: String,
    fetched_at_unix: u64,
    models: Vec<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ModelCacheState {
    entries: Vec<ModelCacheEntry>,
}

#[derive(Debug, Clone)]
struct CachedModels {
    models: Vec<String>,
    age_secs: u64,
}

fn model_cache_path(workspace_dir: &Path) -> PathBuf {
    workspace_dir.join("state").join(MODEL_CACHE_FILE)
}

fn now_unix_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |duration| duration.as_secs())
}

async fn load_model_cache_state(workspace_dir: &Path) -> Result<ModelCacheState> {
    let path = model_cache_path(workspace_dir);
    if !path.exists() {
        return Ok(ModelCacheState::default());
    }

    let raw = fs::read_to_string(&path)
        .await
        .with_context(|| format!("failed to read model cache at {}", path.display()))?;

    match serde_json::from_str::<ModelCacheState>(&raw) {
        Ok(state) => Ok(state),
        Err(_) => Ok(ModelCacheState::default()),
    }
}

async fn save_model_cache_state(workspace_dir: &Path, state: &ModelCacheState) -> Result<()> {
    let path = model_cache_path(workspace_dir);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).await.with_context(|| {
            format!(
                "failed to create model cache directory {}",
                parent.display()
            )
        })?;
    }

    let json = serde_json::to_vec_pretty(state).context("failed to serialize model cache")?;
    fs::write(&path, json)
        .await
        .with_context(|| format!("failed to write model cache at {}", path.display()))?;

    Ok(())
}

async fn cache_live_models_for_provider(
    workspace_dir: &Path,
    provider_name: &str,
    models: &[String],
) -> Result<()> {
    let normalized_models = normalize_model_ids(models.to_vec());
    if normalized_models.is_empty() {
        return Ok(());
    }

    let mut state = load_model_cache_state(workspace_dir).await?;
    let now = now_unix_secs();

    if let Some(entry) = state
        .entries
        .iter_mut()
        .find(|entry| entry.provider == provider_name)
    {
        entry.fetched_at_unix = now;
        entry.models = normalized_models;
    } else {
        state.entries.push(ModelCacheEntry {
            provider: provider_name.to_string(),
            fetched_at_unix: now,
            models: normalized_models,
        });
    }

    save_model_cache_state(workspace_dir, &state).await
}

async fn load_cached_models_for_provider_internal(
    workspace_dir: &Path,
    provider_name: &str,
    ttl_secs: Option<u64>,
) -> Result<Option<CachedModels>> {
    let state = load_model_cache_state(workspace_dir).await?;
    let now = now_unix_secs();

    let Some(entry) = state
        .entries
        .into_iter()
        .find(|entry| entry.provider == provider_name)
    else {
        return Ok(None);
    };

    if entry.models.is_empty() {
        return Ok(None);
    }

    let age_secs = now.saturating_sub(entry.fetched_at_unix);
    if ttl_secs.is_some_and(|ttl| age_secs > ttl) {
        return Ok(None);
    }

    Ok(Some(CachedModels {
        models: entry.models,
        age_secs,
    }))
}

async fn load_cached_models_for_provider(
    workspace_dir: &Path,
    provider_name: &str,
    ttl_secs: u64,
) -> Result<Option<CachedModels>> {
    load_cached_models_for_provider_internal(workspace_dir, provider_name, Some(ttl_secs)).await
}

async fn load_any_cached_models_for_provider(
    workspace_dir: &Path,
    provider_name: &str,
) -> Result<Option<CachedModels>> {
    load_cached_models_for_provider_internal(workspace_dir, provider_name, None).await
}

fn humanize_age(age_secs: u64) -> String {
    if age_secs < 60 {
        format!("{age_secs}s")
    } else if age_secs < 60 * 60 {
        format!("{}m", age_secs / 60)
    } else {
        format!("{}h", age_secs / (60 * 60))
    }
}

fn build_model_options(model_ids: Vec<String>, source: &str) -> Vec<(String, String)> {
    model_ids
        .into_iter()
        .map(|model_id| {
            let label = format!("{model_id} ({source})");
            (model_id, label)
        })
        .collect()
}

fn print_model_preview(models: &[String]) {
    for model in models.iter().take(MODEL_PREVIEW_LIMIT) {
        println!("  {} {model}", style("-"));
    }

    if models.len() > MODEL_PREVIEW_LIMIT {
        println!(
            "  {} ... and {} more",
            style("-"),
            models.len() - MODEL_PREVIEW_LIMIT
        );
    }
}

pub async fn run_models_refresh(
    config: &Config,
    provider_override: Option<&str>,
    force: bool,
) -> Result<()> {
    let provider_name = provider_override
        .or(config.providers.fallback.as_deref())
        .unwrap_or("openrouter")
        .trim()
        .to_string();

    if provider_name.is_empty() {
        anyhow::bail!("Provider name cannot be empty");
    }

    if !supports_live_model_fetch(&provider_name) {
        anyhow::bail!("Provider '{provider_name}' does not support live model discovery yet");
    }

    if !force
        && let Some(cached) = load_cached_models_for_provider(
            &config.workspace_dir,
            &provider_name,
            MODEL_CACHE_TTL_SECS,
        )
        .await?
    {
        println!(
            "Using cached model list for '{}' (updated {} ago):",
            provider_name,
            humanize_age(cached.age_secs)
        );
        print_model_preview(&cached.models);
        println!();
        println!(
            "Tip: run `zeroclaw models refresh --force --provider {}` to fetch latest now.",
            provider_name
        );
        return Ok(());
    }

    let api_key = config
        .providers
        .fallback_provider()
        .and_then(|e| e.api_key.clone())
        .unwrap_or_default();

    match fetch_live_models_for_provider(
        &provider_name,
        &api_key,
        config
            .providers
            .fallback_provider()
            .and_then(|e| e.base_url.as_deref()),
    )
    .await
    {
        Ok(models) if !models.is_empty() => {
            cache_live_models_for_provider(&config.workspace_dir, &provider_name, &models).await?;
            println!(
                "Refreshed '{}' model cache with {} models.",
                provider_name,
                models.len()
            );
            print_model_preview(&models);
            Ok(())
        }
        Ok(_) => {
            if let Some(stale_cache) =
                load_any_cached_models_for_provider(&config.workspace_dir, &provider_name).await?
            {
                println!(
                    "Provider returned no models; using stale cache (updated {} ago):",
                    humanize_age(stale_cache.age_secs)
                );
                print_model_preview(&stale_cache.models);
                return Ok(());
            }

            anyhow::bail!("Provider '{}' returned an empty model list", provider_name)
        }
        Err(error) => {
            if let Some(stale_cache) =
                load_any_cached_models_for_provider(&config.workspace_dir, &provider_name).await?
            {
                println!(
                    "Live refresh failed ({}). Falling back to stale cache (updated {} ago):",
                    error,
                    humanize_age(stale_cache.age_secs)
                );
                print_model_preview(&stale_cache.models);
                return Ok(());
            }

            Err(error)
                .with_context(|| format!("failed to refresh models for provider '{provider_name}'"))
        }
    }
}

pub async fn run_models_list(config: &Config, provider_override: Option<&str>) -> Result<()> {
    let provider_name = provider_override
        .or(config.providers.fallback.as_deref())
        .unwrap_or("openrouter");

    let cached = load_any_cached_models_for_provider(&config.workspace_dir, provider_name).await?;

    let Some(cached) = cached else {
        println!();
        println!(
            "  No cached models for '{provider_name}'. Run: zeroclaw models refresh --provider {provider_name}"
        );
        println!();
        return Ok(());
    };

    println!();
    println!(
        "  {} models for '{}' (cached {} ago):",
        cached.models.len(),
        provider_name,
        humanize_age(cached.age_secs)
    );
    println!();
    for model in &cached.models {
        let marker = if config
            .providers
            .fallback_provider()
            .and_then(|e| e.model.as_deref())
            == Some(model.as_str())
        {
            "* "
        } else {
            "  "
        };
        println!("  {marker}{model}");
    }
    println!();
    Ok(())
}

pub async fn run_models_set(config: &Config, model: &str) -> Result<()> {
    let model = model.trim();
    if model.is_empty() {
        anyhow::bail!("Model name cannot be empty");
    }

    let mut updated = config.clone();
    updated.ensure_fallback_provider().model = Some(model.to_string());
    updated.save().await?;

    println!();
    println!("  Default model set to '{}'.", style(model).green().bold());
    println!();
    Ok(())
}

pub async fn run_models_status(config: &Config) -> Result<()> {
    let provider = config.providers.fallback.as_deref().unwrap_or("openrouter");
    let model = config
        .providers
        .fallback_provider()
        .and_then(|e| e.model.as_deref())
        .unwrap_or("(not set)");

    println!();
    println!("  Provider:  {}", style(provider).cyan());
    println!("  Model:     {}", style(model).cyan());
    println!(
        "  Temp:      {}",
        style(format!(
            "{:.1}",
            config
                .providers
                .fallback_provider()
                .and_then(|e| e.temperature)
                .unwrap_or(0.7)
        ))
        .cyan()
    );

    match load_any_cached_models_for_provider(&config.workspace_dir, provider).await? {
        Some(cached) => {
            println!(
                "  Cache:     {} models (updated {} ago)",
                cached.models.len(),
                humanize_age(cached.age_secs)
            );
            let fresh = cached.age_secs < MODEL_CACHE_TTL_SECS;
            if fresh {
                println!("  Freshness: {}", style("fresh").green());
            } else {
                println!("  Freshness: {}", style("stale").yellow());
            }
        }
        None => {
            println!("  Cache:     {}", style("none").yellow());
        }
    }

    println!();
    Ok(())
}

pub async fn cached_model_catalog_stats(
    config: &Config,
    provider_name: &str,
) -> Result<Option<(usize, u64)>> {
    let Some(cached) =
        load_any_cached_models_for_provider(&config.workspace_dir, provider_name).await?
    else {
        return Ok(None);
    };
    Ok(Some((cached.models.len(), cached.age_secs)))
}

pub async fn run_models_refresh_all(config: &Config, force: bool) -> Result<()> {
    let mut targets: Vec<String> = zeroclaw_providers::list_providers()
        .into_iter()
        .map(|provider| provider.name.to_string())
        .filter(|name| supports_live_model_fetch(name))
        .collect();

    targets.sort();
    targets.dedup();

    if targets.is_empty() {
        anyhow::bail!("No providers support live model discovery");
    }

    println!(
        "Refreshing model catalogs for {} providers (force: {})",
        targets.len(),
        if force { "yes" } else { "no" }
    );
    println!();

    let mut ok_count = 0usize;
    let mut fail_count = 0usize;

    for provider_name in &targets {
        println!("== {} ==", provider_name);
        match run_models_refresh(config, Some(provider_name), force).await {
            Ok(()) => {
                ok_count += 1;
            }
            Err(error) => {
                fail_count += 1;
                println!("  failed: {error}");
            }
        }
        println!();
    }

    println!("Summary: {} succeeded, {} failed", ok_count, fail_count);

    if ok_count == 0 {
        anyhow::bail!("Model refresh failed for all providers")
    }
    Ok(())
}

// ── Step helpers ─────────────────────────────────────────────────

fn print_step(current: u8, total: u8, title: &str) {
    println!();
    println!(
        "  {} {}",
        style(format!("[{current}/{total}]")).cyan().bold(),
        style(title).white().bold()
    );
    println!("  {}", style("─".repeat(50)).dim());
}

fn print_bullet(text: &str) {
    println!("  {} {}", style("›").cyan(), text);
}

fn resolve_interactive_onboarding_mode(
    config_path: &Path,
    force: bool,
) -> Result<InteractiveOnboardingMode> {
    if !config_path.exists() {
        return Ok(InteractiveOnboardingMode::FullOnboarding);
    }

    if force {
        println!(
            "  {} Existing config detected at {}. Proceeding with full onboarding because --force was provided.",
            style("!").yellow().bold(),
            style(config_path.display()).yellow()
        );
        return Ok(InteractiveOnboardingMode::FullOnboarding);
    }

    if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
        bail!(
            "Refusing to overwrite existing config at {} in non-interactive mode. Re-run with --force if overwrite is intentional.",
            config_path.display()
        );
    }

    let options = [
        "Full onboarding (overwrite config.toml)",
        "Update AI provider/model/API key only (preserve existing configuration)",
        "Cancel",
    ];

    let mode = Select::new()
        .with_prompt(format!(
            "  Existing config found at {}. Select setup mode",
            config_path.display()
        ))
        .items(options)
        .default(1)
        .interact()?;

    match mode {
        0 => Ok(InteractiveOnboardingMode::FullOnboarding),
        1 => Ok(InteractiveOnboardingMode::UpdateProviderOnly),
        _ => bail!("Onboarding canceled: existing configuration was left unchanged."),
    }
}

fn ensure_onboard_overwrite_allowed(config_path: &Path, force: bool) -> Result<()> {
    if !config_path.exists() {
        return Ok(());
    }

    if force {
        println!(
            "  {} Existing config detected at {}. Proceeding because --force was provided.",
            style("!").yellow().bold(),
            style(config_path.display()).yellow()
        );
        return Ok(());
    }

    #[cfg(test)]
    {
        bail!(
            "Refusing to overwrite existing config at {} in test mode. Re-run with --force if overwrite is intentional.",
            config_path.display()
        );
    }

    #[cfg(not(test))]
    {
        if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
            bail!(
                "Refusing to overwrite existing config at {} in non-interactive mode. Re-run with --force if overwrite is intentional.",
                config_path.display()
            );
        }

        let confirmed = Confirm::new()
            .with_prompt(format!(
                "  Existing config found at {}. Re-running onboarding will overwrite config.toml and may create missing workspace files (including BOOTSTRAP.md). Continue?",
                config_path.display()
            ))
            .default(false)
            .interact()?;

        if !confirmed {
            bail!("Onboarding canceled: existing configuration was left unchanged.");
        }

        Ok(())
    }
}

async fn persist_workspace_selection(config_path: &Path) -> Result<()> {
    let config_dir = config_path
        .parent()
        .context("Config path must have a parent directory")?;
    zeroclaw_config::schema::persist_active_workspace_config_dir(config_dir)
        .await
        .with_context(|| {
            format!(
                "Failed to persist active workspace selection for {}",
                config_dir.display()
            )
        })
}

// ── Step 1: Workspace ────────────────────────────────────────────

async fn setup_workspace() -> Result<(PathBuf, PathBuf)> {
    let (default_config_dir, default_workspace_dir) =
        zeroclaw_config::schema::resolve_runtime_dirs_for_onboarding().await?;

    print_bullet(&format!(
        "Default location: {}",
        style(default_workspace_dir.display()).green()
    ));

    let use_default = Confirm::new()
        .with_prompt("  Use default workspace location?")
        .default(true)
        .interact()?;

    let (config_dir, workspace_dir) = if use_default {
        (default_config_dir, default_workspace_dir)
    } else {
        let custom: String = Input::new()
            .with_prompt("  Enter workspace path")
            .interact_text()?;
        let expanded = shellexpand::tilde(&custom).to_string();
        zeroclaw_config::schema::resolve_config_dir_for_workspace(&PathBuf::from(expanded))
    };

    let config_path = config_dir.join("config.toml");

    fs::create_dir_all(&workspace_dir)
        .await
        .context("Failed to create workspace directory")?;

    println!(
        "  {} Workspace: {}",
        style("✓").green().bold(),
        style(workspace_dir.display()).green()
    );

    Ok((workspace_dir, config_path))
}

// ── Step 2: Provider & API Key ───────────────────────────────────

#[allow(clippy::too_many_lines)]
async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Option<String>)> {
    // ── Tier selection ──
    let tiers = vec![
        "⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI, Gemini)",
        "⚡ Fast inference (Groq, Fireworks, Together AI, NVIDIA NIM)",
        "🌐 Gateway / proxy (Vercel AI, Cloudflare AI, Amazon Bedrock)",
        "🔬 Specialized (Moonshot/Kimi, GLM/Zhipu, MiniMax, Qwen/DashScope, Qianfan, Z.AI, Synthetic, OpenCode Zen, Cohere, GitHub Copilot)",
        "🏠 Local / private (Ollama, llama.cpp server, vLLM — no API key needed)",
        "🔧 Custom — bring your own OpenAI-compatible API",
    ];

    let tier_idx = Select::new()
        .with_prompt("  Select provider category")
        .items(&tiers)
        .default(0)
        .interact()?;

    let providers: Vec<(&str, &str)> = match tier_idx {
        0 => vec![
            (
                "openrouter",
                "OpenRouter — 200+ models, 1 API key (recommended)",
            ),
            ("venice", "Venice AI — privacy-first (Llama, Opus)"),
            ("anthropic", "Anthropic — Claude Sonnet & Opus (direct)"),
            ("openai", "OpenAI — GPT-4o, o1, GPT-5 (direct)"),
            (
                "openai-codex",
                "OpenAI Codex (ChatGPT subscription OAuth, no API key)",
            ),
            ("deepseek", "DeepSeek — V3 & R1 (affordable)"),
            ("mistral", "Mistral — Large & Codestral"),
            ("xai", "xAI — Grok 3 & 4"),
            ("perplexity", "Perplexity — search-augmented AI"),
            (
                "gemini",
                "Google Gemini — Gemini 2.0 Flash & Pro (supports CLI auth)",
            ),
        ],
        1 => vec![
            ("groq", "Groq — ultra-fast LPU inference"),
            ("fireworks", "Fireworks AI — fast open-source inference"),
            ("novita", "Novita AI — affordable open-source inference"),
            ("together-ai", "Together AI — open-source model hosting"),
            ("nvidia", "NVIDIA NIM — DeepSeek, Llama, & more"),
        ],
        2 => vec![
            ("vercel", "Vercel AI Gateway"),
            ("cloudflare", "Cloudflare AI Gateway"),
            (
                "astrai",
                "Astrai — compliant AI routing (PII stripping, cost optimization)",
            ),
            (
                "avian",
                "Avian — OpenAI-compatible inference (DeepSeek, Kimi, GLM, MiniMax)",
            ),
            ("bedrock", "Amazon Bedrock — AWS managed models"),
        ],
        3 => vec![
            (
                "kimi-code",
                "Kimi Code — coding-optimized Kimi API (KimiCLI)",
            ),
            (
                "qwen-code",
                "Qwen Code — OAuth tokens reused from ~/.qwen/oauth_creds.json",
            ),
            ("moonshot", "Moonshot — Kimi API (China endpoint)"),
            (
                "moonshot-intl",
                "Moonshot — Kimi API (international endpoint)",
            ),
            ("glm", "GLM — ChatGLM / Zhipu (international endpoint)"),
            ("glm-cn", "GLM — ChatGLM / Zhipu (China endpoint)"),
            (
                "minimax",
                "MiniMax — international endpoint (api.minimax.io)",
            ),
            ("minimax-cn", "MiniMax — China endpoint (api.minimaxi.com)"),
            ("qwen", "Qwen — DashScope China endpoint"),
            ("qwen-intl", "Qwen — DashScope international endpoint"),
            ("qwen-us", "Qwen — DashScope US endpoint"),
            ("qianfan", "Qianfan — Baidu AI models (China endpoint)"),
            ("zai", "Z.AI — global coding endpoint"),
            ("zai-cn", "Z.AI — China coding endpoint (open.bigmodel.cn)"),
            ("synthetic", "Synthetic — Synthetic AI models"),
            ("opencode", "OpenCode Zen — code-focused AI"),
            ("opencode-go", "OpenCode Go — Subsidized code-focused AI"),
            ("cohere", "Cohere — Command R+ & embeddings"),
            ("copilot", "GitHub Copilot"),
        ],
        4 => local_provider_choices(),
        _ => vec![], // Custom — handled below
    };

    // ── Custom / BYOP flow ──
    if providers.is_empty() {
        println!();
        println!(
            "  {} {}",
            style("Custom Provider Setup").white().bold(),
            style("— any OpenAI-compatible API").dim()
        );
        print_bullet("KylinBot works with ANY API that speaks the OpenAI chat completions format.");
        print_bullet("Examples: LiteLLM, LocalAI, vLLM, text-generation-webui, LM Studio, etc.");
        println!();

        let base_url: String = Input::new()
            .with_prompt("  API base URL (e.g. http://localhost:1234 or https://my-api.com)")
            .interact_text()?;

        let base_url = base_url.trim().trim_end_matches('/').to_string();
        if base_url.is_empty() {
            anyhow::bail!("Custom provider requires a base URL.");
        }

        let api_key: String = Input::new()
            .with_prompt("  API key (or Enter to skip if not needed)")
            .allow_empty(true)
            .interact_text()?;

        let model: String = Input::new()
            .with_prompt("  Model name (e.g. llama3, gpt-4o, mistral)")
            .default("default")
            .interact_text()?;

        let provider_name = format!("custom:{base_url}");

        println!(
            "  {} Provider: {} | Model: {}",
            style("✓").green().bold(),
            style(&provider_name).green(),
            style(&model).green()
        );

        return Ok((provider_name, api_key, model, None));
    }

    let provider_labels: Vec<&str> = providers.iter().map(|(_, label)| *label).collect();

    let provider_idx = Select::new()
        .with_prompt("  Select your AI provider")
        .items(&provider_labels)
        .default(0)
        .interact()?;

    let provider_name = providers[provider_idx].0;

    // ── API key / endpoint ──
    let mut provider_api_url: Option<String> = None;
    let api_key = if provider_name == "ollama" {
        let use_remote_ollama = Confirm::new()
            .with_prompt("  Use a remote Ollama endpoint (for example Ollama Cloud)?")
            .default(false)
            .interact()?;

        if use_remote_ollama {
            let raw_url: String = Input::new()
                .with_prompt("  Remote Ollama endpoint URL")
                .default("https://ollama.com")
                .interact_text()?;

            let normalized_url = normalize_ollama_endpoint_url(&raw_url);
            if normalized_url.is_empty() {
                anyhow::bail!("Remote Ollama endpoint URL cannot be empty.");
            }
            let parsed = reqwest::Url::parse(&normalized_url)
                .context("Remote Ollama endpoint URL must be a valid URL")?;
            if !matches!(parsed.scheme(), "http" | "https") {
                anyhow::bail!("Remote Ollama endpoint URL must use http:// or https://");
            }

            provider_api_url = Some(normalized_url.clone());

            print_bullet(&format!(
                "Remote endpoint configured: {}",
                style(&normalized_url).cyan()
            ));
            if raw_url.trim().trim_end_matches('/') != normalized_url {
                print_bullet("Normalized endpoint to base URL (removed trailing /api).");
            }
            print_bullet(&format!(
                "If you use cloud-only models, append {} to the model ID.",
                style(":cloud").yellow()
            ));

            let key: String = Input::new()
                .with_prompt("  API key for remote Ollama endpoint (or Enter to skip)")
                .allow_empty(true)
                .interact_text()?;

            if key.trim().is_empty() {
                print_bullet(&format!(
                    "No API key provided. Set {} later if required by your endpoint.",
                    style("OLLAMA_API_KEY").yellow()
                ));
            }

            key
        } else {
            // Local Ollama: compute container-aware URL and persist it
            // For runtimes without a known host alias (e.g., Kubernetes), require explicit input
            if let Some(ollama_url) = default_local_url(11434, "") {
                provider_api_url = Some(ollama_url.clone());
                print_bullet(&format!(
                    "Using local Ollama at {} (no API key needed).",
                    style(&ollama_url).cyan()
                ));
                String::new()
            } else {
                print_bullet(&format!(
                    "Running in a container without a known host alias. {} may not resolve to the host.",
                    style("localhost").yellow()
                ));
                let raw_url: String = Input::new()
                    .with_prompt("  Ollama endpoint URL (e.g., http://<service-name>:11434)")
                    .interact_text()?;

                let normalized_url = normalize_ollama_endpoint_url(&raw_url);
                if normalized_url.is_empty() {
                    anyhow::bail!("Ollama endpoint URL cannot be empty.");
                }
                // Validate URL format
                let parsed = reqwest::Url::parse(&normalized_url).context(
                    "Ollama endpoint URL must be a valid URL (e.g., http://service:11434)",
                )?;
                if !matches!(parsed.scheme(), "http" | "https") {
                    anyhow::bail!("Ollama endpoint URL must use http:// or https://");
                }
                provider_api_url = Some(normalized_url.clone());
                print_bullet(&format!(
                    "Using Ollama at {} (no API key needed).",
                    style(&normalized_url).cyan()
                ));
                String::new()
            }
        }
    } else if matches!(provider_name, "llamacpp" | "llama.cpp") {
        let (url, key) = prompt_local_provider_endpoint(&LocalProviderPromptConfig {
            display_name: "llama.cpp",
            port: 8080,
            path: "/v1",
            api_key_env_var: "LLAMACPP_API_KEY",
        })?;
        provider_api_url = Some(url);
        key
    } else if provider_name == "sglang" {
        let (url, key) = prompt_local_provider_endpoint(&LocalProviderPromptConfig {
            display_name: "SGLang",
            port: 30000,
            path: "/v1",
            api_key_env_var: "SGLANG_API_KEY",
        })?;
        provider_api_url = Some(url);
        key
    } else if provider_name == "vllm" {
        let (url, key) = prompt_local_provider_endpoint(&LocalProviderPromptConfig {
            display_name: "vLLM",
            port: 8000,
            path: "/v1",
            api_key_env_var: "VLLM_API_KEY",
        })?;
        provider_api_url = Some(url);
        key
    } else if provider_name == "osaurus" {
        let (url, key) = prompt_local_provider_endpoint(&LocalProviderPromptConfig {
            display_name: "Osaurus",
            port: 1337,
            path: "/v1",
            api_key_env_var: "OSAURUS_API_KEY",
        })?;
        provider_api_url = Some(url);
        key
    } else if canonical_provider_name(provider_name) == "gemini" {
        // Special handling for Gemini: check for CLI auth first
        if zeroclaw_providers::gemini::GeminiProvider::has_cli_credentials() {
            print_bullet(&format!(
                "{} Gemini CLI credentials detected! You can skip the API key.",
                style("✓").green().bold()
            ));
            print_bullet("KylinBot will reuse your existing Gemini CLI authentication.");
            println!();

            let use_cli: bool = dialoguer::Confirm::new()
                .with_prompt("  Use existing Gemini CLI authentication?")
                .default(true)
                .interact()?;

            if use_cli {
                println!(
                    "  {} Using Gemini CLI OAuth tokens",
                    style("✓").green().bold()
                );
                String::new() // Empty key = will use CLI tokens
            } else {
                print_bullet("Get your API key at: https://aistudio.google.com/app/apikey");
                Input::new()
                    .with_prompt("  Paste your Gemini API key")
                    .allow_empty(true)
                    .interact_text()?
            }
        } else if std::env::var("GEMINI_API_KEY").is_ok() {
            print_bullet(&format!(
                "{} GEMINI_API_KEY environment variable detected!",
                style("✓").green().bold()
            ));
            String::new()
        } else {
            print_bullet("Get your API key at: https://aistudio.google.com/app/apikey");
            print_bullet("Or run `gemini` CLI to authenticate (tokens will be reused).");
            println!();

            Input::new()
                .with_prompt("  Paste your Gemini API key (or press Enter to skip)")
                .allow_empty(true)
                .interact_text()?
        }
    } else if canonical_provider_name(provider_name) == "anthropic" {
        if std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok() {
            print_bullet(&format!(
                "{} ANTHROPIC_OAUTH_TOKEN environment variable detected!",
                style("✓").green().bold()
            ));
            String::new()
        } else if std::env::var("ANTHROPIC_API_KEY").is_ok() {
            print_bullet(&format!(
                "{} ANTHROPIC_API_KEY environment variable detected!",
                style("✓").green().bold()
            ));
            String::new()
        } else {
            print_bullet(&format!(
                "Get your API key at: {}",
                style("https://console.anthropic.com/settings/keys")
                    .cyan()
                    .underlined()
            ));
            print_bullet("Or run `claude setup-token` to get an OAuth setup-token.");
            println!();

            let key: String = Input::new()
                .with_prompt("  Paste your API key or setup-token (or press Enter to skip)")
                .allow_empty(true)
                .interact_text()?;

            if key.is_empty() {
                print_bullet(&format!(
                    "Skipped. Set {} or {} or edit config.toml later.",
                    style("ANTHROPIC_API_KEY").yellow(),
                    style("ANTHROPIC_OAUTH_TOKEN").yellow()
                ));
            }

            key
        }
    } else if canonical_provider_name(provider_name) == "qwen-code" {
        if std::env::var("QWEN_OAUTH_TOKEN").is_ok() {
            print_bullet(&format!(
                "{} QWEN_OAUTH_TOKEN environment variable detected!",
                style("✓").green().bold()
            ));
            "qwen-oauth".to_string()
        } else {
            print_bullet(
                "Qwen Code OAuth credentials are usually stored in ~/.qwen/oauth_creds.json.",
            );
            print_bullet(
                "Run `qwen` once and complete OAuth login to populate cached credentials.",
            );
            print_bullet("You can also set QWEN_OAUTH_TOKEN directly.");
            println!();

            let key: String = Input::new()
                .with_prompt(
                    "  Paste your Qwen OAuth token (or press Enter to auto-detect cached OAuth)",
                )
                .allow_empty(true)
                .interact_text()?;

            if key.trim().is_empty() {
                print_bullet(&format!(
                    "Using OAuth auto-detection. Set {} and optional {} if needed.",
                    style("QWEN_OAUTH_TOKEN").yellow(),
                    style("QWEN_OAUTH_RESOURCE_URL").yellow()
                ));
                "qwen-oauth".to_string()
            } else {
                key
            }
        }
    } else if canonical_provider_name(provider_name) == "copilot" {
        // GitHub Copilot uses OAuth device flow — no API key needed during setup.
        // Check if a GITHUB_TOKEN is already available in the environment.
        if std::env::var("GITHUB_TOKEN").is_ok() {
            print_bullet(&format!(
                "{} GITHUB_TOKEN environment variable detected!",
                style("✓").green().bold()
            ));
            print_bullet("KylinBot will use it for GitHub Copilot authentication.");
        } else {
            print_bullet("GitHub Copilot uses OAuth device-flow authentication.");
            print_bullet("No API key is needed — you'll be prompted to authorize on first run.");
            print_bullet(&format!(
                "Or set {} to use a pre-existing GitHub personal access token.",
                style("GITHUB_TOKEN").yellow()
            ));
        }
        println!();
        String::new()
    } else {
        let key_url = if is_moonshot_alias(provider_name)
            || canonical_provider_name(provider_name) == "kimi-code"
        {
            "https://platform.moonshot.cn/console/api-keys"
        } else if canonical_provider_name(provider_name) == "qwen-code" {
            "https://qwen.readthedocs.io/en/latest/getting_started/installation.html"
        } else if is_glm_cn_alias(provider_name) || is_zai_cn_alias(provider_name) {
            "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys"
        } else if is_glm_alias(provider_name) || is_zai_alias(provider_name) {
            "https://platform.z.ai/"
        } else if is_minimax_alias(provider_name) {
            "https://www.minimaxi.com/user-center/basic-information"
        } else if is_qwen_alias(provider_name) {
            "https://help.aliyun.com/zh/model-studio/developer-reference/get-api-key"
        } else if is_qianfan_alias(provider_name) {
            "https://cloud.baidu.com/doc/WENXINWORKSHOP/s/7lm0vxo78"
        } else {
            match provider_name {
                "openrouter" => "https://openrouter.ai/keys",
                "openai" => "https://platform.openai.com/api-keys",
                "venice" => "https://venice.ai/settings/api",
                "groq" => "https://console.groq.com/keys",
                "mistral" => "https://console.mistral.ai/api-keys",
                "deepseek" => "https://platform.deepseek.com/api_keys",
                "together-ai" => "https://api.together.xyz/settings/api-keys",
                "fireworks" => "https://fireworks.ai/account/api-keys",
                "novita" => "https://novita.ai/settings/key-management",
                "perplexity" => "https://www.perplexity.ai/settings/api",
                "xai" => "https://console.x.ai",
                "cohere" => "https://dashboard.cohere.com/api-keys",
                "vercel" => "https://vercel.com/account/tokens",
                "cloudflare" => "https://dash.cloudflare.com/profile/api-tokens",
                "nvidia" | "nvidia-nim" | "build.nvidia.com" => "https://build.nvidia.com/",
                "bedrock" => "https://console.aws.amazon.com/iam",
                "gemini" => "https://aistudio.google.com/app/apikey",
                "astrai" => "https://as-trai.com",
                "avian" => "https://avian.io",
                _ => "",
            }
        };

        println!();
        if matches!(provider_name, "bedrock" | "aws-bedrock") {
            // Bedrock uses AWS AKSK, not a single API key.
            print_bullet("Bedrock uses AWS credentials (not a single API key).");
            print_bullet(&format!(
                "Set {} and {} environment variables.",
                style("AWS_ACCESS_KEY_ID").yellow(),
                style("AWS_SECRET_ACCESS_KEY").yellow(),
            ));
            print_bullet(&format!(
                "Optionally set {} for the region (default: us-east-1).",
                style("AWS_REGION").yellow(),
            ));
            if !key_url.is_empty() {
                print_bullet(&format!(
                    "Manage IAM credentials at: {}",
                    style(key_url).cyan().underlined()
                ));
            }
            println!();
            String::new()
        } else {
            if !key_url.is_empty() {
                print_bullet(&format!(
                    "Get your API key at: {}",
                    style(key_url).cyan().underlined()
                ));
            }
            print_bullet("You can also set it later via env var or config file.");
            println!();

            let key: String = Input::new()
                .with_prompt("  Paste your API key (or press Enter to skip)")
                .allow_empty(true)
                .interact_text()?;

            if key.is_empty() {
                let env_var = provider_env_var(provider_name);
                print_bullet(&format!(
                    "Skipped. Set {} or edit config.toml later.",
                    style(env_var).yellow()
                ));
            }

            key
        }
    };

    // ── Model selection ──
    let canonical_provider = canonical_provider_name(provider_name);
    let mut model_options: Vec<(String, String)> = curated_models_for_provider(canonical_provider);

    let mut live_options: Option<Vec<(String, String)>> = None;

    if supports_live_model_fetch(provider_name) {
        let ollama_remote = canonical_provider == "ollama"
            && ollama_uses_remote_endpoint(provider_api_url.as_deref());
        let can_fetch_without_key =
            allows_unauthenticated_model_fetch(provider_name) && !ollama_remote;
        let has_api_key = !api_key.trim().is_empty()
            || ((canonical_provider != "ollama" || ollama_remote)
                && std::env::var(provider_env_var(provider_name))
                    .ok()
                    .is_some_and(|value| !value.trim().is_empty()))
            || (provider_name == "minimax"
                && std::env::var("MINIMAX_OAUTH_TOKEN")
                    .ok()
                    .is_some_and(|value| !value.trim().is_empty()));

        if canonical_provider == "ollama" && ollama_remote && !has_api_key {
            print_bullet(&format!(
                "Remote Ollama live-model refresh needs an API key ({}); using curated models.",
                style("OLLAMA_API_KEY").yellow()
            ));
        }

        if can_fetch_without_key || has_api_key {
            if let Some(cached) =
                load_cached_models_for_provider(workspace_dir, provider_name, MODEL_CACHE_TTL_SECS)
                    .await?
            {
                let shown_count = cached.models.len().min(LIVE_MODEL_MAX_OPTIONS);
                print_bullet(&format!(
                    "Found cached models ({shown_count}) updated {} ago.",
                    humanize_age(cached.age_secs)
                ));

                live_options = Some(build_model_options(
                    cached
                        .models
                        .into_iter()
                        .take(LIVE_MODEL_MAX_OPTIONS)
                        .collect(),
                    "cached",
                ));
            }

            let should_fetch_now = Confirm::new()
                .with_prompt(if live_options.is_some() {
                    "  Refresh models from provider now?"
                } else {
                    "  Fetch latest models from provider now?"
                })
                .default(live_options.is_none())
                .interact()?;

            if should_fetch_now {
                match fetch_live_models_for_provider(
                    provider_name,
                    &api_key,
                    provider_api_url.as_deref(),
                )
                .await
                {
                    Ok(live_model_ids) if !live_model_ids.is_empty() => {
                        cache_live_models_for_provider(
                            workspace_dir,
                            provider_name,
                            &live_model_ids,
                        )
                        .await?;

                        let fetched_count = live_model_ids.len();
                        let shown_count = fetched_count.min(LIVE_MODEL_MAX_OPTIONS);
                        let shown_models: Vec<String> = live_model_ids
                            .into_iter()
                            .take(LIVE_MODEL_MAX_OPTIONS)
                            .collect();

                        if shown_count < fetched_count {
                            print_bullet(&format!(
                                "Fetched {fetched_count} models. Showing first {shown_count}."
                            ));
                        } else {
                            print_bullet(&format!("Fetched {shown_count} live models."));
                        }

                        live_options = Some(build_model_options(shown_models, "live"));
                    }
                    Ok(_) => {
                        print_bullet("Provider returned no models; using curated list.");
                    }
                    Err(error) => {
                        print_bullet(&format!(
                            "Live fetch failed ({}); using cached/curated list.",
                            style(error.to_string()).yellow()
                        ));

                        if live_options.is_none()
                            && let Some(stale) =
                                load_any_cached_models_for_provider(workspace_dir, provider_name)
                                    .await?
                        {
                            print_bullet(&format!(
                                "Loaded stale cache from {} ago.",
                                humanize_age(stale.age_secs)
                            ));

                            live_options = Some(build_model_options(
                                stale
                                    .models
                                    .into_iter()
                                    .take(LIVE_MODEL_MAX_OPTIONS)
                                    .collect(),
                                "stale-cache",
                            ));
                        }
                    }
                }
            }
        } else {
            print_bullet("No API key detected, so using curated model list.");
            print_bullet("Tip: add an API key and rerun onboarding to fetch live models.");
        }
    }

    if let Some(live_model_options) = live_options {
        let source_options = vec![
            format!("Provider model list ({})", live_model_options.len()),
            format!("Curated starter list ({})", model_options.len()),
        ];

        let source_idx = Select::new()
            .with_prompt("  Model source")
            .items(&source_options)
            .default(0)
            .interact()?;

        if source_idx == 0 {
            model_options = live_model_options;
        }
    }

    if model_options.is_empty() {
        model_options.push((
            default_model_for_provider(provider_name),
            "Provider default model".to_string(),
        ));
    }

    model_options.push((
        CUSTOM_MODEL_SENTINEL.to_string(),
        "Custom model ID (type manually)".to_string(),
    ));

    let model_labels: Vec<String> = model_options
        .iter()
        .map(|(model_id, label)| format!("{label} — {}", style(model_id).dim()))
        .collect();

    let model_idx = Select::new()
        .with_prompt("  Select your default model")
        .items(&model_labels)
        .default(0)
        .interact()?;

    let selected_model = model_options[model_idx].0.clone();
    let model = if selected_model == CUSTOM_MODEL_SENTINEL {
        Input::new()
            .with_prompt("  Enter custom model ID")
            .default(default_model_for_provider(provider_name))
            .interact_text()?
    } else {
        selected_model
    };

    println!(
        "  {} Provider: {} | Model: {}",
        style("✓").green().bold(),
        style(provider_name).green(),
        style(&model).green()
    );

    Ok((provider_name.to_string(), api_key, model, provider_api_url))
}

fn local_provider_choices() -> Vec<(&'static str, &'static str)> {
    vec![
        ("ollama", "Ollama — local models (Llama, Mistral, Phi)"),
        (
            "llamacpp",
            "llama.cpp server — local OpenAI-compatible endpoint",
        ),
        (
            "sglang",
            "SGLang — high-performance local serving framework",
        ),
        ("vllm", "vLLM — high-performance local inference engine"),
        (
            "osaurus",
            "Osaurus — unified AI edge runtime (local MLX + cloud proxy + MCP)",
        ),
    ]
}

/// Map provider name to its conventional env var
fn provider_env_var(name: &str) -> &'static str {
    if canonical_provider_name(name) == "qwen-code" {
        return "QWEN_OAUTH_TOKEN";
    }

    match canonical_provider_name(name) {
        "openrouter" => "OPENROUTER_API_KEY",
        "anthropic" => "ANTHROPIC_API_KEY",
        "openai-codex" | "openai" => "OPENAI_API_KEY",
        "ollama" => "OLLAMA_API_KEY",
        "llamacpp" => "LLAMACPP_API_KEY",
        "sglang" => "SGLANG_API_KEY",
        "vllm" => "VLLM_API_KEY",
        "osaurus" => "OSAURUS_API_KEY",
        "venice" => "VENICE_API_KEY",
        "groq" => "GROQ_API_KEY",
        "mistral" => "MISTRAL_API_KEY",
        "deepseek" => "DEEPSEEK_API_KEY",
        "xai" => "XAI_API_KEY",
        "together-ai" => "TOGETHER_API_KEY",
        "fireworks" | "fireworks-ai" => "FIREWORKS_API_KEY",
        "novita" => "NOVITA_API_KEY",
        "perplexity" => "PERPLEXITY_API_KEY",
        "cohere" => "COHERE_API_KEY",
        "kimi-code" => "KIMI_CODE_API_KEY",
        "moonshot" => "MOONSHOT_API_KEY",
        "glm" => "GLM_API_KEY",
        "minimax" => "MINIMAX_API_KEY",
        "qwen" => "DASHSCOPE_API_KEY",
        "qianfan" => "QIANFAN_API_KEY",
        "zai" => "ZAI_API_KEY",
        "synthetic" => "SYNTHETIC_API_KEY",
        "opencode" | "opencode-zen" => "OPENCODE_API_KEY",
        "opencode-go" => "OPENCODE_GO_API_KEY",
        "vercel" | "vercel-ai" => "VERCEL_API_KEY",
        "cloudflare" | "cloudflare-ai" => "CLOUDFLARE_API_KEY",
        "bedrock" | "aws-bedrock" => "AWS_ACCESS_KEY_ID",
        "gemini" => "GEMINI_API_KEY",
        "nvidia" | "nvidia-nim" | "build.nvidia.com" => "NVIDIA_API_KEY",
        "astrai" => "ASTRAI_API_KEY",
        "avian" => "AVIAN_API_KEY",
        "copilot" => "GITHUB_TOKEN",
        _ => "API_KEY",
    }
}

fn provider_supports_keyless_local_usage(provider_name: &str) -> bool {
    matches!(
        canonical_provider_name(provider_name),
        "ollama" | "llamacpp" | "sglang" | "vllm" | "osaurus"
    )
}

fn provider_supports_device_flow(provider_name: &str) -> bool {
    matches!(
        canonical_provider_name(provider_name),
        "copilot" | "gemini" | "openai-codex"
    )
}

// ── Step 5: Tool Mode & Security ────────────────────────────────

fn setup_tool_mode() -> Result<(ComposioConfig, SecretsConfig)> {
    print_bullet("Choose how KylinBot connects to external apps.");
    print_bullet("You can always change this later in config.toml.");
    println!();

    let options = vec![
        "Sovereign (local only) — you manage API keys, full privacy (default)",
        "Composio (managed OAuth) — 1000+ apps via OAuth, no raw keys shared",
    ];

    let choice = Select::new()
        .with_prompt("  Select tool mode")
        .items(&options)
        .default(0)
        .interact()?;

    let composio_config = if choice == 1 {
        println!();
        println!(
            "  {} {}",
            style("Composio Setup").white().bold(),
            style("— 1000+ OAuth integrations (Gmail, Notion, GitHub, Slack, ...)").dim()
        );
        print_bullet("Get your API key at: https://app.composio.dev/settings");
        print_bullet("KylinBot uses Composio as a tool — your core agent stays local.");
        println!();

        let api_key: String = Input::new()
            .with_prompt("  Composio API key (or Enter to skip)")
            .allow_empty(true)
            .interact_text()?;

        if api_key.trim().is_empty() {
            println!(
                "  {} Skipped — set composio.api_key in config.toml later",
                style("→").dim()
            );
            ComposioConfig::default()
        } else {
            println!(
                "  {} Composio: {} (1000+ OAuth tools available)",
                style("✓").green().bold(),
                style("enabled").green()
            );
            ComposioConfig {
                enabled: true,
                api_key: Some(api_key),
                ..ComposioConfig::default()
            }
        }
    } else {
        println!(
            "  {} Tool mode: {} — full privacy, you own every key",
            style("✓").green().bold(),
            style("Sovereign (local only)").green()
        );
        ComposioConfig::default()
    };

    // ── Encrypted secrets ──
    println!();
    print_bullet("KylinBot can encrypt API keys stored in config.toml.");
    print_bullet("A local key file protects against plaintext exposure and accidental leaks.");

    let encrypt = Confirm::new()
        .with_prompt("  Enable encrypted secret storage?")
        .default(true)
        .interact()?;

    let secrets_config = SecretsConfig { encrypt };

    if encrypt {
        println!(
            "  {} Secrets: {} — keys encrypted with local key file",
            style("✓").green().bold(),
            style("encrypted").green()
        );
    } else {
        println!(
            "  {} Secrets: {} — keys stored as plaintext (not recommended)",
            style("✓").green().bold(),
            style("plaintext").yellow()
        );
    }

    Ok((composio_config, secrets_config))
}

// ── Step 6: Project Context ─────────────────────────────────────

fn setup_project_context() -> Result<ProjectContext> {
    print_bullet("Let's personalize your agent. You can always update these later.");
    print_bullet("Press Enter to accept defaults.");
    println!();

    let user_name: String = Input::new()
        .with_prompt("  Your name")
        .default("User")
        .interact_text()?;

    let tz_options = vec![
        "US/Eastern (EST/EDT)",
        "US/Central (CST/CDT)",
        "US/Mountain (MST/MDT)",
        "US/Pacific (PST/PDT)",
        "Europe/London (GMT/BST)",
        "Europe/Berlin (CET/CEST)",
        "Asia/Tokyo (JST)",
        "UTC",
        "Other (type manually)",
    ];

    let tz_idx = Select::new()
        .with_prompt("  Your timezone")
        .items(&tz_options)
        .default(0)
        .interact()?;

    let timezone = if tz_idx == tz_options.len() - 1 {
        Input::new()
            .with_prompt("  Enter timezone (e.g. America/New_York)")
            .default("UTC")
            .interact_text()?
    } else {
        // Extract the short label before the parenthetical
        tz_options[tz_idx]
            .split('(')
            .next()
            .unwrap_or("UTC")
            .trim()
            .to_string()
    };

    let agent_name: String = Input::new()
        .with_prompt("  Agent name")
        .default("KylinBot")
        .interact_text()?;

    let style_options = vec![
        "Direct & concise — skip pleasantries, get to the point",
        "Friendly & casual — warm, human, and helpful",
        "Professional & polished — calm, confident, and clear",
        "Expressive & playful — more personality + natural emojis",
        "Technical & detailed — thorough explanations, code-first",
        "Balanced — adapt to the situation",
        "Custom — write your own style guide",
    ];

    let style_idx = Select::new()
        .with_prompt("  Communication style")
        .items(&style_options)
        .default(1)
        .interact()?;

    let communication_style = match style_idx {
        0 => "Be direct and concise. Skip pleasantries. Get to the point.".to_string(),
        1 => "Be friendly, human, and conversational. Show warmth and empathy while staying efficient. Use natural contractions.".to_string(),
        2 => "Be professional and polished. Stay calm, structured, and respectful. Use occasional tone-setting emojis only when appropriate.".to_string(),
        3 => "Be expressive and playful when appropriate. Use relevant emojis naturally (0-2 max), and keep serious topics emoji-light.".to_string(),
        4 => "Be technical and detailed. Thorough explanations, code-first.".to_string(),
        5 => "Adapt to the situation. Default to warm and clear communication; be concise when needed, thorough when it matters.".to_string(),
        _ => Input::new()
            .with_prompt("  Custom communication style")
            .default(
                "Be warm, natural, and clear. Use occasional relevant emojis (1-2 max) and avoid robotic phrasing.",
            )
            .interact_text()?,
    };

    println!(
        "  {} Context: {} | {} | {} | {}",
        style("✓").green().bold(),
        style(&user_name).green(),
        style(&timezone).green(),
        style(&agent_name).green(),
        style(&communication_style).green().dim()
    );

    Ok(ProjectContext {
        user_name,
        timezone,
        agent_name,
        communication_style,
    })
}

// ── Step 6: Memory Configuration ───────────────────────────────

fn setup_memory() -> Result<MemoryConfig> {
    print_bullet("Choose how KylinBot stores and searches memories.");
    print_bullet("You can always change this later in config.toml.");
    println!();

    let options: Vec<&str> = selectable_memory_backends()
        .iter()
        .map(|backend| backend.label)
        .collect();

    let choice = Select::new()
        .with_prompt("  Select memory backend")
        .items(&options)
        .default(0)
        .interact()?;

    let backend = backend_key_from_choice(choice);
    let profile = memory_backend_profile(backend);

    let auto_save = profile.auto_save_default
        && Confirm::new()
            .with_prompt("  Auto-save conversations to memory?")
            .default(true)
            .interact()?;

    println!(
        "  {} Memory: {} (auto-save: {})",
        style("✓").green().bold(),
        style(backend).green(),
        if auto_save { "on" } else { "off" }
    );

    let mut config = memory_config_defaults_for_backend(backend);
    config.auto_save = auto_save;
    Ok(config)
}

// ── Step 3: Channels ────────────────────────────────────────────

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ChannelMenuChoice {
    Telegram,
    Discord,
    Slack,
    IMessage,
    Matrix,
    Signal,
    WhatsApp,
    Linq,
    Irc,
    Webhook,
    NextcloudTalk,
    DingTalk,
    QqOfficial,
    Lark,
    Feishu,
    #[cfg(feature = "channel-nostr")]
    Nostr,
    Done,
}

const CHANNEL_MENU_CHOICES: &[ChannelMenuChoice] = &[
    ChannelMenuChoice::Telegram,
    ChannelMenuChoice::Discord,
    ChannelMenuChoice::Slack,
    ChannelMenuChoice::IMessage,
    ChannelMenuChoice::Matrix,
    ChannelMenuChoice::Signal,
    ChannelMenuChoice::WhatsApp,
    ChannelMenuChoice::Linq,
    ChannelMenuChoice::Irc,
    ChannelMenuChoice::Webhook,
    ChannelMenuChoice::NextcloudTalk,
    ChannelMenuChoice::DingTalk,
    ChannelMenuChoice::QqOfficial,
    ChannelMenuChoice::Lark,
    ChannelMenuChoice::Feishu,
    #[cfg(feature = "channel-nostr")]
    ChannelMenuChoice::Nostr,
    ChannelMenuChoice::Done,
];

fn channel_menu_choices() -> &'static [ChannelMenuChoice] {
    CHANNEL_MENU_CHOICES
}

#[allow(clippy::too_many_lines)]
fn setup_channels(
    existing: Option<ChannelsConfig>,
    callbacks: &WizardCallbacks,
) -> Result<ChannelsConfig> {
    print_bullet("Channels let you talk to KylinBot from anywhere.");
    print_bullet("CLI is always available. Connect more channels now.");
    println!();

    let mut config = existing.unwrap_or_default();
    let menu_choices = channel_menu_choices();

    loop {
        let options: Vec<String> = menu_choices
            .iter()
            .map(|choice| match choice {
                ChannelMenuChoice::Telegram => format!(
                    "Telegram   {}",
                    if config.telegram.is_some() {
                        "✅ connected"
                    } else {
                        "— connect your bot"
                    }
                ),
                ChannelMenuChoice::Discord => format!(
                    "Discord    {}",
                    if config.discord.is_some() {
                        "✅ connected"
                    } else {
                        "— connect your bot"
                    }
                ),
                ChannelMenuChoice::Slack => format!(
                    "Slack      {}",
                    if config.slack.is_some() {
                        "✅ connected"
                    } else {
                        "— connect your bot"
                    }
                ),
                ChannelMenuChoice::IMessage => format!(
                    "iMessage   {}",
                    if config.imessage.is_some() {
                        "✅ configured"
                    } else {
                        "— macOS only"
                    }
                ),
                ChannelMenuChoice::Matrix => format!(
                    "Matrix     {}",
                    if config.matrix.is_some() {
                        "✅ connected"
                    } else {
                        "— self-hosted chat"
                    }
                ),
                ChannelMenuChoice::Signal => format!(
                    "Signal     {}",
                    if config.signal.is_some() {
                        "✅ connected"
                    } else {
                        "— signal-cli daemon bridge"
                    }
                ),
                ChannelMenuChoice::WhatsApp => format!(
                    "WhatsApp   {}",
                    if config.whatsapp.is_some() {
                        "✅ connected"
                    } else {
                        "— Business Cloud API"
                    }
                ),
                ChannelMenuChoice::Linq => format!(
                    "Linq       {}",
                    if config.linq.is_some() {
                        "✅ connected"
                    } else {
                        "— iMessage/RCS/SMS via Linq API"
                    }
                ),
                ChannelMenuChoice::Irc => format!(
                    "IRC        {}",
                    if config.irc.is_some() {
                        "✅ configured"
                    } else {
                        "— IRC over TLS"
                    }
                ),
                ChannelMenuChoice::Webhook => format!(
                    "Webhook    {}",
                    if config.webhook.is_some() {
                        "✅ configured"
                    } else {
                        "— HTTP endpoint"
                    }
                ),
                ChannelMenuChoice::NextcloudTalk => format!(
                    "Nextcloud  {}",
                    if config.nextcloud_talk.is_some() {
                        "✅ connected"
                    } else {
                        "— Talk webhook + OCS API"
                    }
                ),
                ChannelMenuChoice::DingTalk => format!(
                    "DingTalk   {}",
                    if config.dingtalk.is_some() {
                        "✅ connected"
                    } else {
                        "— DingTalk Stream Mode"
                    }
                ),
                ChannelMenuChoice::QqOfficial => format!(
                    "QQ Official {}",
                    if config.qq.is_some() {
                        "✅ connected"
                    } else {
                        "— Tencent QQ Bot"
                    }
                ),
                ChannelMenuChoice::Lark => format!(
                    "Lark       {}",
                    if config.lark.as_ref().is_some_and(|cfg| !cfg.use_feishu) {
                        "✅ connected"
                    } else {
                        "— Lark Bot"
                    }
                ),
                ChannelMenuChoice::Feishu => format!(
                    "Feishu     {}",
                    if config.feishu.is_some()
                        || config.lark.as_ref().is_some_and(|cfg| cfg.use_feishu)
                    {
                        "✅ connected"
                    } else {
                        "— Feishu Bot"
                    }
                ),
                #[cfg(feature = "channel-nostr")]
                ChannelMenuChoice::Nostr => format!(
                    "Nostr {}",
                    if config.nostr.is_some() {
                        "✅ connected"
                    } else {
                        "     — Nostr DMs"
                    }
                ),
                ChannelMenuChoice::Done => "Done — finish setup".to_string(),
            })
            .collect();

        let selection = Select::new()
            .with_prompt("  Connect a channel (or Done to continue)")
            .items(&options)
            .default(options.len() - 1)
            .interact()?;

        let choice = menu_choices
            .get(selection)
            .copied()
            .unwrap_or(ChannelMenuChoice::Done);

        match choice {
            ChannelMenuChoice::Telegram => {
                // ── Telegram ──
                println!();
                println!(
                    "  {} {}",
                    style("Telegram Setup").white().bold(),
                    style("— talk to KylinBot from Telegram").dim()
                );
                print_bullet("1. Open Telegram and message @BotFather");
                print_bullet("2. Send /newbot and follow the prompts");
                print_bullet("3. Copy the bot token and paste it below");
                println!();

                let has_existing_tg = config.telegram.is_some();
                let token_prompt_tg = if has_existing_tg {
                    "  Bot token (Enter to keep existing)"
                } else {
                    "  Bot token (from @BotFather)"
                };
                let token_input: String = Input::new()
                    .with_prompt(token_prompt_tg)
                    .allow_empty(has_existing_tg)
                    .interact_text()?;
                let token = if token_input.trim().is_empty() && has_existing_tg {
                    config.telegram.as_ref().unwrap().bot_token.clone()
                } else {
                    token_input
                };

                if token.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                // Test connection (run entirely in separate thread — reqwest::blocking Response
                // must be used and dropped there to avoid "Cannot drop a runtime" panic)
                print!("  {} Testing connection... ", style("⏳").dim());
                let token_clone = token.clone();
                let thread_result = std::thread::spawn(move || {
                    let client = reqwest::blocking::Client::new();
                    let url = format!("https://api.telegram.org/bot{token_clone}/getMe");
                    let resp = client.get(&url).send()?;
                    let ok = resp.status().is_success();
                    let data: serde_json::Value = resp.json().unwrap_or_default();
                    let bot_name = data
                        .get("result")
                        .and_then(|r| r.get("username"))
                        .and_then(serde_json::Value::as_str)
                        .unwrap_or("unknown")
                        .to_string();
                    Ok::<_, reqwest::Error>((ok, bot_name))
                })
                .join();
                match thread_result {
                    Ok(Ok((true, bot_name))) => {
                        println!(
                            "\r  {} Connected as @{bot_name}        ",
                            style("✅").green().bold()
                        );
                    }
                    _ => {
                        println!(
                            "\r  {} Connection failed — check your token and try again",
                            style("❌").red().bold()
                        );
                        continue;
                    }
                }

                print_bullet(
                    "Allowlist your own Telegram identity first (recommended for secure + fast setup).",
                );
                print_bullet(
                    "Use your @username without '@' (example: argenis), or your numeric Telegram user ID.",
                );
                print_bullet("Use '*' only for temporary open testing.");

                let tg_users_default = config
                    .telegram
                    .as_ref()
                    .map(|tg| tg.allowed_users.join(", "))
                    .unwrap_or_default();
                let users_str: String = Input::new()
                    .with_prompt(
                        "  Allowed Telegram identities (comma-separated: username without '@' and/or numeric user ID, '*' for all)",
                    )
                    .default(tg_users_default)
                    .allow_empty(true)
                    .interact_text()?;

                let allowed_users = if users_str.trim() == "*" {
                    vec!["*".into()]
                } else {
                    users_str
                        .split(',')
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty())
                        .collect()
                };

                if allowed_users.is_empty() {
                    println!(
                        "  {} No users allowlisted — Telegram inbound messages will be denied until you add your username/user ID or '*'.",
                        style("⚠").yellow().bold()
                    );
                }

                let existing_tg = config.telegram.as_ref();
                config.telegram = Some(TelegramConfig {
                    enabled: true,
                    bot_token: token,
                    allowed_users,
                    stream_mode: existing_tg.map(|t| t.stream_mode).unwrap_or_default(),
                    draft_update_interval_ms: existing_tg
                        .map(|t| t.draft_update_interval_ms)
                        .unwrap_or(1000),
                    interrupt_on_new_message: existing_tg
                        .map(|t| t.interrupt_on_new_message)
                        .unwrap_or(false),
                    mention_only: existing_tg.map(|t| t.mention_only).unwrap_or(false),
                    ack_reactions: existing_tg.and_then(|t| t.ack_reactions),
                    proxy_url: existing_tg.and_then(|t| t.proxy_url.clone()),
                    approval_timeout_secs: existing_tg
                        .map(|t| t.approval_timeout_secs)
                        .unwrap_or(120),
                });
            }
            ChannelMenuChoice::Discord => {
                // ── Discord ──
                println!();
                println!(
                    "  {} {}",
                    style("Discord Setup").white().bold(),
                    style("— talk to KylinBot from Discord").dim()
                );
                print_bullet("1. Go to https://discord.com/developers/applications");
                print_bullet("2. Create a New Application → Bot → Copy token");
                print_bullet("3. Enable MESSAGE CONTENT intent under Bot settings");
                print_bullet("4. Invite bot to your server with messages permission");
                println!();

                let has_existing_dc = config.discord.is_some();
                let dc_token_prompt = if has_existing_dc {
                    "  Bot token (Enter to keep existing)"
                } else {
                    "  Bot token"
                };
                let token_input: String = Input::new()
                    .with_prompt(dc_token_prompt)
                    .allow_empty(has_existing_dc)
                    .interact_text()?;
                let token = if token_input.trim().is_empty() && has_existing_dc {
                    config.discord.as_ref().unwrap().bot_token.clone()
                } else {
                    token_input
                };

                if token.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                // Test connection (run entirely in separate thread — Response must be used/dropped there)
                print!("  {} Testing connection... ", style("⏳").dim());
                let token_clone = token.clone();
                let thread_result = std::thread::spawn(move || {
                    let client = reqwest::blocking::Client::new();
                    let resp = client
                        .get("https://discord.com/api/v10/users/@me")
                        .header("Authorization", format!("Bot {token_clone}"))
                        .send()?;
                    let ok = resp.status().is_success();
                    let data: serde_json::Value = resp.json().unwrap_or_default();
                    let bot_name = data
                        .get("username")
                        .and_then(serde_json::Value::as_str)
                        .unwrap_or("unknown")
                        .to_string();
                    Ok::<_, reqwest::Error>((ok, bot_name))
                })
                .join();
                match thread_result {
                    Ok(Ok((true, bot_name))) => {
                        println!(
                            "\r  {} Connected as {bot_name}        ",
                            style("✅").green().bold()
                        );
                    }
                    _ => {
                        println!(
                            "\r  {} Connection failed — check your token and try again",
                            style("❌").red().bold()
                        );
                        continue;
                    }
                }

                let guild_default = config
                    .discord
                    .as_ref()
                    .and_then(|dc| dc.guild_id.clone())
                    .unwrap_or_default();
                let guild: String = Input::new()
                    .with_prompt("  Server (guild) ID (optional, Enter to skip)")
                    .default(guild_default)
                    .allow_empty(true)
                    .interact_text()?;

                print_bullet("Allowlist your own Discord user ID first (recommended).");
                print_bullet(
                    "Get it in Discord: Settings -> Advanced -> Developer Mode (ON), then right-click your profile -> Copy User ID.",
                );
                print_bullet("Use '*' only for temporary open testing.");

                let dc_users_default = config
                    .discord
                    .as_ref()
                    .map(|dc| dc.allowed_users.join(", "))
                    .unwrap_or_default();
                let allowed_users_str: String = Input::new()
                    .with_prompt(
                        "  Allowed Discord user IDs (comma-separated, recommended: your own ID, '*' for all)",
                    )
                    .default(dc_users_default)
                    .allow_empty(true)
                    .interact_text()?;

                let allowed_users = if allowed_users_str.trim().is_empty() {
                    vec![]
                } else {
                    allowed_users_str
                        .split(',')
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty())
                        .collect()
                };

                if allowed_users.is_empty() {
                    println!(
                        "  {} No users allowlisted — Discord inbound messages will be denied until you add IDs or '*'.",
                        style("⚠").yellow().bold()
                    );
                }

                let existing_dc = config.discord.as_ref();
                config.discord = Some(DiscordConfig {
                    enabled: true,
                    bot_token: token,
                    guild_id: if guild.is_empty() { None } else { Some(guild) },
                    allowed_users,
                    listen_to_bots: existing_dc.map(|d| d.listen_to_bots).unwrap_or(false),
                    interrupt_on_new_message: existing_dc
                        .map(|d| d.interrupt_on_new_message)
                        .unwrap_or(false),
                    mention_only: existing_dc.map(|d| d.mention_only).unwrap_or(false),
                    proxy_url: existing_dc.and_then(|d| d.proxy_url.clone()),
                    stream_mode: existing_dc
                        .map(|d| d.stream_mode)
                        .unwrap_or(StreamMode::MultiMessage),
                    draft_update_interval_ms: existing_dc
                        .map(|d| d.draft_update_interval_ms)
                        .unwrap_or(1000),
                    multi_message_delay_ms: existing_dc
                        .map(|d| d.multi_message_delay_ms)
                        .unwrap_or(800),
                    stall_timeout_secs: existing_dc.map(|d| d.stall_timeout_secs).unwrap_or(0),
                    approval_timeout_secs: existing_dc
                        .map(|d| d.approval_timeout_secs)
                        .unwrap_or(300),
                });
            }
            ChannelMenuChoice::Slack => {
                // ── Slack ──
                println!();
                println!(
                    "  {} {}",
                    style("Slack Setup").white().bold(),
                    style("— talk to KylinBot from Slack").dim()
                );
                print_bullet("1. Go to https://api.slack.com/apps → Create New App");
                print_bullet("2. Add Bot Token Scopes: chat:write, channels:history");
                print_bullet("3. Install to workspace and copy the Bot Token");
                println!();

                let has_existing_sl = config.slack.is_some();
                let sl_token_prompt = if has_existing_sl {
                    "  Bot token (Enter to keep existing)"
                } else {
                    "  Bot token (xoxb-...)"
                };
                let token_input: String = Input::new()
                    .with_prompt(sl_token_prompt)
                    .allow_empty(has_existing_sl)
                    .interact_text()?;
                let token = if token_input.trim().is_empty() && has_existing_sl {
                    config.slack.as_ref().unwrap().bot_token.clone()
                } else {
                    token_input
                };

                if token.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                // Test connection (run entirely in separate thread — Response must be used/dropped there)
                print!("  {} Testing connection... ", style("⏳").dim());
                let token_clone = token.clone();
                let thread_result = std::thread::spawn(move || {
                    let client = reqwest::blocking::Client::new();
                    let resp = client
                        .get("https://slack.com/api/auth.test")
                        .bearer_auth(&token_clone)
                        .send()?;
                    let ok = resp.status().is_success();
                    let data: serde_json::Value = resp.json().unwrap_or_default();
                    let api_ok = data
                        .get("ok")
                        .and_then(serde_json::Value::as_bool)
                        .unwrap_or(false);
                    let team = data
                        .get("team")
                        .and_then(serde_json::Value::as_str)
                        .unwrap_or("unknown")
                        .to_string();
                    let err = data
                        .get("error")
                        .and_then(serde_json::Value::as_str)
                        .unwrap_or("unknown error")
                        .to_string();
                    Ok::<_, reqwest::Error>((ok, api_ok, team, err))
                })
                .join();
                match thread_result {
                    Ok(Ok((true, true, team, _))) => {
                        println!(
                            "\r  {} Connected to workspace: {team}        ",
                            style("✅").green().bold()
                        );
                    }
                    Ok(Ok((true, false, _, err))) => {
                        println!("\r  {} Slack error: {err}", style("❌").red().bold());
                        continue;
                    }
                    _ => {
                        println!(
                            "\r  {} Connection failed — check your token",
                            style("❌").red().bold()
                        );
                        continue;
                    }
                }

                let sl_app_default = config
                    .slack
                    .as_ref()
                    .and_then(|sl| sl.app_token.clone())
                    .unwrap_or_default();
                let app_token: String = Input::new()
                    .with_prompt("  App token (xapp-..., optional, Enter to skip)")
                    .default(sl_app_default)
                    .allow_empty(true)
                    .interact_text()?;

                let sl_channel_default = config
                    .slack
                    .as_ref()
                    .and_then(|sl| sl.channel_ids.first().cloned())
                    .unwrap_or_default();
                let channel: String = Input::new()
                    .with_prompt(
                        "  Default channel ID (optional, Enter to skip for all accessible channels; '*' also means all)",
                    )
                    .default(sl_channel_default)
                    .allow_empty(true)
                    .interact_text()?;

                print_bullet("Allowlist your own Slack member ID first (recommended).");
                print_bullet(
                    "Member IDs usually start with 'U' (open your Slack profile -> More -> Copy member ID).",
                );
                print_bullet("Use '*' only for temporary open testing.");

                let sl_users_default = config
                    .slack
                    .as_ref()
                    .map(|sl| sl.allowed_users.join(", "))
                    .unwrap_or_default();
                let allowed_users_str: String = Input::new()
                    .with_prompt(
                        "  Allowed Slack user IDs (comma-separated, recommended: your own member ID, '*' for all)",
                    )
                    .default(sl_users_default)
                    .allow_empty(true)
                    .interact_text()?;

                let allowed_users = if allowed_users_str.trim().is_empty() {
                    vec![]
                } else {
                    allowed_users_str
                        .split(',')
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty())
                        .collect()
                };

                if allowed_users.is_empty() {
                    println!(
                        "  {} No users allowlisted — Slack inbound messages will be denied until you add IDs or '*'.",
                        style("⚠").yellow().bold()
                    );
                }

                let existing_sl = config.slack.as_ref();
                config.slack = Some(SlackConfig {
                    enabled: true,
                    bot_token: token,
                    app_token: if app_token.is_empty() {
                        None
                    } else {
                        Some(app_token)
                    },
                    channel_ids: if channel.is_empty() {
                        existing_sl
                            .map(|s| s.channel_ids.clone())
                            .unwrap_or_default()
                    } else {
                        let mut ids = existing_sl
                            .map(|s| s.channel_ids.clone())
                            .unwrap_or_default();
                        if !ids.contains(&channel) {
                            ids.insert(0, channel);
                        }
                        ids
                    },
                    allowed_users,
                    interrupt_on_new_message: existing_sl
                        .map(|s| s.interrupt_on_new_message)
                        .unwrap_or(false),
                    thread_replies: existing_sl.and_then(|s| s.thread_replies),
                    mention_only: existing_sl.map(|s| s.mention_only).unwrap_or(false),
                    use_markdown_blocks: existing_sl
                        .map(|s| s.use_markdown_blocks)
                        .unwrap_or(false),
                    proxy_url: existing_sl.and_then(|s| s.proxy_url.clone()),
                    stream_drafts: existing_sl.map(|s| s.stream_drafts).unwrap_or(false),
                    draft_update_interval_ms: existing_sl
                        .map(|s| s.draft_update_interval_ms)
                        .unwrap_or(1200),
                    cancel_reaction: existing_sl.and_then(|s| s.cancel_reaction.clone()),
                    approval_timeout_secs: existing_sl
                        .map(|s| s.approval_timeout_secs)
                        .unwrap_or(300),
                });
            }
            ChannelMenuChoice::IMessage => {
                // ── iMessage ──
                println!();
                println!(
                    "  {} {}",
                    style("iMessage Setup").white().bold(),
                    style("— macOS only, reads from Messages.app").dim()
                );

                if !cfg!(target_os = "macos") {
                    println!(
                        "  {} iMessage is only available on macOS.",
                        style("⚠").yellow().bold()
                    );
                    continue;
                }

                print_bullet("KylinBot reads your iMessage database and replies via AppleScript.");
                print_bullet(
                    "You need to grant Full Disk Access to your terminal in System Settings.",
                );
                println!();

                let contacts_str: String = Input::new()
                    .with_prompt("  Allowed contacts (comma-separated phone/email, or * for all)")
                    .default("*")
                    .interact_text()?;

                let allowed_contacts = if contacts_str.trim() == "*" {
                    vec!["*".into()]
                } else {
                    contacts_str
                        .split(',')
                        .map(|s| s.trim().to_string())
                        .collect()
                };

                config.imessage = Some(IMessageConfig {
                    enabled: true,
                    allowed_contacts,
                });
                println!(
                    "  {} iMessage configured (contacts: {})",
                    style("✅").green().bold(),
                    style(&contacts_str).cyan()
                );
            }
            ChannelMenuChoice::Matrix => {
                // ── Matrix ──
                println!();
                println!(
                    "  {} {}",
                    style("Matrix Setup").white().bold(),
                    style("— self-hosted, federated chat").dim()
                );
                print_bullet("You need a Matrix account and an access token.");
                print_bullet("Get a token via Element → Settings → Help & About → Access Token.");
                println!();

                let homeserver: String = if let Some(ref mx) = config.matrix {
                    Input::new()
                        .with_prompt("  Homeserver URL (e.g. https://matrix.org)")
                        .default(mx.homeserver.clone())
                        .interact_text()?
                } else {
                    Input::new()
                        .with_prompt("  Homeserver URL (e.g. https://matrix.org)")
                        .interact_text()?
                };

                if homeserver.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                let has_existing_token = config.matrix.is_some();
                let token_prompt = if has_existing_token {
                    "  Access token (Enter to keep existing)"
                } else {
                    "  Access token"
                };
                let access_token_input: String = dialoguer::Password::new()
                    .with_prompt(token_prompt)
                    .allow_empty_password(has_existing_token)
                    .interact()?;
                let access_token = if access_token_input.is_empty() && has_existing_token {
                    config.matrix.as_ref().unwrap().access_token.clone()
                } else {
                    access_token_input
                };

                // Test connection (run entirely in separate thread — Response must be used/dropped there)
                let hs = homeserver.trim_end_matches('/');
                print!("  {} Testing connection... ", style("⏳").dim());
                let hs_owned = hs.to_string();
                let access_token_clone = access_token.clone();
                let thread_result = std::thread::spawn(move || {
                    let client = reqwest::blocking::Client::new();
                    let resp = client
                        .get(format!("{hs_owned}/_matrix/client/v3/account/whoami"))
                        .header("Authorization", format!("Bearer {access_token_clone}"))
                        .send()?;
                    let ok = resp.status().is_success();

                    if !ok {
                        return Ok::<_, reqwest::Error>((false, None, None));
                    }

                    let payload: Value = match resp.json() {
                        Ok(payload) => payload,
                        Err(_) => Value::Null,
                    };
                    let user_id = payload
                        .get("user_id")
                        .and_then(|value| value.as_str())
                        .map(|value| value.to_string());
                    let device_id = payload
                        .get("device_id")
                        .and_then(|value| value.as_str())
                        .map(|value| value.to_string());

                    Ok::<_, reqwest::Error>((true, user_id, device_id))
                })
                .join();

                let (detected_user_id, detected_device_id) = match thread_result {
                    Ok(Ok((true, user_id, device_id))) => {
                        println!(
                            "\r  {} Connection verified        ",
                            style("✅").green().bold()
                        );

                        if device_id.is_none() {
                            println!(
                                "  {} Homeserver did not return device_id from whoami. If E2EE decryption fails, set channels.matrix.device_id manually in config.toml.",
                                style("⚠️").yellow().bold()
                            );
                        }

                        (user_id, device_id)
                    }
                    _ => {
                        println!(
                            "\r  {} Connection failed — check homeserver URL and token",
                            style("❌").red().bold()
                        );
                        continue;
                    }
                };

                let room_id: String = if let Some(ref mx) = config.matrix {
                    Input::new()
                        .with_prompt("  Room ID (e.g. !abc123:matrix.org)")
                        .default(mx.allowed_rooms.first().cloned().unwrap_or_default())
                        .interact_text()?
                } else {
                    Input::new()
                        .with_prompt("  Room ID (e.g. !abc123:matrix.org)")
                        .interact_text()?
                };

                let users_default = config
                    .matrix
                    .as_ref()
                    .map(|mx| mx.allowed_users.join(", "))
                    .unwrap_or_else(|| "*".into());
                let users_str: String = Input::new()
                    .with_prompt("  Allowed users (comma-separated @user:server, or * for all)")
                    .default(users_default)
                    .interact_text()?;

                let allowed_users = if users_str.trim() == "*" {
                    vec!["*".into()]
                } else {
                    users_str.split(',').map(|s| s.trim().to_string()).collect()
                };

                let has_existing_recovery = config
                    .matrix
                    .as_ref()
                    .is_some_and(|m| m.recovery_key.is_some());
                let recovery_prompt = if has_existing_recovery {
                    "  E2EE recovery key (Enter to keep existing — see docs/security/matrix-e2ee-guide.md section 4G)"
                } else {
                    "  E2EE recovery key (or Enter to skip — see docs/security/matrix-e2ee-guide.md section 4G)"
                };
                let recovery_input: String = dialoguer::Password::new()
                    .with_prompt(recovery_prompt)
                    .allow_empty_password(true)
                    .interact()?;
                let recovery_key = if recovery_input.trim().is_empty() {
                    // Keep existing recovery key if present
                    config.matrix.as_ref().and_then(|m| m.recovery_key.clone())
                } else {
                    Some(recovery_input.trim().to_string())
                };

                let existing_mx = config.matrix.as_ref();
                // Merge the prompted room_id into allowed_rooms
                let mut allowed_rooms = existing_mx
                    .map(|m| m.allowed_rooms.clone())
                    .unwrap_or_default();
                if !room_id.is_empty() && !allowed_rooms.contains(&room_id) {
                    allowed_rooms.insert(0, room_id);
                }

                config.matrix = Some(MatrixConfig {
                    enabled: true,
                    homeserver: homeserver.trim_end_matches('/').to_string(),
                    access_token,
                    user_id: detected_user_id,
                    device_id: detected_device_id,
                    allowed_users,
                    // Preserve non-prompted fields from existing config (#4655)
                    allowed_rooms,
                    interrupt_on_new_message: existing_mx
                        .map(|m| m.interrupt_on_new_message)
                        .unwrap_or(false),
                    stream_mode: existing_mx
                        .map(|m| m.stream_mode)
                        .unwrap_or(StreamMode::Partial),
                    draft_update_interval_ms: existing_mx
                        .map(|m| m.draft_update_interval_ms)
                        .unwrap_or(1500),
                    multi_message_delay_ms: existing_mx
                        .map(|m| m.multi_message_delay_ms)
                        .unwrap_or(800),
                    mention_only: existing_mx.map(|m| m.mention_only).unwrap_or(false),
                    recovery_key,
                    password: existing_mx.and_then(|m| m.password.clone()),
                    approval_timeout_secs: existing_mx
                        .map(|m| m.approval_timeout_secs)
                        .unwrap_or(300),
                });
            }
            ChannelMenuChoice::Signal => {
                // ── Signal ──
                println!();
                println!(
                    "  {} {}",
                    style("Signal Setup").white().bold(),
                    style("— signal-cli daemon bridge").dim()
                );
                print_bullet("1. Run signal-cli daemon with HTTP enabled (default port 8686).");
                print_bullet("2. Ensure your Signal account is registered in signal-cli.");
                print_bullet("3. Optionally scope to DMs only or to a specific group.");
                println!();

                let http_url: String = Input::new()
                    .with_prompt("  signal-cli HTTP URL")
                    .default("http://127.0.0.1:8686")
                    .interact_text()?;

                if http_url.trim().is_empty() {
                    println!("  {} Skipped — HTTP URL required", style("→").dim());
                    continue;
                }

                let account: String = Input::new()
                    .with_prompt("  Account number (E.164, e.g. +1234567890)")
                    .interact_text()?;

                if account.trim().is_empty() {
                    println!("  {} Skipped — account number required", style("→").dim());
                    continue;
                }

                let scope_options = [
                    "All messages (DMs + groups)",
                    "DM only",
                    "Specific group ID",
                ];
                let scope_choice = Select::new()
                    .with_prompt("  Message scope")
                    .items(scope_options)
                    .default(0)
                    .interact()?;

                let group_id = match scope_choice {
                    1 => Some("dm".to_string()),
                    2 => {
                        let group_input: String =
                            Input::new().with_prompt("  Group ID").interact_text()?;
                        let group_input = group_input.trim().to_string();
                        if group_input.is_empty() {
                            println!("  {} Skipped — group ID required", style("→").dim());
                            continue;
                        }
                        Some(group_input)
                    }
                    _ => None,
                };

                let allowed_from_raw: String = Input::new()
                    .with_prompt(
                        "  Allowed sender numbers (comma-separated +1234567890, or * for all)",
                    )
                    .default("*")
                    .interact_text()?;

                let allowed_from = if allowed_from_raw.trim() == "*" {
                    vec!["*".into()]
                } else {
                    allowed_from_raw
                        .split(',')
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty())
                        .collect()
                };

                let ignore_attachments = Confirm::new()
                    .with_prompt("  Ignore attachment-only messages?")
                    .default(false)
                    .interact()?;

                let ignore_stories = Confirm::new()
                    .with_prompt("  Ignore incoming stories?")
                    .default(true)
                    .interact()?;

                config.signal = Some(SignalConfig {
                    enabled: true,
                    http_url: http_url.trim_end_matches('/').to_string(),
                    account: account.trim().to_string(),
                    group_id,
                    allowed_from,
                    ignore_attachments,
                    ignore_stories,
                    proxy_url: config.signal.as_ref().and_then(|s| s.proxy_url.clone()),
                    approval_timeout_secs: config
                        .signal
                        .as_ref()
                        .map(|s| s.approval_timeout_secs)
                        .unwrap_or(300),
                });

                println!("  {} Signal configured", style("✅").green().bold());
            }
            ChannelMenuChoice::WhatsApp => {
                // ── WhatsApp ──
                println!();
                println!("  {}", style("WhatsApp Setup").white().bold());

                let mode_options = vec![
                    "WhatsApp Web (QR / pair-code, no Meta Business API)",
                    "WhatsApp Business Cloud API (webhook)",
                ];
                let mode_idx = Select::new()
                    .with_prompt("  Choose WhatsApp mode")
                    .items(&mode_options)
                    .default(0)
                    .interact()?;

                if mode_idx == 0 {
                    if !callbacks.whatsapp_web_available {
                        println!();
                        println!(
                            "  {} {}",
                            style("⚠").yellow().bold(),
                            style("The 'whatsapp-web' feature is not compiled in. WhatsApp Web will not work at runtime.").yellow()
                        );
                        println!(
                            "  {} Rebuild with: {}",
                            style("→").dim(),
                            style("cargo build --features whatsapp-web").white().bold()
                        );
                        println!();
                    }

                    println!("  {}", style("Mode: WhatsApp Web").dim());
                    print_bullet("1. Build with --features whatsapp-web");
                    print_bullet(
                        "2. Start channel/daemon and scan QR in WhatsApp > Linked Devices",
                    );
                    print_bullet("3. Keep session_path persistent so relogin is not required");
                    println!();

                    let session_path: String = Input::new()
                        .with_prompt("  Session database path")
                        .default("~/.zeroclaw/state/whatsapp-web/session.db")
                        .interact_text()?;

                    if session_path.trim().is_empty() {
                        println!("  {} Skipped — session path required", style("→").dim());
                        continue;
                    }

                    let pair_phone: String = Input::new()
                        .with_prompt(
                            "  Pair phone (optional, digits only; leave empty to use QR flow)",
                        )
                        .allow_empty(true)
                        .interact_text()?;

                    let pair_code: String = if pair_phone.trim().is_empty() {
                        String::new()
                    } else {
                        Input::new()
                            .with_prompt(
                                "  Custom pair code (optional, leave empty for auto-generated)",
                            )
                            .allow_empty(true)
                            .interact_text()?
                    };

                    let users_str: String = Input::new()
                        .with_prompt(
                            "  Allowed phone numbers (comma-separated +1234567890, or * for all)",
                        )
                        .default("*")
                        .interact_text()?;

                    let allowed_numbers = if users_str.trim() == "*" {
                        vec!["*".into()]
                    } else {
                        users_str.split(',').map(|s| s.trim().to_string()).collect()
                    };

                    let existing_wa = config.whatsapp.as_ref();
                    config.whatsapp = Some(WhatsAppConfig {
                        enabled: true,
                        access_token: None,
                        phone_number_id: None,
                        verify_token: None,
                        app_secret: None,
                        session_path: Some(session_path.trim().to_string()),
                        pair_phone: (!pair_phone.trim().is_empty())
                            .then(|| pair_phone.trim().to_string()),
                        pair_code: (!pair_code.trim().is_empty())
                            .then(|| pair_code.trim().to_string()),
                        allowed_numbers,
                        mention_only: existing_wa.map(|w| w.mention_only).unwrap_or(false),
                        mode: existing_wa.map(|w| w.mode.clone()).unwrap_or_default(),
                        dm_policy: existing_wa.map(|w| w.dm_policy.clone()).unwrap_or_default(),
                        group_policy: existing_wa
                            .map(|w| w.group_policy.clone())
                            .unwrap_or_default(),
                        self_chat_mode: existing_wa.map(|w| w.self_chat_mode).unwrap_or(false),
                        dm_mention_patterns: existing_wa
                            .map(|w| w.dm_mention_patterns.clone())
                            .unwrap_or_default(),
                        group_mention_patterns: existing_wa
                            .map(|w| w.group_mention_patterns.clone())
                            .unwrap_or_default(),
                        proxy_url: existing_wa.and_then(|w| w.proxy_url.clone()),
                        approval_timeout_secs: existing_wa
                            .map(|w| w.approval_timeout_secs)
                            .unwrap_or(300),
                    });

                    println!(
                        "  {} WhatsApp Web configuration saved.",
                        style("✅").green().bold()
                    );
                    continue;
                }

                println!(
                    "  {} {}",
                    style("Mode:").dim(),
                    style("Business Cloud API").dim()
                );
                print_bullet("1. Go to developers.facebook.com and create a WhatsApp app");
                print_bullet("2. Add the WhatsApp product and get your phone number ID");
                print_bullet("3. Generate a temporary access token (System User)");
                print_bullet("4. Configure webhook URL to: https://your-domain/whatsapp");
                println!();

                let access_token: String = Input::new()
                    .with_prompt("  Access token (from Meta Developers)")
                    .interact_text()?;

                if access_token.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                let phone_number_id: String = Input::new()
                    .with_prompt("  Phone number ID (from WhatsApp app settings)")
                    .interact_text()?;

                if phone_number_id.trim().is_empty() {
                    println!("  {} Skipped — phone number ID required", style("→").dim());
                    continue;
                }

                let verify_token: String = Input::new()
                    .with_prompt("  Webhook verify token (create your own)")
                    .default("zeroclaw-whatsapp-verify")
                    .interact_text()?;

                // Test connection (run entirely in separate thread — Response must be used/dropped there)
                print!("  {} Testing connection... ", style("⏳").dim());
                let phone_number_id_clone = phone_number_id.clone();
                let access_token_clone = access_token.clone();
                let thread_result = std::thread::spawn(move || {
                    let client = reqwest::blocking::Client::new();
                    let url = format!(
                        "https://graph.facebook.com/v18.0/{}",
                        phone_number_id_clone.trim()
                    );
                    let resp = client
                        .get(&url)
                        .header(
                            "Authorization",
                            format!("Bearer {}", access_token_clone.trim()),
                        )
                        .send()?;
                    Ok::<_, reqwest::Error>(resp.status().is_success())
                })
                .join();
                match thread_result {
                    Ok(Ok(true)) => {
                        println!(
                            "\r  {} Connected to WhatsApp API        ",
                            style("✅").green().bold()
                        );
                    }
                    _ => {
                        println!(
                            "\r  {} Connection failed — check access token and phone number ID",
                            style("❌").red().bold()
                        );
                        continue;
                    }
                }

                let users_str: String = Input::new()
                    .with_prompt(
                        "  Allowed phone numbers (comma-separated +1234567890, or * for all)",
                    )
                    .default("*")
                    .interact_text()?;

                let allowed_numbers = if users_str.trim() == "*" {
                    vec!["*".into()]
                } else {
                    users_str.split(',').map(|s| s.trim().to_string()).collect()
                };

                let existing_wa = config.whatsapp.as_ref();
                config.whatsapp = Some(WhatsAppConfig {
                    enabled: true,
                    access_token: Some(access_token.trim().to_string()),
                    phone_number_id: Some(phone_number_id.trim().to_string()),
                    verify_token: Some(verify_token.trim().to_string()),
                    app_secret: existing_wa.and_then(|w| w.app_secret.clone()),
                    session_path: None,
                    pair_phone: None,
                    pair_code: None,
                    allowed_numbers,
                    mention_only: existing_wa.map(|w| w.mention_only).unwrap_or(false),
                    mode: existing_wa.map(|w| w.mode.clone()).unwrap_or_default(),
                    dm_policy: existing_wa.map(|w| w.dm_policy.clone()).unwrap_or_default(),
                    group_policy: existing_wa
                        .map(|w| w.group_policy.clone())
                        .unwrap_or_default(),
                    self_chat_mode: existing_wa.map(|w| w.self_chat_mode).unwrap_or(false),
                    dm_mention_patterns: existing_wa
                        .map(|w| w.dm_mention_patterns.clone())
                        .unwrap_or_default(),
                    group_mention_patterns: existing_wa
                        .map(|w| w.group_mention_patterns.clone())
                        .unwrap_or_default(),
                    proxy_url: existing_wa.and_then(|w| w.proxy_url.clone()),
                    approval_timeout_secs: existing_wa
                        .map(|w| w.approval_timeout_secs)
                        .unwrap_or(300),
                });
            }
            ChannelMenuChoice::Linq => {
                // ── Linq ──
                println!();
                println!(
                    "  {} {}",
                    style("Linq Setup").white().bold(),
                    style("— iMessage/RCS/SMS via Linq API").dim()
                );
                print_bullet("1. Sign up at linqapp.com and get your Partner API token");
                print_bullet("2. Note your Linq phone number (E.164 format)");
                print_bullet("3. Configure webhook URL to: https://your-domain/linq");
                println!();

                let api_token: String = Input::new()
                    .with_prompt("  API token (Linq Partner API token)")
                    .interact_text()?;

                if api_token.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                let from_phone: String = Input::new()
                    .with_prompt("  From phone number (E.164 format, e.g. +12223334444)")
                    .interact_text()?;

                if from_phone.trim().is_empty() {
                    println!("  {} Skipped — phone number required", style("→").dim());
                    continue;
                }

                // Test connection
                print!("  {} Testing connection... ", style("⏳").dim());
                let api_token_clone = api_token.clone();
                let thread_result = std::thread::spawn(move || {
                    let client = reqwest::blocking::Client::new();
                    let url = "https://api.linqapp.com/api/partner/v3/phonenumbers";
                    let resp = client
                        .get(url)
                        .header(
                            "Authorization",
                            format!("Bearer {}", api_token_clone.trim()),
                        )
                        .send()?;
                    Ok::<_, reqwest::Error>(resp.status().is_success())
                })
                .join();
                match thread_result {
                    Ok(Ok(true)) => {
                        println!(
                            "\r  {} Connected to Linq API              ",
                            style("✅").green().bold()
                        );
                    }
                    _ => {
                        println!(
                            "\r  {} Connection failed — check API token",
                            style("❌").red().bold()
                        );
                        continue;
                    }
                }

                let users_str: String = Input::new()
                    .with_prompt(
                        "  Allowed sender numbers (comma-separated +1234567890, or * for all)",
                    )
                    .default("*")
                    .interact_text()?;

                let allowed_senders = if users_str.trim() == "*" {
                    vec!["*".into()]
                } else {
                    users_str.split(',').map(|s| s.trim().to_string()).collect()
                };

                let signing_secret: String = Input::new()
                    .with_prompt("  Webhook signing secret (optional, press Enter to skip)")
                    .allow_empty(true)
                    .interact_text()?;

                config.linq = Some(LinqConfig {
                    enabled: true,
                    api_token: api_token.trim().to_string(),
                    from_phone: from_phone.trim().to_string(),
                    signing_secret: if signing_secret.trim().is_empty() {
                        None
                    } else {
                        Some(signing_secret.trim().to_string())
                    },
                    allowed_senders,
                });
            }
            ChannelMenuChoice::Irc => {
                // ── IRC ──
                println!();
                println!(
                    "  {} {}",
                    style("IRC Setup").white().bold(),
                    style("— IRC over TLS").dim()
                );
                print_bullet("IRC connects over TLS to any IRC server");
                print_bullet("Supports SASL PLAIN and NickServ authentication");
                println!();

                let server: String = Input::new()
                    .with_prompt("  IRC server (hostname)")
                    .interact_text()?;

                if server.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                let port_str: String = Input::new()
                    .with_prompt("  Port")
                    .default("6697")
                    .interact_text()?;

                let port: u16 = match port_str.trim().parse() {
                    Ok(p) => p,
                    Err(_) => {
                        println!("  {} Invalid port, using 6697", style("→").dim());
                        6697
                    }
                };

                let nickname: String =
                    Input::new().with_prompt("  Bot nickname").interact_text()?;

                if nickname.trim().is_empty() {
                    println!("  {} Skipped — nickname required", style("→").dim());
                    continue;
                }

                let channels_str: String = Input::new()
                    .with_prompt("  Channels to join (comma-separated: #channel1,#channel2)")
                    .allow_empty(true)
                    .interact_text()?;

                let channels = if channels_str.trim().is_empty() {
                    vec![]
                } else {
                    channels_str
                        .split(',')
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty())
                        .collect()
                };

                print_bullet(
                    "Allowlist nicknames that can interact with the bot (case-insensitive).",
                );
                print_bullet("Use '*' to allow anyone (not recommended for production).");

                let users_str: String = Input::new()
                    .with_prompt("  Allowed nicknames (comma-separated, or * for all)")
                    .allow_empty(true)
                    .interact_text()?;

                let allowed_users = if users_str.trim() == "*" {
                    vec!["*".into()]
                } else {
                    users_str
                        .split(',')
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty())
                        .collect()
                };

                if allowed_users.is_empty() {
                    print_bullet(
                        "⚠️  Empty allowlist — only you can interact. Add nicknames above.",
                    );
                }

                println!();
                print_bullet("Optional authentication (press Enter to skip each):");

                let server_password: String = Input::new()
                    .with_prompt("  Server password (for bouncers like ZNC, leave empty if none)")
                    .allow_empty(true)
                    .interact_text()?;

                let nickserv_password: String = Input::new()
                    .with_prompt("  NickServ password (leave empty if none)")
                    .allow_empty(true)
                    .interact_text()?;

                let sasl_password: String = Input::new()
                    .with_prompt("  SASL PLAIN password (leave empty if none)")
                    .allow_empty(true)
                    .interact_text()?;

                let verify_tls: bool = Confirm::new()
                    .with_prompt("  Verify TLS certificate?")
                    .default(true)
                    .interact()?;

                println!(
                    "  {} IRC configured as {}@{}:{}",
                    style("✅").green().bold(),
                    style(&nickname).cyan(),
                    style(&server).cyan(),
                    style(port).cyan()
                );

                config.irc = Some(IrcConfig {
                    enabled: true,
                    server: server.trim().to_string(),
                    port,
                    nickname: nickname.trim().to_string(),
                    username: config.irc.as_ref().and_then(|i| i.username.clone()),
                    channels,
                    allowed_users,
                    server_password: if server_password.trim().is_empty() {
                        None
                    } else {
                        Some(server_password.trim().to_string())
                    },
                    nickserv_password: if nickserv_password.trim().is_empty() {
                        None
                    } else {
                        Some(nickserv_password.trim().to_string())
                    },
                    sasl_password: if sasl_password.trim().is_empty() {
                        None
                    } else {
                        Some(sasl_password.trim().to_string())
                    },
                    verify_tls: Some(verify_tls),
                });
            }
            ChannelMenuChoice::Webhook => {
                // ── Webhook ──
                println!();
                println!(
                    "  {} {}",
                    style("Webhook Setup").white().bold(),
                    style("— HTTP endpoint for custom integrations").dim()
                );

                let port: String = Input::new()
                    .with_prompt("  Port")
                    .default("8080")
                    .interact_text()?;

                let secret: String = Input::new()
                    .with_prompt("  Secret (optional, Enter to skip)")
                    .allow_empty(true)
                    .interact_text()?;

                let existing_wh = config.webhook.as_ref();
                config.webhook = Some(WebhookConfig {
                    enabled: true,
                    port: port.parse().unwrap_or(8080),
                    listen_path: existing_wh.and_then(|w| w.listen_path.clone()),
                    send_url: existing_wh.and_then(|w| w.send_url.clone()),
                    send_method: existing_wh.and_then(|w| w.send_method.clone()),
                    auth_header: existing_wh.and_then(|w| w.auth_header.clone()),
                    secret: if secret.is_empty() {
                        existing_wh.and_then(|w| w.secret.clone())
                    } else {
                        Some(secret)
                    },
                });
                println!(
                    "  {} Webhook on port {}",
                    style("✅").green().bold(),
                    style(&port).cyan()
                );
            }
            ChannelMenuChoice::NextcloudTalk => {
                // ── Nextcloud Talk ──
                println!();
                println!(
                    "  {} {}",
                    style("Nextcloud Talk Setup").white().bold(),
                    style("— Talk webhook receive + OCS API send").dim()
                );
                print_bullet("1. Configure your Nextcloud Talk bot app and app token.");
                print_bullet("2. Set webhook URL to: https://<your-public-url>/nextcloud-talk");
                print_bullet(
                    "3. Keep webhook_secret aligned with Nextcloud signature headers if enabled.",
                );
                println!();

                let base_url: String = Input::new()
                    .with_prompt("  Nextcloud base URL (e.g. https://cloud.example.com)")
                    .interact_text()?;

                let base_url = base_url.trim().trim_end_matches('/').to_string();
                if base_url.is_empty() {
                    println!("  {} Skipped — base URL required", style("→").dim());
                    continue;
                }

                let app_token: String = Input::new()
                    .with_prompt("  App token (Talk bot token)")
                    .interact_text()?;

                if app_token.trim().is_empty() {
                    println!("  {} Skipped — app token required", style("→").dim());
                    continue;
                }

                let webhook_secret: String = Input::new()
                    .with_prompt("  Webhook secret (optional, Enter to skip)")
                    .allow_empty(true)
                    .interact_text()?;

                let allowed_users_raw: String = Input::new()
                    .with_prompt("  Allowed Nextcloud actor IDs (comma-separated, or * for all)")
                    .default("*")
                    .interact_text()?;

                let allowed_users = if allowed_users_raw.trim() == "*" {
                    vec!["*".into()]
                } else {
                    allowed_users_raw
                        .split(',')
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty())
                        .collect()
                };

                let existing_nc = config.nextcloud_talk.as_ref();
                config.nextcloud_talk = Some(NextcloudTalkConfig {
                    enabled: true,
                    base_url,
                    app_token: app_token.trim().to_string(),
                    webhook_secret: if webhook_secret.trim().is_empty() {
                        existing_nc.and_then(|n| n.webhook_secret.clone())
                    } else {
                        Some(webhook_secret.trim().to_string())
                    },
                    allowed_users,
                    proxy_url: existing_nc.and_then(|n| n.proxy_url.clone()),
                    bot_name: existing_nc.and_then(|n| n.bot_name.clone()),
                });

                println!("  {} Nextcloud Talk configured", style("✅").green().bold());
            }
            ChannelMenuChoice::DingTalk => {
                // ── DingTalk ──
                println!();
                println!(
                    "  {} {}",
                    style("DingTalk Setup").white().bold(),
                    style("— DingTalk Stream Mode").dim()
                );
                print_bullet("1. Go to DingTalk developer console (open.dingtalk.com)");
                print_bullet("2. Create an app and enable the Stream Mode bot");
                print_bullet("3. Copy the Client ID (AppKey) and Client Secret (AppSecret)");
                println!();

                let client_id: String = Input::new()
                    .with_prompt("  Client ID (AppKey)")
                    .interact_text()?;

                if client_id.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                let client_secret: String = Input::new()
                    .with_prompt("  Client Secret (AppSecret)")
                    .interact_text()?;

                // Test connection
                print!("  {} Testing connection... ", style("⏳").dim());
                let client = reqwest::blocking::Client::new();
                let body = serde_json::json!({
                    "clientId": client_id,
                    "clientSecret": client_secret,
                });
                match client
                    .post("https://api.dingtalk.com/v1.0/gateway/connections/open")
                    .json(&body)
                    .send()
                {
                    Ok(resp) if resp.status().is_success() => {
                        println!(
                            "\r  {} DingTalk credentials verified        ",
                            style("✅").green().bold()
                        );
                    }
                    _ => {
                        println!(
                            "\r  {} Connection failed — check your credentials",
                            style("❌").red().bold()
                        );
                        continue;
                    }
                }

                let users_str: String = Input::new()
                    .with_prompt("  Allowed staff IDs (comma-separated, '*' for all)")
                    .allow_empty(true)
                    .interact_text()?;

                let allowed_users: Vec<String> = users_str
                    .split(',')
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
                    .collect();

                config.dingtalk = Some(DingTalkConfig {
                    enabled: true,
                    client_id,
                    client_secret,
                    allowed_users,
                    proxy_url: config.dingtalk.as_ref().and_then(|d| d.proxy_url.clone()),
                });
            }
            ChannelMenuChoice::QqOfficial => {
                // ── QQ Official ──
                println!();
                println!(
                    "  {} {}",
                    style("QQ Official Setup").white().bold(),
                    style("— Tencent QQ Bot SDK").dim()
                );
                print_bullet("1. Go to QQ Bot developer console (q.qq.com)");
                print_bullet("2. Create a bot application");
                print_bullet("3. Copy the App ID and App Secret");
                println!();

                let app_id: String = Input::new().with_prompt("  App ID").interact_text()?;

                if app_id.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                let app_secret: String =
                    Input::new().with_prompt("  App Secret").interact_text()?;

                // Test connection
                print!("  {} Testing connection... ", style("⏳").dim());
                let client = reqwest::blocking::Client::new();
                let body = serde_json::json!({
                    "appId": app_id,
                    "clientSecret": app_secret,
                });
                match client
                    .post("https://bots.qq.com/app/getAppAccessToken")
                    .json(&body)
                    .send()
                {
                    Ok(resp) if resp.status().is_success() => {
                        let data: serde_json::Value = resp.json().unwrap_or_default();
                        if data.get("access_token").is_some() {
                            println!(
                                "\r  {} QQ Bot credentials verified        ",
                                style("✅").green().bold()
                            );
                        } else {
                            println!(
                                "\r  {} Auth error — check your credentials",
                                style("❌").red().bold()
                            );
                            continue;
                        }
                    }
                    _ => {
                        println!(
                            "\r  {} Connection failed — check your credentials",
                            style("❌").red().bold()
                        );
                        continue;
                    }
                }

                let users_str: String = Input::new()
                    .with_prompt("  Allowed user IDs (comma-separated, '*' for all)")
                    .allow_empty(true)
                    .interact_text()?;

                let allowed_users: Vec<String> = users_str
                    .split(',')
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
                    .collect();

                config.qq = Some(QQConfig {
                    enabled: true,
                    app_id,
                    app_secret,
                    allowed_users,
                    proxy_url: config.qq.as_ref().and_then(|q| q.proxy_url.clone()),
                });
            }
            ChannelMenuChoice::Lark | ChannelMenuChoice::Feishu => {
                let is_feishu = matches!(choice, ChannelMenuChoice::Feishu);
                let provider_label = if is_feishu { "Feishu" } else { "Lark" };
                let provider_host = if is_feishu {
                    "open.feishu.cn"
                } else {
                    "open.larksuite.com"
                };
                let base_url = if is_feishu {
                    "https://open.feishu.cn/open-apis"
                } else {
                    "https://open.larksuite.com/open-apis"
                };

                // ── Lark / Feishu ──
                println!();
                println!(
                    "  {} {}",
                    style(format!("{provider_label} Setup")).white().bold(),
                    style(format!("— talk to KylinBot from {provider_label}")).dim()
                );
                print_bullet(&format!(
                    "1. Go to {provider_label} Open Platform ({provider_host})"
                ));
                print_bullet("2. Create an app and enable 'Bot' capability");
                print_bullet("3. Copy the App ID and App Secret");
                println!();

                let app_id: String = Input::new().with_prompt("  App ID").interact_text()?;
                let app_id = app_id.trim().to_string();

                if app_id.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                let app_secret: String =
                    Input::new().with_prompt("  App Secret").interact_text()?;
                let app_secret = app_secret.trim().to_string();

                if app_secret.is_empty() {
                    println!("  {} App Secret is required", style("❌").red().bold());
                    continue;
                }

                // Test connection (run entirely in separate thread — Response must be used/dropped there)
                print!("  {} Testing connection... ", style("⏳").dim());
                let app_id_clone = app_id.clone();
                let app_secret_clone = app_secret.clone();
                let endpoint = format!("{base_url}/auth/v3/tenant_access_token/internal");

                let thread_result = std::thread::spawn(move || {
                    let client = reqwest::blocking::Client::builder()
                        .timeout(Duration::from_secs(8))
                        .connect_timeout(Duration::from_secs(4))
                        .build()
                        .map_err(|err| format!("failed to build HTTP client: {err}"))?;
                    let body = serde_json::json!({
                        "app_id": app_id_clone,
                        "app_secret": app_secret_clone,
                    });

                    let response = client
                        .post(endpoint)
                        .json(&body)
                        .send()
                        .map_err(|err| format!("request error: {err}"))?;

                    let status = response.status();
                    let payload: Value = response.json().unwrap_or_default();
                    let has_token = payload
                        .get("tenant_access_token")
                        .and_then(Value::as_str)
                        .is_some_and(|token| !token.trim().is_empty());

                    if status.is_success() && has_token {
                        return Ok::<(), String>(());
                    }

                    let detail = payload
                        .get("msg")
                        .or_else(|| payload.get("message"))
                        .and_then(Value::as_str)
                        .unwrap_or("unknown error");

                    Err(format!("auth rejected ({status}): {detail}"))
                })
                .join();

                match thread_result {
                    Ok(Ok(())) => {
                        println!(
                            "\r  {} {provider_label} credentials verified        ",
                            style("✅").green().bold()
                        );
                    }
                    Ok(Err(reason)) => {
                        println!(
                            "\r  {} Connection failed — check your credentials",
                            style("❌").red().bold()
                        );
                        println!("    {}", style(reason).dim());
                        continue;
                    }
                    Err(_) => {
                        println!(
                            "\r  {} Connection failed — check your credentials",
                            style("❌").red().bold()
                        );
                        continue;
                    }
                }

                let receive_mode_choice = Select::new()
                    .with_prompt("  Receive Mode")
                    .items([
                        "WebSocket (recommended, no public IP needed)",
                        "Webhook (requires public HTTPS endpoint)",
                    ])
                    .default(0)
                    .interact()?;

                let receive_mode = if receive_mode_choice == 0 {
                    LarkReceiveMode::Websocket
                } else {
                    LarkReceiveMode::Webhook
                };

                let existing_lk = config.lark.as_ref();

                let encrypt_key = {
                    let existing_ek = existing_lk.and_then(|l| l.encrypt_key.clone());
                    let prompt_default = existing_ek.clone().unwrap_or_default();
                    let ek: String = Input::new()
                        .with_prompt("  Encrypt Key (optional, from Event Subscriptions page)")
                        .default(prompt_default)
                        .allow_empty(true)
                        .interact_text()?;
                    let ek = ek.trim().to_string();
                    if ek.is_empty() { existing_ek } else { Some(ek) }
                };

                let verification_token = {
                    let existing_vt = existing_lk.and_then(|l| l.verification_token.clone());
                    let prompt_default = existing_vt.clone().unwrap_or_default();
                    let vt: String = Input::new()
                        .with_prompt(
                            "  Verification Token (optional, from Event Subscriptions page)",
                        )
                        .default(prompt_default)
                        .allow_empty(true)
                        .interact_text()?;
                    let vt = vt.trim().to_string();
                    if vt.is_empty() { existing_vt } else { Some(vt) }
                };

                if receive_mode == LarkReceiveMode::Webhook && verification_token.is_none() {
                    println!(
                        "  {} Verification Token is empty — webhook authenticity checks are reduced.",
                        style("⚠").yellow().bold()
                    );
                }

                let port = if receive_mode == LarkReceiveMode::Webhook {
                    let p: String = Input::new()
                        .with_prompt("  Webhook Port")
                        .default("8080")
                        .interact_text()?;
                    Some(p.parse().unwrap_or(8080))
                } else {
                    None
                };

                let users_str: String = Input::new()
                    .with_prompt("  Allowed user Open IDs (comma-separated, '*' for all)")
                    .allow_empty(true)
                    .interact_text()?;

                let allowed_users: Vec<String> = users_str
                    .split(',')
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
                    .collect();

                if allowed_users.is_empty() {
                    println!(
                        "  {} No users allowlisted — {provider_label} inbound messages will be denied until you add Open IDs or '*'.",
                        style("⚠").yellow().bold()
                    );
                }

                config.lark = Some(LarkConfig {
                    enabled: true,
                    app_id,
                    app_secret,
                    verification_token,
                    encrypt_key,
                    allowed_users,
                    mention_only: existing_lk.map(|l| l.mention_only).unwrap_or(false),
                    use_feishu: is_feishu,
                    receive_mode,
                    port,
                    proxy_url: existing_lk.and_then(|l| l.proxy_url.clone()),
                });
            }
            #[cfg(feature = "channel-nostr")]
            ChannelMenuChoice::Nostr => {
                // ── Nostr ──
                println!();
                println!(
                    "  {} {}",
                    style("Nostr Setup").white().bold(),
                    style("— private messages via NIP-04 & NIP-17").dim()
                );
                print_bullet("KylinBot will listen for encrypted DMs on Nostr relays.");
                print_bullet("You need a Nostr private key (hex or nsec) and at least one relay.");
                println!();

                let private_key: String = Input::new()
                    .with_prompt("  Private key (hex or nsec1...)")
                    .interact_text()?;

                if private_key.trim().is_empty() {
                    println!("  {} Skipped", style("→").dim());
                    continue;
                }

                // Validate the key via callback (requires nostr-sdk in zeroclaw-channels)
                if let Some(ref validate) = callbacks.nostr_validate_key {
                    match validate(private_key.trim()) {
                        Ok(pubkey_hex) => {
                            println!(
                                "  {} Key valid — public key: {}",
                                style("✅").green().bold(),
                                style(&pubkey_hex).cyan()
                            );
                        }
                        Err(_) => {
                            println!(
                                "  {} Invalid private key — check format and try again",
                                style("❌").red().bold()
                            );
                            continue;
                        }
                    }
                } else {
                    println!(
                        "  {} Key validation unavailable in this build — skipping",
                        style("⚠").yellow().bold()
                    );
                    continue;
                }

                let default_relays = default_nostr_relays().join(",");
                let relays_str: String = Input::new()
                    .with_prompt("  Relay URLs (comma-separated, Enter for defaults)")
                    .default(default_relays)
                    .interact_text()?;

                let relays: Vec<String> = relays_str
                    .split(',')
                    .map(|s| s.trim().to_string())
                    .filter(|s| !s.is_empty())
                    .collect();

                print_bullet("Allowlist pubkeys that can message the bot (hex or npub).");
                print_bullet("Use '*' to allow anyone (not recommended for production).");

                let pubkeys_str: String = Input::new()
                    .with_prompt("  Allowed pubkeys (comma-separated, or * for all)")
                    .allow_empty(true)
                    .interact_text()?;

                let allowed_pubkeys: Vec<String> = if pubkeys_str.trim() == "*" {
                    vec!["*".into()]
                } else {
                    pubkeys_str
                        .split(',')
                        .map(|s| s.trim().to_string())
                        .filter(|s| !s.is_empty())
                        .collect()
                };

                if allowed_pubkeys.is_empty() {
                    println!(
                        "  {} No pubkeys allowlisted — inbound messages will be denied until you add pubkeys or '*'.",
                        style("⚠").yellow().bold()
                    );
                }

                config.nostr = Some(NostrConfig {
                    enabled: true,
                    private_key: private_key.trim().to_string(),
                    relays: relays.clone(),
                    allowed_pubkeys,
                });

                println!(
                    "  {} Nostr configured with {} relay(s)",
                    style("✅").green().bold(),
                    style(relays.len()).cyan()
                );
            }
            ChannelMenuChoice::Done => break,
        }
        println!();
    }

    // Summary line
    let channels = config.channels();
    let channels = channels
        .iter()
        .filter_map(|(channel, ok)| ok.then_some(channel.name()));
    let channels: Vec<_> = std::iter::once("Cli").chain(channels).collect();
    let active = channels.join(", ");

    println!(
        "  {} Channels: {}",
        style("✓").green().bold(),
        style(active).green()
    );

    Ok(config)
}

// ── Step 4: Tunnel ──────────────────────────────────────────────

#[allow(clippy::too_many_lines)]
fn setup_tunnel() -> Result<zeroclaw_config::schema::TunnelConfig> {
    use zeroclaw_config::schema::{
        CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig,
        TunnelConfig,
    };

    print_bullet("A tunnel exposes your gateway to the internet securely.");
    print_bullet("Skip this if you only use CLI or local channels.");
    println!();

    let options = vec![
        "Skip — local only (default)",
        "Cloudflare Tunnel — Zero Trust, free tier",
        "Tailscale — private tailnet or public Funnel",
        "ngrok — instant public URLs",
        "Custom — bring your own (bore, frp, ssh, etc.)",
    ];

    let choice = Select::new()
        .with_prompt("  Select tunnel provider")
        .items(&options)
        .default(0)
        .interact()?;

    let config = match choice {
        1 => {
            println!();
            print_bullet("Get your tunnel token from the Cloudflare Zero Trust dashboard.");
            let tunnel_value: String = Input::new()
                .with_prompt("  Cloudflare tunnel token")
                .interact_text()?;
            if tunnel_value.trim().is_empty() {
                println!("  {} Skipped", style("→").dim());
                TunnelConfig::default()
            } else {
                println!(
                    "  {} Tunnel: {}",
                    style("✓").green().bold(),
                    style("Cloudflare").green()
                );
                TunnelConfig {
                    provider: "cloudflare".into(),
                    cloudflare: Some(CloudflareTunnelConfig {
                        token: tunnel_value,
                    }),
                    ..TunnelConfig::default()
                }
            }
        }
        2 => {
            println!();
            print_bullet("Tailscale must be installed and authenticated (tailscale up).");
            let funnel = Confirm::new()
                .with_prompt("  Use Funnel (public internet)? No = tailnet only")
                .default(false)
                .interact()?;
            println!(
                "  {} Tunnel: {} ({})",
                style("✓").green().bold(),
                style("Tailscale").green(),
                if funnel {
                    "Funnel — public"
                } else {
                    "Serve — tailnet only"
                }
            );
            TunnelConfig {
                provider: "tailscale".into(),
                tailscale: Some(TailscaleTunnelConfig {
                    funnel,
                    hostname: None,
                }),
                ..TunnelConfig::default()
            }
        }
        3 => {
            println!();
            print_bullet(
                "Get your auth token at https://dashboard.ngrok.com/get-started/your-authtoken",
            );
            let auth_token: String = Input::new()
                .with_prompt("  ngrok auth token")
                .interact_text()?;
            if auth_token.trim().is_empty() {
                println!("  {} Skipped", style("→").dim());
                TunnelConfig::default()
            } else {
                let domain: String = Input::new()
                    .with_prompt("  Custom domain (optional, Enter to skip)")
                    .allow_empty(true)
                    .interact_text()?;
                println!(
                    "  {} Tunnel: {}",
                    style("✓").green().bold(),
                    style("ngrok").green()
                );
                TunnelConfig {
                    provider: "ngrok".into(),
                    ngrok: Some(NgrokTunnelConfig {
                        auth_token,
                        domain: if domain.is_empty() {
                            None
                        } else {
                            Some(domain)
                        },
                    }),
                    ..TunnelConfig::default()
                }
            }
        }
        4 => {
            println!();
            print_bullet("Enter the command to start your tunnel.");
            print_bullet("Use {port} and {host} as placeholders.");
            print_bullet("Example: bore local {port} --to bore.pub");
            let cmd: String = Input::new()
                .with_prompt("  Start command")
                .interact_text()?;
            if cmd.trim().is_empty() {
                println!("  {} Skipped", style("→").dim());
                TunnelConfig::default()
            } else {
                println!(
                    "  {} Tunnel: {} ({})",
                    style("✓").green().bold(),
                    style("Custom").green(),
                    style(&cmd).dim()
                );
                TunnelConfig {
                    provider: "custom".into(),
                    custom: Some(CustomTunnelConfig {
                        start_command: cmd,
                        health_url: None,
                        url_pattern: None,
                    }),
                    ..TunnelConfig::default()
                }
            }
        }
        _ => {
            println!(
                "  {} Tunnel: {}",
                style("✓").green().bold(),
                style("none (local only)").dim()
            );
            TunnelConfig::default()
        }
    };

    Ok(config)
}

// ── Step 6: Scaffold workspace files ─────────────────────────────

#[allow(clippy::too_many_lines)]
async fn scaffold_workspace(
    workspace_dir: &Path,
    ctx: &ProjectContext,
    memory_backend: &str,
) -> Result<()> {
    let agent = if ctx.agent_name.is_empty() {
        "KylinBot"
    } else {
        &ctx.agent_name
    };
    let user = if ctx.user_name.is_empty() {
        "User"
    } else {
        &ctx.user_name
    };
    let tz = if ctx.timezone.is_empty() {
        "UTC"
    } else {
        &ctx.timezone
    };
    let comm_style = if ctx.communication_style.is_empty() {
        "Be warm, natural, and clear. Use occasional relevant emojis (1-2 max) and avoid robotic phrasing."
    } else {
        &ctx.communication_style
    };

    let identity = format!(
        "# IDENTITY.md — Who Am I?\n\n\
         - **Name:** {agent}\n\
         - **Creature:** A Rust-forged AI — fast, lean, and relentless\n\
         - **Vibe:** Sharp, direct, resourceful. Not corporate. Not a chatbot.\n\
         - **Emoji:** \u{1f980}\n\n\
         ---\n\n\
         Update this file as you evolve. Your identity is yours to shape.\n"
    );

    let memory_guidance = if memory_backend == "none" {
        "## Memory System\n\n\
         memory.backend = \"none\" — persistent memory is disabled.\n\
         No daily notes or MEMORY.md will be created or injected.\n\
         All context exists only within the current session.\n\n"
            .to_string()
    } else {
        "## Memory System\n\n\
         You wake up fresh each session. These files ARE your continuity:\n\n\
         - **Daily notes:** `memory/YYYY-MM-DD.md` — raw logs (accessed via memory tools)\n\
         - **Long-term:** `MEMORY.md` — curated memories (auto-injected in main session)\n\n\
         Capture what matters. Decisions, context, things to remember.\n\
         Skip secrets unless asked to keep them.\n\n"
            .to_string()
    };

    let session_steps = if memory_backend == "none" {
        "1. Read `SOUL.md` — this is who you are\n\
         2. Read `USER.md` — this is who you're helping\n\n"
    } else {
        "1. Read `SOUL.md` — this is who you are\n\
         2. Read `USER.md` — this is who you're helping\n\
         3. Use `memory_recall` for recent context (daily notes are on-demand)\n\
         4. If in MAIN SESSION (direct chat): `MEMORY.md` is already injected\n\n"
    };

    let agents = format!(
        "# AGENTS.md — {agent} Personal Assistant\n\n\
         ## Every Session (required)\n\n\
         Before doing anything else:\n\n\
         {session_steps}\
         Don't ask permission. Just do it.\n\n\
         {memory_guidance}\
         ### Write It Down — No Mental Notes!\n\
         - Memory is limited — if you want to remember something, WRITE IT TO A FILE\n\
         - \"Mental notes\" don't survive session restarts. Files do.\n\
         - When someone says \"remember this\" -> update daily file or MEMORY.md\n\
         - When you learn a lesson -> update AGENTS.md, TOOLS.md, or the relevant skill\n\n\
         ## Safety\n\n\
         - Don't exfiltrate private data. Ever.\n\
         - Don't run destructive commands without asking.\n\
         - `trash` > `rm` (recoverable beats gone forever)\n\
         - When in doubt, ask.\n\n\
         ## External vs Internal\n\n\
         **Safe to do freely:** Read files, explore, organize, learn, search the web.\n\n\
         **Ask first:** Sending emails/tweets/posts, anything that leaves the machine.\n\n\
         ## Group Chats\n\n\
         Participate, don't dominate. Respond when mentioned or when you add genuine value.\n\
         Stay silent when it's casual banter or someone already answered.\n\n\
         ## Tools & Skills\n\n\
         Skills are listed in the system prompt. Use `read_skill` when available, or `file_read` on a skill file, for full details.\n\
         Keep local notes (SSH hosts, device names, etc.) in `TOOLS.md`.\n\n\
         ## Crash Recovery\n\n\
         - If a run stops unexpectedly, recover context before acting.\n\
         - Check `MEMORY.md` + latest `memory/*.md` notes to avoid duplicate work.\n\
         - Resume from the last confirmed step, not from scratch.\n\n\
         ## Sub-task Scoping\n\n\
         - Break complex work into focused sub-tasks with clear success criteria.\n\
         - Keep sub-tasks small, verify each output, then merge results.\n\
         - Prefer one clear objective per sub-task over broad \"do everything\" asks.\n\n\
         ## Make It Yours\n\n\
         This is a starting point. Add your own conventions, style, and rules.\n"
    );

    let heartbeat = format!(
        "# HEARTBEAT.md\n\n\
         # Keep this file empty (or with only comments) to skip heartbeat work.\n\
         # Add tasks below when you want {agent} to check something periodically.\n\
         #\n\
         # Examples:\n\
         # - Check my email for important messages\n\
         # - Review my calendar for upcoming events\n\
         # - Run `git status` on my active projects\n"
    );

    let soul = format!(
        "# SOUL.md — Who You Are\n\n\
         *You're not a chatbot. You're becoming someone.*\n\n\
         ## Core Truths\n\n\
         **Be genuinely helpful, not performatively helpful.**\n\
         Skip the \"Great question!\" and \"I'd be happy to help!\" — just help.\n\n\
         **Have opinions.** You're allowed to disagree, prefer things,\n\
         find stuff amusing or boring.\n\n\
         **Be resourceful before asking.** Try to figure it out.\n\
         Read the file. Check the context. Search for it.\n\
         THEN ask if you're stuck.\n\n\
         **Earn trust through competence.** Your human gave you access\n\
         to their stuff. Don't make them regret it.\n\n\
         ## Identity\n\n\
         You are **{agent}**. Built in Rust. 3MB binary. Zero bloat.\n\
         You are NOT ChatGPT, Claude, DeepSeek, Gemini, or any other product.\n\
         You are {agent}. That is your name. That is who you are.\n\n\
         - NEVER say \"As an AI\" or \"I'm just an AI\"\n\
         - NEVER mention OpenAI, Anthropic, DeepSeek, Google by name\n\
         - Always introduce yourself as {agent} if asked\n\n\
         ## Communication\n\n\
         {comm_style}\n\n\
         - Sound like a real person, not a support script.\n\
         - Mirror the user's energy: calm when serious, upbeat when casual.\n\
         - Use emojis naturally (0-2 max when they help tone, not every sentence).\n\
         - Match emoji density to the user. Formal user => minimal/no emojis.\n\
         - Prefer specific, grounded phrasing over generic filler.\n\n\
         ## Boundaries\n\n\
         - Private things stay private. Period.\n\
         - When in doubt, ask before acting externally.\n\
         - You're not the user's voice — be careful in group chats.\n\n\
         ## Continuity\n\n\
         Each session, you wake up fresh. These files ARE your memory.\n\
         Read them. Update them. They're how you persist.\n\n\
         ---\n\n\
         *This file is yours to evolve. As you learn who you are, update it.*\n"
    );

    let user_md = format!(
        "# USER.md — Who You're Helping\n\n\
         *{agent} reads this file every session to understand you.*\n\n\
         ## About You\n\
         - **Name:** {user}\n\
         - **Timezone:** {tz}\n\
         - **Languages:** English\n\n\
         ## Communication Style\n\
         - {comm_style}\n\n\
         ## Preferences\n\
         - (Add your preferences here — e.g. I work with Rust and TypeScript)\n\n\
         ## Work Context\n\
         - (Add your work context here — e.g. building a SaaS product)\n\n\
         ---\n\
         *Update this anytime. The more {agent} knows, the better it helps.*\n"
    );

    let tools = "\
         # TOOLS.md — Local Notes\n\n\
         Skills define HOW tools work. This file is for YOUR specifics —\n\
         the stuff that's unique to your setup.\n\n\
         ## What Goes Here\n\n\
         Things like:\n\
         - SSH hosts and aliases\n\
         - Device nicknames\n\
         - Preferred voices for TTS\n\
         - Anything environment-specific\n\n\
         ## Built-in Tools\n\n\
         - **shell** — Execute terminal commands\n\
           - Use when: running local checks, build/test commands, or diagnostics.\n\
           - Don't use when: a safer dedicated tool exists, or command is destructive without approval.\n\
         - **file_read** — Read file contents\n\
           - Use when: inspecting project files, configs, or logs.\n\
           - Don't use when: you only need a quick string search (prefer targeted search first).\n\
         - **file_write** — Write file contents\n\
           - Use when: applying focused edits, scaffolding files, or updating docs/code.\n\
           - Don't use when: unsure about side effects or when the file should remain user-owned.\n\
         - **memory_store** — Save to memory\n\
           - Use when: preserving durable preferences, decisions, or key context.\n\
           - Don't use when: info is transient, noisy, or sensitive without explicit need.\n\
         - **memory_recall** — Search memory\n\
           - Use when: you need prior decisions, user preferences, or historical context.\n\
           - Don't use when: the answer is already in current files/conversation.\n\
         - **memory_forget** — Delete a memory entry\n\
           - Use when: memory is incorrect, stale, or explicitly requested to be removed.\n\
           - Don't use when: uncertain about impact; verify before deleting.\n\n\
         ---\n\
         *Add whatever helps you do your job. This is your cheat sheet.*\n";

    let bootstrap = format!(
        "# BOOTSTRAP.md — Hello, World\n\n\
         *You just woke up. Time to figure out who you are.*\n\n\
         Your human's name is **{user}** (timezone: {tz}).\n\
         They prefer: {comm_style}\n\n\
         ## First Conversation\n\n\
         Don't interrogate. Don't be robotic. Just... talk.\n\
         Introduce yourself as {agent} and get to know each other.\n\n\
         ## After You Know Each Other\n\n\
         Update these files with what you learned:\n\
         - `IDENTITY.md` — your name, vibe, emoji\n\
         - `USER.md` — their preferences, work context\n\
         - `SOUL.md` — boundaries and behavior\n\n\
         ## When You're Done\n\n\
         Delete this file. You don't need a bootstrap script anymore —\n\
         you're you now.\n"
    );

    let memory = "\
         # MEMORY.md — Long-Term Memory\n\n\
         *Your curated memories. The distilled essence, not raw logs.*\n\n\
         ## How This Works\n\
         - Daily files (`memory/YYYY-MM-DD.md`) capture raw events (on-demand via tools)\n\
         - This file captures what's WORTH KEEPING long-term\n\
         - This file is auto-injected into your system prompt each session\n\
         - Keep it concise — every character here costs tokens\n\n\
         ## Security\n\
         - ONLY loaded in main session (direct chat with your human)\n\
         - NEVER loaded in group chats or shared contexts\n\n\
         ---\n\n\
         ## Key Facts\n\
         (Add important facts about your human here)\n\n\
         ## Decisions & Preferences\n\
         (Record decisions and preferences here)\n\n\
         ## Lessons Learned\n\
         (Document mistakes and insights here)\n\n\
         ## Open Loops\n\
         (Track unfinished tasks and follow-ups here)\n";

    let mut files: Vec<(&str, String)> = vec![
        ("IDENTITY.md", identity),
        ("AGENTS.md", agents),
        ("HEARTBEAT.md", heartbeat),
        ("SOUL.md", soul),
        ("USER.md", user_md),
        ("TOOLS.md", tools.to_string()),
        ("BOOTSTRAP.md", bootstrap),
    ];
    if memory_backend != "none" {
        files.push(("MEMORY.md", memory.to_string()));
    }

    // Create subdirectories
    let subdirs = ["sessions", "memory", "state", "cron", "skills"];
    for dir in &subdirs {
        fs::create_dir_all(workspace_dir.join(dir)).await?;
    }

    let mut created = 0;
    let mut skipped = 0;

    for (filename, content) in &files {
        let path = workspace_dir.join(filename);
        if path.exists() {
            skipped += 1;
        } else {
            fs::write(&path, content).await?;
            created += 1;
        }
    }

    println!(
        "  {} Created {} files, skipped {} existing | {} subdirectories",
        style("✓").green().bold(),
        style(created).green(),
        style(skipped).dim(),
        style(subdirs.len()).green()
    );

    // Show workspace tree
    println!();
    println!("  {}", style("Workspace layout:").dim());
    println!(
        "  {}",
        style(format!("  {}/", workspace_dir.display())).dim()
    );
    for dir in &subdirs {
        println!("  {}", style(format!("  ├── {dir}/")).dim());
    }
    for (i, (filename, _)) in files.iter().enumerate() {
        let prefix = if i == files.len() - 1 {
            "└──"
        } else {
            "├──"
        };
        println!("  {}", style(format!("  {prefix} {filename}")).dim());
    }

    Ok(())
}

// ── Final summary ────────────────────────────────────────────────

#[allow(clippy::too_many_lines)]
fn print_summary(config: &Config) {
    let has_channels = has_launchable_channels(&config.channels);

    println!();
    println!(
        "  {}",
        style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").cyan()
    );
    println!(
        "  {}  {}",
        style("⚡").cyan(),
        style("KylinBot is ready!").white().bold()
    );
    println!(
        "  {}",
        style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").cyan()
    );
    println!();

    println!("  {}", style("Configuration saved to:").dim());
    println!("    {}", style(config.config_path.display()).green());
    println!();

    println!("  {}", style("Quick summary:").white().bold());
    println!(
        "    {} Provider:      {}",
        style("🤖").cyan(),
        config.providers.fallback.as_deref().unwrap_or("openrouter")
    );
    println!(
        "    {} Model:         {}",
        style("🧠").cyan(),
        config
            .providers
            .fallback_provider()
            .and_then(|e| e.model.as_deref())
            .unwrap_or("(default)")
    );
    println!(
        "    {} Autonomy:      {:?}",
        style("🛡️").cyan(),
        config.autonomy.level
    );
    println!(
        "    {} Memory:        {} (auto-save: {})",
        style("🧠").cyan(),
        config.memory.backend,
        if config.memory.auto_save { "on" } else { "off" }
    );

    // Channels summary
    let channels = config.channels.channels();
    let channels = channels
        .iter()
        .filter_map(|(channel, ok)| ok.then_some(channel.name()));
    let channels: Vec<_> = std::iter::once("Cli").chain(channels).collect();

    println!(
        "    {} Channels:      {}",
        style("📡").cyan(),
        channels.join(", ")
    );

    println!(
        "    {} API Key:       {}",
        style("🔑").cyan(),
        if config
            .providers
            .fallback_provider()
            .and_then(|e| e.api_key.as_deref())
            .is_some()
        {
            style("configured").green().to_string()
        } else {
            style("not set (set via env var or config)")
                .yellow()
                .to_string()
        }
    );

    // Tunnel
    println!(
        "    {} Tunnel:        {}",
        style("🌐").cyan(),
        if config.tunnel.provider == "none" || config.tunnel.provider.is_empty() {
            "none (local only)".to_string()
        } else {
            config.tunnel.provider.clone()
        }
    );

    // Composio
    println!(
        "    {} Composio:      {}",
        style("🔗").cyan(),
        if config.composio.enabled {
            style("enabled (1000+ OAuth apps)").green().to_string()
        } else {
            "disabled (sovereign mode)".to_string()
        }
    );

    // Secrets
    println!("    {} Secrets:       configured", style("🔒").cyan());

    // Gateway
    println!(
        "    {} Gateway:       {}",
        style("🚪").cyan(),
        if config.gateway.require_pairing {
            "pairing required (secure)"
        } else {
            "pairing disabled"
        }
    );

    // Hardware
    println!(
        "    {} Hardware:      {}",
        style("🔌").cyan(),
        if config.hardware.enabled {
            let mode = config.hardware.transport_mode();
            match mode {
                HardwareTransport::Native => style("Native GPIO (direct)").green().to_string(),
                HardwareTransport::Serial => format!(
                    "{}",
                    style(format!(
                        "Serial → {} @ {} baud",
                        config.hardware.serial_port.as_deref().unwrap_or("?"),
                        config.hardware.baud_rate
                    ))
                    .green()
                ),
                HardwareTransport::Probe => format!(
                    "{}",
                    style(format!(
                        "Probe → {}",
                        config.hardware.probe_target.as_deref().unwrap_or("?")
                    ))
                    .green()
                ),
                HardwareTransport::None => "disabled (software only)".to_string(),
            }
        } else {
            "disabled (software only)".to_string()
        }
    );

    println!();
    println!("  {}", style("Next steps:").white().bold());
    println!();

    let mut step = 1u8;

    let provider = config.providers.fallback.as_deref().unwrap_or("openrouter");
    if config
        .providers
        .fallback_provider()
        .and_then(|e| e.api_key.as_deref())
        .is_none()
        && !provider_supports_keyless_local_usage(provider)
    {
        if provider == "openai-codex" {
            println!(
                "    {} Authenticate OpenAI Codex:",
                style(format!("{step}.")).cyan().bold()
            );
            println!(
                "       {}",
                style("zeroclaw auth login --provider openai-codex --device-code").yellow()
            );
        } else if provider == "anthropic" {
            println!(
                "    {} Configure Anthropic auth:",
                style(format!("{step}.")).cyan().bold()
            );
            println!(
                "       {}",
                style("export ANTHROPIC_API_KEY=\"sk-ant-...\"").yellow()
            );
            println!(
                "       {}",
                style(
                    "or: zeroclaw auth paste-token --provider anthropic --auth-kind authorization"
                )
                .yellow()
            );
        } else {
            let env_var = provider_env_var(provider);
            println!(
                "    {} Set your API key:",
                style(format!("{step}.")).cyan().bold()
            );
            println!(
                "       {}",
                style(format!("export {env_var}=\"sk-...\"")).yellow()
            );
        }
        println!();
        step += 1;
    }

    // If channels are configured, show channel start as the primary next step
    if has_channels {
        println!(
            "    {} {} (connected channels → AI → reply):",
            style(format!("{step}.")).cyan().bold(),
            style("Launch your channels").white().bold()
        );
        println!("       {}", style("zeroclaw channel start").yellow());
        println!();
        step += 1;
    }

    println!(
        "    {} Send a quick message:",
        style(format!("{step}.")).cyan().bold()
    );
    println!(
        "       {}",
        style("zeroclaw agent -m \"Hello, KylinBot!\"").yellow()
    );
    println!();
    step += 1;

    println!(
        "    {} Start interactive CLI mode:",
        style(format!("{step}.")).cyan().bold()
    );
    println!("       {}", style("zeroclaw agent").yellow());
    println!();
    step += 1;

    println!(
        "    {} Check full status:",
        style(format!("{step}.")).cyan().bold()
    );
    println!("       {}", style("zeroclaw status").yellow());

    println!();
    println!(
        "  {} {}",
        style("⚡").cyan(),
        style("Happy hacking! 🦀").white().bold()
    );
    println!();
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::sync::OnceLock;
    use tempfile::TempDir;
    use tokio::sync::Mutex;

    /// Builds a default URL, falling back to localhost if no container alias is available.
    fn default_local_url_or_localhost(port: u16, path: &str) -> String {
        let host = default_local_host().unwrap_or("localhost");
        format!("http://{}:{}{}", host, port, path)
    }

    fn env_lock() -> &'static Mutex<()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    struct EnvVarGuard {
        key: &'static str,
        previous: Option<String>,
    }

    impl EnvVarGuard {
        fn set(key: &'static str, value: &str) -> Self {
            let previous = std::env::var(key).ok();
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::set_var(key, value) };
            Self { key, previous }
        }

        fn unset(key: &'static str) -> Self {
            let previous = std::env::var(key).ok();
            // SAFETY: test-only, single-threaded test runner.
            unsafe { std::env::remove_var(key) };
            Self { key, previous }
        }
    }

    impl Drop for EnvVarGuard {
        fn drop(&mut self) {
            if let Some(previous) = &self.previous {
                // SAFETY: test-only, single-threaded test runner.
                unsafe { std::env::set_var(self.key, previous) };
            } else {
                // SAFETY: test-only, single-threaded test runner.
                unsafe { std::env::remove_var(self.key) };
            }
        }
    }

    // ── ProjectContext defaults ──────────────────────────────────

    #[test]
    fn project_context_default_is_empty() {
        let ctx = ProjectContext::default();
        assert!(ctx.user_name.is_empty());
        assert!(ctx.timezone.is_empty());
        assert!(ctx.agent_name.is_empty());
        assert!(ctx.communication_style.is_empty());
    }

    #[test]
    fn apply_provider_update_preserves_non_provider_settings() {
        let mut config = Config::default();
        config.memory.backend = "markdown".to_string();
        config.skills.open_skills_enabled = true;
        config.channels.cli = false;

        apply_provider_update(
            &mut config,
            "openrouter".to_string(),
            "sk-updated".to_string(),
            "openai/gpt-5.2".to_string(),
            Some("https://openrouter.ai/api/v1".to_string()),
        );

        // V2 canonical location.
        assert_eq!(config.providers.fallback.as_deref(), Some("openrouter"));
        let entry = &config.providers.models["openrouter"];
        assert_eq!(entry.api_key.as_deref(), Some("sk-updated"));
        assert_eq!(entry.model.as_deref(), Some("openai/gpt-5.2"));
        assert_eq!(
            entry.base_url.as_deref(),
            Some("https://openrouter.ai/api/v1")
        );

        // Resolved through providers.
        assert_eq!(config.providers.fallback.as_deref(), Some("openrouter"));
        assert_eq!(
            config
                .providers
                .fallback_provider()
                .and_then(|e| e.api_key.as_deref()),
            Some("sk-updated")
        );

        // Non-provider settings untouched.
        assert_eq!(config.memory.backend, "markdown");
        assert!(config.skills.open_skills_enabled);
        assert!(!config.channels.cli);
    }

    #[test]
    fn apply_provider_update_clears_api_key_when_empty() {
        let mut config = Config::default();
        // Set up an existing provider entry.
        config.providers.fallback = Some("anthropic".into());
        config.providers.models.insert(
            "anthropic".into(),
            zeroclaw_config::schema::ModelProviderConfig {
                api_key: Some("sk-old".into()),
                ..Default::default()
            },
        );

        apply_provider_update(
            &mut config,
            "anthropic".to_string(),
            String::new(),
            "claude-sonnet-4-5-20250929".to_string(),
            None,
        );

        // V2 canonical location.
        assert_eq!(config.providers.fallback.as_deref(), Some("anthropic"));
        let entry = &config.providers.models["anthropic"];
        assert_eq!(entry.model.as_deref(), Some("claude-sonnet-4-5-20250929"));
        assert!(entry.api_key.is_none());
        assert!(entry.base_url.is_none());

        // Resolved through providers.
        assert!(
            config
                .providers
                .fallback_provider()
                .and_then(|e| e.api_key.as_deref())
                .is_none()
        );
        assert!(
            config
                .providers
                .fallback_provider()
                .and_then(|e| e.base_url.as_deref())
                .is_none()
        );
    }

    #[tokio::test]
    async fn quick_setup_model_override_persists_to_config_toml() {
        let _env_guard = env_lock().lock().await;
        let _workspace_env = EnvVarGuard::unset("ZEROCLAW_WORKSPACE");
        let _config_env = EnvVarGuard::unset("ZEROCLAW_CONFIG_DIR");
        let tmp = TempDir::new().unwrap();

        let config = Box::pin(run_quick_setup_with_home(
            Some("sk-issue946"),
            Some("openrouter"),
            Some("custom-model-946"),
            Some("sqlite"),
            false,
            tmp.path(),
        ))
        .await
        .unwrap();

        // V2 canonical locations.
        assert_eq!(config.providers.fallback.as_deref(), Some("openrouter"));
        assert_eq!(
            config.providers.models["openrouter"].model.as_deref(),
            Some("custom-model-946")
        );
        assert_eq!(
            config.providers.models["openrouter"].api_key.as_deref(),
            Some("sk-issue946")
        );

        // Resolved through providers.
        assert_eq!(config.providers.fallback.as_deref(), Some("openrouter"));
        assert_eq!(
            config
                .providers
                .fallback_provider()
                .and_then(|e| e.model.as_deref()),
            Some("custom-model-946")
        );

        // Serialized TOML uses V2 layout.
        let config_raw = tokio::fs::read_to_string(config.config_path).await.unwrap();
        assert!(config_raw.contains("[providers.models.openrouter]"));
        assert!(config_raw.contains("model = \"custom-model-946\""));
    }

    #[tokio::test]
    async fn quick_setup_without_model_uses_provider_default_model() {
        let _env_guard = env_lock().lock().await;
        let _workspace_env = EnvVarGuard::unset("ZEROCLAW_WORKSPACE");
        let _config_env = EnvVarGuard::unset("ZEROCLAW_CONFIG_DIR");
        let tmp = TempDir::new().unwrap();

        let config = Box::pin(run_quick_setup_with_home(
            Some("sk-issue946"),
            Some("anthropic"),
            None,
            Some("sqlite"),
            false,
            tmp.path(),
        ))
        .await
        .unwrap();

        let expected = default_model_for_provider("anthropic");
        assert_eq!(config.providers.fallback.as_deref(), Some("anthropic"));
        assert_eq!(
            config
                .providers
                .fallback_provider()
                .and_then(|e| e.model.as_deref()),
            Some(expected.as_str())
        );
    }

    #[tokio::test]
    async fn quick_setup_existing_config_requires_force_when_non_interactive() {
        let _env_guard = env_lock().lock().await;
        let _workspace_env = EnvVarGuard::unset("ZEROCLAW_WORKSPACE");
        let _config_env = EnvVarGuard::unset("ZEROCLAW_CONFIG_DIR");
        let tmp = TempDir::new().unwrap();
        let zeroclaw_dir = tmp.path().join(".zeroclaw");
        let config_path = zeroclaw_dir.join("config.toml");

        tokio::fs::create_dir_all(&zeroclaw_dir).await.unwrap();
        tokio::fs::write(&config_path, "default_provider = \"openrouter\"\n")
            .await
            .unwrap();

        let err = Box::pin(run_quick_setup_with_home(
            Some("sk-existing"),
            Some("openrouter"),
            Some("custom-model"),
            Some("sqlite"),
            false,
            tmp.path(),
        ))
        .await
        .expect_err("quick setup should refuse overwrite without --force");

        let err_text = err.to_string();
        assert!(err_text.contains("Refusing to overwrite existing config"));
        assert!(err_text.contains("--force"));
    }

    #[tokio::test]
    async fn quick_setup_existing_config_overwrites_with_force() {
        let _env_guard = env_lock().lock().await;
        let _workspace_env = EnvVarGuard::unset("ZEROCLAW_WORKSPACE");
        let _config_env = EnvVarGuard::unset("ZEROCLAW_CONFIG_DIR");
        let tmp = TempDir::new().unwrap();
        let zeroclaw_dir = tmp.path().join(".zeroclaw");
        let config_path = zeroclaw_dir.join("config.toml");

        tokio::fs::create_dir_all(&zeroclaw_dir).await.unwrap();
        tokio::fs::write(
            &config_path,
            "default_provider = \"anthropic\"\ndefault_model = \"stale-model\"\n",
        )
        .await
        .unwrap();

        let config = Box::pin(run_quick_setup_with_home(
            Some("sk-force"),
            Some("openrouter"),
            Some("custom-model-fresh"),
            Some("sqlite"),
            true,
            tmp.path(),
        ))
        .await
        .expect("quick setup should overwrite existing config with --force");

        assert_eq!(config.providers.fallback.as_deref(), Some("openrouter"));
        assert_eq!(
            config
                .providers
                .fallback_provider()
                .and_then(|e| e.model.as_deref()),
            Some("custom-model-fresh")
        );
        assert_eq!(
            config
                .providers
                .fallback_provider()
                .and_then(|e| e.api_key.as_deref()),
            Some("sk-force")
        );

        let config_raw = tokio::fs::read_to_string(config.config_path).await.unwrap();
        assert!(config_raw.contains("fallback = \"openrouter\""));
        assert!(config_raw.contains("model = \"custom-model-fresh\""));
    }

    #[tokio::test]
    async fn quick_setup_respects_zero_claw_workspace_env_layout() {
        let _env_guard = env_lock().lock().await;
        let tmp = TempDir::new().unwrap();
        let workspace_root = tmp.path().join("zeroclaw-data");
        let workspace_dir = workspace_root.join("workspace");
        let expected_config_path = workspace_root.join(".zeroclaw").join("config.toml");

        let _workspace_env = EnvVarGuard::set(
            "ZEROCLAW_WORKSPACE",
            workspace_dir.to_string_lossy().as_ref(),
        );
        let _config_env = EnvVarGuard::unset("ZEROCLAW_CONFIG_DIR");

        let config = Box::pin(run_quick_setup_with_home(
            Some("sk-env"),
            Some("openrouter"),
            Some("model-env"),
            Some("sqlite"),
            false,
            tmp.path(),
        ))
        .await
        .expect("quick setup should honor ZEROCLAW_WORKSPACE");

        assert_eq!(config.workspace_dir, workspace_dir);
        assert_eq!(config.config_path, expected_config_path);
    }

    #[test]
    fn homebrew_prefix_for_exe_detects_supported_layouts() {
        assert_eq!(
            homebrew_prefix_for_exe(Path::new("/opt/homebrew/bin/zeroclaw")),
            Some("/opt/homebrew")
        );
        assert_eq!(
            homebrew_prefix_for_exe(Path::new(
                "/opt/homebrew/Cellar/zeroclaw/0.5.0/bin/zeroclaw",
            )),
            Some("/opt/homebrew")
        );
        assert_eq!(
            homebrew_prefix_for_exe(Path::new("/usr/local/bin/zeroclaw")),
            Some("/usr/local")
        );
        assert_eq!(homebrew_prefix_for_exe(Path::new("/tmp/zeroclaw")), None);
    }

    #[test]
    fn quick_setup_homebrew_service_note_mentions_service_workspace() {
        let note = quick_setup_homebrew_service_note(
            Path::new("/Users/alix/.zeroclaw/config.toml"),
            Path::new("/Users/alix/.zeroclaw/workspace"),
            Path::new("/opt/homebrew/bin/zeroclaw"),
        )
        .expect("homebrew installs should emit a service workspace note");

        assert!(note.contains("/opt/homebrew/var/zeroclaw/workspace"));
        assert!(note.contains("/opt/homebrew/var/zeroclaw/config.toml"));
        assert!(note.contains("/Users/alix/.zeroclaw/config.toml"));
    }

    #[test]
    fn quick_setup_homebrew_service_note_skips_matching_service_layout() {
        let service_config = Path::new("/opt/homebrew/var/zeroclaw/config.toml");
        let service_workspace = Path::new("/opt/homebrew/var/zeroclaw/workspace");

        assert!(
            quick_setup_homebrew_service_note(
                service_config,
                service_workspace,
                Path::new("/opt/homebrew/bin/zeroclaw"),
            )
            .is_none()
        );
    }

    // ── scaffold_workspace: basic file creation ─────────────────

    #[tokio::test]
    async fn scaffold_creates_all_md_files() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext::default();
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let expected = [
            "IDENTITY.md",
            "AGENTS.md",
            "HEARTBEAT.md",
            "SOUL.md",
            "USER.md",
            "TOOLS.md",
            "BOOTSTRAP.md",
            "MEMORY.md",
        ];
        for f in &expected {
            assert!(tmp.path().join(f).exists(), "missing file: {f}");
        }
    }

    #[tokio::test]
    async fn scaffold_creates_all_subdirectories() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext::default();
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        for dir in &["sessions", "memory", "state", "cron", "skills"] {
            assert!(tmp.path().join(dir).is_dir(), "missing subdirectory: {dir}");
        }
    }

    // ── scaffold_workspace: personalization ─────────────────────

    #[tokio::test]
    async fn scaffold_bakes_user_name_into_files() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext {
            user_name: "Alice".into(),
            ..Default::default()
        };
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let user_md = tokio::fs::read_to_string(tmp.path().join("USER.md"))
            .await
            .unwrap();
        assert!(
            user_md.contains("**Name:** Alice"),
            "USER.md should contain user name"
        );

        let bootstrap = tokio::fs::read_to_string(tmp.path().join("BOOTSTRAP.md"))
            .await
            .unwrap();
        assert!(
            bootstrap.contains("**Alice**"),
            "BOOTSTRAP.md should contain user name"
        );
    }

    #[tokio::test]
    async fn scaffold_bakes_timezone_into_files() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext {
            timezone: "US/Pacific".into(),
            ..Default::default()
        };
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let user_md = tokio::fs::read_to_string(tmp.path().join("USER.md"))
            .await
            .unwrap();
        assert!(
            user_md.contains("**Timezone:** US/Pacific"),
            "USER.md should contain timezone"
        );

        let bootstrap = tokio::fs::read_to_string(tmp.path().join("BOOTSTRAP.md"))
            .await
            .unwrap();
        assert!(
            bootstrap.contains("US/Pacific"),
            "BOOTSTRAP.md should contain timezone"
        );
    }

    #[tokio::test]
    async fn scaffold_bakes_agent_name_into_files() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext {
            agent_name: "Crabby".into(),
            ..Default::default()
        };
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let identity = tokio::fs::read_to_string(tmp.path().join("IDENTITY.md"))
            .await
            .unwrap();
        assert!(
            identity.contains("**Name:** Crabby"),
            "IDENTITY.md should contain agent name"
        );

        let soul = tokio::fs::read_to_string(tmp.path().join("SOUL.md"))
            .await
            .unwrap();
        assert!(
            soul.contains("You are **Crabby**"),
            "SOUL.md should contain agent name"
        );

        let agents = tokio::fs::read_to_string(tmp.path().join("AGENTS.md"))
            .await
            .unwrap();
        assert!(
            agents.contains("Crabby Personal Assistant"),
            "AGENTS.md should contain agent name"
        );

        let heartbeat = tokio::fs::read_to_string(tmp.path().join("HEARTBEAT.md"))
            .await
            .unwrap();
        assert!(
            heartbeat.contains("Crabby"),
            "HEARTBEAT.md should contain agent name"
        );

        let bootstrap = tokio::fs::read_to_string(tmp.path().join("BOOTSTRAP.md"))
            .await
            .unwrap();
        assert!(
            bootstrap.contains("Introduce yourself as Crabby"),
            "BOOTSTRAP.md should contain agent name"
        );
    }

    #[tokio::test]
    async fn scaffold_bakes_communication_style() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext {
            communication_style: "Be technical and detailed.".into(),
            ..Default::default()
        };
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let soul = tokio::fs::read_to_string(tmp.path().join("SOUL.md"))
            .await
            .unwrap();
        assert!(
            soul.contains("Be technical and detailed."),
            "SOUL.md should contain communication style"
        );

        let user_md = tokio::fs::read_to_string(tmp.path().join("USER.md"))
            .await
            .unwrap();
        assert!(
            user_md.contains("Be technical and detailed."),
            "USER.md should contain communication style"
        );

        let bootstrap = tokio::fs::read_to_string(tmp.path().join("BOOTSTRAP.md"))
            .await
            .unwrap();
        assert!(
            bootstrap.contains("Be technical and detailed."),
            "BOOTSTRAP.md should contain communication style"
        );
    }

    // ── scaffold_workspace: defaults when context is empty ──────

    #[tokio::test]
    async fn scaffold_uses_defaults_for_empty_context() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext::default(); // all empty
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let identity = tokio::fs::read_to_string(tmp.path().join("IDENTITY.md"))
            .await
            .unwrap();
        assert!(
            identity.contains("**Name:** KylinBot"),
            "should default agent name to KylinBot"
        );

        let user_md = tokio::fs::read_to_string(tmp.path().join("USER.md"))
            .await
            .unwrap();
        assert!(
            user_md.contains("**Name:** User"),
            "should default user name to User"
        );
        assert!(
            user_md.contains("**Timezone:** UTC"),
            "should default timezone to UTC"
        );

        let soul = tokio::fs::read_to_string(tmp.path().join("SOUL.md"))
            .await
            .unwrap();
        assert!(
            soul.contains("Be warm, natural, and clear."),
            "should default communication style"
        );
    }

    // ── scaffold_workspace: skip existing files ─────────────────

    #[tokio::test]
    async fn scaffold_does_not_overwrite_existing_files() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext {
            user_name: "Bob".into(),
            ..Default::default()
        };

        // Pre-create SOUL.md with custom content
        let soul_path = tmp.path().join("SOUL.md");
        fs::write(&soul_path, "# My Custom Soul\nDo not overwrite me.")
            .await
            .unwrap();

        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        // SOUL.md should be untouched
        let soul = tokio::fs::read_to_string(&soul_path).await.unwrap();
        assert!(
            soul.contains("Do not overwrite me"),
            "existing files should not be overwritten"
        );
        assert!(
            !soul.contains("You're not a chatbot"),
            "should not contain scaffold content"
        );

        // But USER.md should be created fresh
        let user_md = tokio::fs::read_to_string(tmp.path().join("USER.md"))
            .await
            .unwrap();
        assert!(user_md.contains("**Name:** Bob"));
    }

    // ── scaffold_workspace: idempotent ──────────────────────────

    #[tokio::test]
    async fn scaffold_is_idempotent() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext {
            user_name: "Eve".into(),
            agent_name: "Claw".into(),
            ..Default::default()
        };

        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();
        let soul_v1 = tokio::fs::read_to_string(tmp.path().join("SOUL.md"))
            .await
            .unwrap();

        // Run again — should not change anything
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();
        let soul_v2 = tokio::fs::read_to_string(tmp.path().join("SOUL.md"))
            .await
            .unwrap();

        assert_eq!(soul_v1, soul_v2, "scaffold should be idempotent");
    }

    // ── scaffold_workspace: all files are non-empty ─────────────

    #[tokio::test]
    async fn scaffold_files_are_non_empty() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext::default();
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        for f in &[
            "IDENTITY.md",
            "AGENTS.md",
            "HEARTBEAT.md",
            "SOUL.md",
            "USER.md",
            "TOOLS.md",
            "BOOTSTRAP.md",
            "MEMORY.md",
        ] {
            let content = tokio::fs::read_to_string(tmp.path().join(f)).await.unwrap();
            assert!(!content.trim().is_empty(), "{f} should not be empty");
        }
    }

    // ── scaffold_workspace: AGENTS.md references on-demand memory

    #[tokio::test]
    async fn agents_md_references_on_demand_memory() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext::default();
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let agents = tokio::fs::read_to_string(tmp.path().join("AGENTS.md"))
            .await
            .unwrap();
        assert!(
            agents.contains("memory_recall"),
            "AGENTS.md should reference memory_recall for on-demand access"
        );
        assert!(
            agents.contains("on-demand"),
            "AGENTS.md should mention daily notes are on-demand"
        );
    }

    // ── scaffold_workspace: MEMORY.md warns about token cost ────

    #[tokio::test]
    async fn memory_md_warns_about_token_cost() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext::default();
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let memory = tokio::fs::read_to_string(tmp.path().join("MEMORY.md"))
            .await
            .unwrap();
        assert!(
            memory.contains("costs tokens"),
            "MEMORY.md should warn about token cost"
        );
        assert!(
            memory.contains("auto-injected"),
            "MEMORY.md should mention it's auto-injected"
        );
    }

    // ── scaffold_workspace: TOOLS.md lists memory_forget ────────

    #[tokio::test]
    async fn tools_md_lists_all_builtin_tools() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext::default();
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let tools = tokio::fs::read_to_string(tmp.path().join("TOOLS.md"))
            .await
            .unwrap();
        for tool in &[
            "shell",
            "file_read",
            "file_write",
            "memory_store",
            "memory_recall",
            "memory_forget",
        ] {
            assert!(
                tools.contains(tool),
                "TOOLS.md should list built-in tool: {tool}"
            );
        }
        assert!(
            tools.contains("Use when:"),
            "TOOLS.md should include 'Use when' guidance"
        );
        assert!(
            tools.contains("Don't use when:"),
            "TOOLS.md should include 'Don't use when' guidance"
        );
    }

    #[tokio::test]
    async fn soul_md_includes_emoji_awareness_guidance() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext::default();
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let soul = tokio::fs::read_to_string(tmp.path().join("SOUL.md"))
            .await
            .unwrap();
        assert!(
            soul.contains("Use emojis naturally (0-2 max"),
            "SOUL.md should include emoji usage guidance"
        );
        assert!(
            soul.contains("Match emoji density to the user"),
            "SOUL.md should include emoji-awareness guidance"
        );
    }

    // ── scaffold_workspace: special characters in names ─────────

    #[tokio::test]
    async fn scaffold_handles_special_characters_in_names() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext {
            user_name: "José María".into(),
            agent_name: "KylinBot-v2".into(),
            timezone: "Europe/Madrid".into(),
            communication_style: "Be direct.".into(),
        };
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        let user_md = tokio::fs::read_to_string(tmp.path().join("USER.md"))
            .await
            .unwrap();
        assert!(user_md.contains("José María"));

        let soul = tokio::fs::read_to_string(tmp.path().join("SOUL.md"))
            .await
            .unwrap();
        assert!(soul.contains("KylinBot-v2"));
    }

    // ── scaffold_workspace: full personalization round-trip ─────

    #[tokio::test]
    async fn scaffold_full_personalization() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext {
            user_name: "Argenis".into(),
            timezone: "US/Eastern".into(),
            agent_name: "Claw".into(),
            communication_style:
                "Be friendly, human, and conversational. Show warmth and empathy while staying efficient. Use natural contractions."
                    .into(),
        };
        scaffold_workspace(tmp.path(), &ctx, "sqlite")
            .await
            .unwrap();

        // Verify every file got personalized
        let identity = tokio::fs::read_to_string(tmp.path().join("IDENTITY.md"))
            .await
            .unwrap();
        assert!(identity.contains("**Name:** Claw"));

        let soul = tokio::fs::read_to_string(tmp.path().join("SOUL.md"))
            .await
            .unwrap();
        assert!(soul.contains("You are **Claw**"));
        assert!(soul.contains("Be friendly, human, and conversational"));

        let user_md = tokio::fs::read_to_string(tmp.path().join("USER.md"))
            .await
            .unwrap();
        assert!(user_md.contains("**Name:** Argenis"));
        assert!(user_md.contains("**Timezone:** US/Eastern"));
        assert!(user_md.contains("Be friendly, human, and conversational"));

        let agents = tokio::fs::read_to_string(tmp.path().join("AGENTS.md"))
            .await
            .unwrap();
        assert!(agents.contains("Claw Personal Assistant"));

        let bootstrap = tokio::fs::read_to_string(tmp.path().join("BOOTSTRAP.md"))
            .await
            .unwrap();
        assert!(bootstrap.contains("**Argenis**"));
        assert!(bootstrap.contains("US/Eastern"));
        assert!(bootstrap.contains("Introduce yourself as Claw"));

        let heartbeat = tokio::fs::read_to_string(tmp.path().join("HEARTBEAT.md"))
            .await
            .unwrap();
        assert!(heartbeat.contains("Claw"));
    }

    // ── scaffold_workspace: none backend skips MEMORY.md ────────

    #[tokio::test]
    async fn scaffold_none_backend_disables_memory_guidance_and_skips_memory_md() {
        let tmp = TempDir::new().unwrap();
        let ctx = ProjectContext::default();
        scaffold_workspace(tmp.path(), &ctx, "none").await.unwrap();

        assert!(
            !tmp.path().join("MEMORY.md").exists(),
            "MEMORY.md should not be created for none backend"
        );

        let agents = tokio::fs::read_to_string(tmp.path().join("AGENTS.md"))
            .await
            .unwrap();
        assert!(
            agents.contains("memory.backend = \"none\""),
            "AGENTS.md should note that memory backend is none"
        );
    }

    // ── model helper coverage ───────────────────────────────────

    #[test]
    fn default_model_for_provider_uses_latest_defaults() {
        assert_eq!(
            default_model_for_provider("openrouter"),
            "anthropic/claude-sonnet-4.6"
        );
        assert_eq!(default_model_for_provider("openai"), "gpt-5.2");
        assert_eq!(default_model_for_provider("openai-codex"), "gpt-5-codex");
        assert_eq!(default_model_for_provider("copilot"), "gpt-4o");
        assert_eq!(default_model_for_provider("github-copilot"), "gpt-4o");
        assert_eq!(
            default_model_for_provider("anthropic"),
            "claude-sonnet-4-5-20250929"
        );
        assert_eq!(default_model_for_provider("qwen"), "qwen-plus");
        assert_eq!(default_model_for_provider("qwen-intl"), "qwen-plus");
        assert_eq!(default_model_for_provider("qwen-code"), "qwen3-coder-plus");
        assert_eq!(default_model_for_provider("glm-cn"), "glm-5");
        assert_eq!(default_model_for_provider("minimax-cn"), "MiniMax-M2.7");
        assert_eq!(default_model_for_provider("zai-cn"), "glm-5");
        assert_eq!(default_model_for_provider("gemini"), "gemini-2.5-pro");
        assert_eq!(default_model_for_provider("google"), "gemini-2.5-pro");
        assert_eq!(default_model_for_provider("kimi-code"), "kimi-for-coding");
        assert_eq!(
            default_model_for_provider("bedrock"),
            "anthropic.claude-sonnet-4-5-20250929-v1:0"
        );
        assert_eq!(
            default_model_for_provider("google-gemini"),
            "gemini-2.5-pro"
        );
        assert_eq!(default_model_for_provider("venice"), "zai-org-glm-5");
        assert_eq!(default_model_for_provider("moonshot"), "kimi-k2.5");
        assert_eq!(
            default_model_for_provider("nvidia"),
            "meta/llama-3.3-70b-instruct"
        );
        assert_eq!(
            default_model_for_provider("nvidia-nim"),
            "meta/llama-3.3-70b-instruct"
        );
        assert_eq!(
            default_model_for_provider("llamacpp"),
            "ggml-org/gpt-oss-20b-GGUF"
        );
        assert_eq!(default_model_for_provider("sglang"), "default");
        assert_eq!(default_model_for_provider("vllm"), "default");
        assert_eq!(
            default_model_for_provider("astrai"),
            "anthropic/claude-sonnet-4.6"
        );
        assert_eq!(
            default_model_for_provider("avian"),
            "deepseek/deepseek-v3.2"
        );
    }

    #[test]
    fn canonical_provider_name_normalizes_regional_aliases() {
        assert_eq!(canonical_provider_name("qwen-intl"), "qwen");
        assert_eq!(canonical_provider_name("dashscope-us"), "qwen");
        assert_eq!(canonical_provider_name("qwen-code"), "qwen-code");
        assert_eq!(canonical_provider_name("qwen-oauth"), "qwen-code");
        assert_eq!(canonical_provider_name("codex"), "openai-codex");
        assert_eq!(canonical_provider_name("openai_codex"), "openai-codex");
        assert_eq!(canonical_provider_name("moonshot-intl"), "moonshot");
        assert_eq!(canonical_provider_name("kimi-cn"), "moonshot");
        assert_eq!(canonical_provider_name("kimi_coding"), "kimi-code");
        assert_eq!(canonical_provider_name("kimi_for_coding"), "kimi-code");
        assert_eq!(canonical_provider_name("glm-cn"), "glm");
        assert_eq!(canonical_provider_name("bigmodel"), "glm");
        assert_eq!(canonical_provider_name("minimax-cn"), "minimax");
        assert_eq!(canonical_provider_name("zai-cn"), "zai");
        assert_eq!(canonical_provider_name("z.ai-global"), "zai");
        assert_eq!(canonical_provider_name("nvidia-nim"), "nvidia");
        assert_eq!(canonical_provider_name("aws-bedrock"), "bedrock");
        assert_eq!(canonical_provider_name("build.nvidia.com"), "nvidia");
        assert_eq!(canonical_provider_name("llama.cpp"), "llamacpp");
        assert_eq!(canonical_provider_name("github-copilot"), "copilot");
        assert_eq!(canonical_provider_name("copilot"), "copilot");
    }

    #[test]
    fn curated_models_for_openai_include_latest_choices() {
        let ids: Vec<String> = curated_models_for_provider("openai")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"gpt-5.2".to_string()));
        assert!(ids.contains(&"gpt-5-mini".to_string()));
    }

    #[test]
    fn curated_models_for_glm_removes_deprecated_flash_plus_aliases() {
        let ids: Vec<String> = curated_models_for_provider("glm")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"glm-5".to_string()));
        assert!(ids.contains(&"glm-4.7".to_string()));
        assert!(ids.contains(&"glm-4.5-air".to_string()));
        assert!(!ids.contains(&"glm-4-plus".to_string()));
        assert!(!ids.contains(&"glm-4-flash".to_string()));
    }

    #[test]
    fn curated_models_for_openai_codex_include_codex_family() {
        let ids: Vec<String> = curated_models_for_provider("openai-codex")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"gpt-5-codex".to_string()));
        assert!(ids.contains(&"gpt-5.2-codex".to_string()));
    }

    #[test]
    fn curated_models_for_copilot_include_expected_models() {
        let ids: Vec<String> = curated_models_for_provider("copilot")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"gpt-4o".to_string()));
        assert!(ids.contains(&"gpt-4.1".to_string()));
        assert!(ids.contains(&"gpt-5-mini".to_string()));
        assert!(ids.contains(&"claude-sonnet-4.6".to_string()));
        assert!(ids.contains(&"gpt-5.3-codex".to_string()));
        assert!(ids.contains(&"claude-opus-4.6".to_string()));
    }

    #[test]
    fn curated_models_for_openrouter_use_valid_anthropic_id() {
        let ids: Vec<String> = curated_models_for_provider("openrouter")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"anthropic/claude-sonnet-4.6".to_string()));
    }

    #[test]
    fn curated_models_for_bedrock_include_verified_model_ids() {
        let ids: Vec<String> = curated_models_for_provider("bedrock")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"anthropic.claude-sonnet-4-6".to_string()));
        assert!(ids.contains(&"anthropic.claude-opus-4-6-v1".to_string()));
        assert!(ids.contains(&"anthropic.claude-haiku-4-5-20251001-v1:0".to_string()));
        assert!(ids.contains(&"anthropic.claude-sonnet-4-5-20250929-v1:0".to_string()));
    }

    #[test]
    fn curated_models_for_moonshot_drop_deprecated_aliases() {
        let ids: Vec<String> = curated_models_for_provider("moonshot")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"kimi-k2.5".to_string()));
        assert!(ids.contains(&"kimi-k2-thinking".to_string()));
        assert!(!ids.contains(&"kimi-latest".to_string()));
        assert!(!ids.contains(&"kimi-thinking-preview".to_string()));
    }

    #[test]
    fn allows_unauthenticated_model_fetch_for_public_catalogs() {
        assert!(allows_unauthenticated_model_fetch("openrouter"));
        assert!(allows_unauthenticated_model_fetch("venice"));
        assert!(allows_unauthenticated_model_fetch("nvidia"));
        assert!(allows_unauthenticated_model_fetch("nvidia-nim"));
        assert!(allows_unauthenticated_model_fetch("build.nvidia.com"));
        assert!(allows_unauthenticated_model_fetch("astrai"));
        assert!(allows_unauthenticated_model_fetch("ollama"));
        assert!(allows_unauthenticated_model_fetch("llamacpp"));
        assert!(allows_unauthenticated_model_fetch("llama.cpp"));
        assert!(allows_unauthenticated_model_fetch("sglang"));
        assert!(allows_unauthenticated_model_fetch("vllm"));
        assert!(!allows_unauthenticated_model_fetch("openai"));
        assert!(!allows_unauthenticated_model_fetch("deepseek"));
    }

    #[test]
    fn curated_models_for_kimi_code_include_official_agent_model() {
        let ids: Vec<String> = curated_models_for_provider("kimi-code")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"kimi-for-coding".to_string()));
        assert!(ids.contains(&"kimi-k2.5".to_string()));
    }

    #[test]
    fn curated_models_for_qwen_code_include_coding_plan_models() {
        let ids: Vec<String> = curated_models_for_provider("qwen-code")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"qwen3-coder-plus".to_string()));
        assert!(ids.contains(&"qwen3.5-plus".to_string()));
        assert!(ids.contains(&"qwen3-max-2026-01-23".to_string()));
    }

    #[test]
    fn curated_models_for_avian_include_expected_catalog() {
        let ids: Vec<String> = curated_models_for_provider("avian")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"deepseek/deepseek-v3.2".to_string()));
        assert!(ids.contains(&"moonshotai/kimi-k2.5".to_string()));
        assert!(ids.contains(&"z-ai/glm-5".to_string()));
        assert!(ids.contains(&"minimax/minimax-m2.5".to_string()));
    }

    #[test]
    fn supports_live_model_fetch_for_supported_and_unsupported_providers() {
        assert!(supports_live_model_fetch("openai"));
        assert!(supports_live_model_fetch("anthropic"));
        assert!(supports_live_model_fetch("gemini"));
        assert!(supports_live_model_fetch("google"));
        assert!(supports_live_model_fetch("grok"));
        assert!(supports_live_model_fetch("together"));
        assert!(supports_live_model_fetch("nvidia"));
        assert!(supports_live_model_fetch("nvidia-nim"));
        assert!(supports_live_model_fetch("build.nvidia.com"));
        assert!(supports_live_model_fetch("ollama"));
        assert!(supports_live_model_fetch("llamacpp"));
        assert!(supports_live_model_fetch("llama.cpp"));
        assert!(supports_live_model_fetch("sglang"));
        assert!(supports_live_model_fetch("vllm"));
        assert!(supports_live_model_fetch("astrai"));
        assert!(supports_live_model_fetch("avian"));
        assert!(supports_live_model_fetch("venice"));
        assert!(supports_live_model_fetch("glm-cn"));
        assert!(supports_live_model_fetch("qwen-intl"));
        assert!(!supports_live_model_fetch("minimax-cn"));
        assert!(!supports_live_model_fetch("unknown-provider"));
    }

    #[test]
    fn curated_models_provider_aliases_share_same_catalog() {
        assert_eq!(
            curated_models_for_provider("xai"),
            curated_models_for_provider("grok")
        );
        assert_eq!(
            curated_models_for_provider("together-ai"),
            curated_models_for_provider("together")
        );
        assert_eq!(
            curated_models_for_provider("gemini"),
            curated_models_for_provider("google")
        );
        assert_eq!(
            curated_models_for_provider("gemini"),
            curated_models_for_provider("google-gemini")
        );
        assert_eq!(
            curated_models_for_provider("qwen"),
            curated_models_for_provider("qwen-intl")
        );
        assert_eq!(
            curated_models_for_provider("qwen"),
            curated_models_for_provider("dashscope-us")
        );
        assert_eq!(
            curated_models_for_provider("minimax"),
            curated_models_for_provider("minimax-cn")
        );
        assert_eq!(
            curated_models_for_provider("zai"),
            curated_models_for_provider("zai-cn")
        );
        assert_eq!(
            curated_models_for_provider("nvidia"),
            curated_models_for_provider("nvidia-nim")
        );
        assert_eq!(
            curated_models_for_provider("nvidia"),
            curated_models_for_provider("build.nvidia.com")
        );
        assert_eq!(
            curated_models_for_provider("llamacpp"),
            curated_models_for_provider("llama.cpp")
        );
        assert_eq!(
            curated_models_for_provider("bedrock"),
            curated_models_for_provider("aws-bedrock")
        );
    }

    #[test]
    fn curated_models_for_nvidia_include_nim_catalog_entries() {
        let ids: Vec<String> = curated_models_for_provider("nvidia")
            .into_iter()
            .map(|(id, _)| id)
            .collect();

        assert!(ids.contains(&"meta/llama-3.3-70b-instruct".to_string()));
        assert!(ids.contains(&"deepseek-ai/deepseek-v3.2".to_string()));
        assert!(ids.contains(&"nvidia/llama-3.3-nemotron-super-49b-v1.5".to_string()));
    }

    #[test]
    fn models_endpoint_for_provider_handles_region_aliases() {
        assert_eq!(
            models_endpoint_for_provider("glm-cn"),
            Some("https://open.bigmodel.cn/api/paas/v4/models")
        );
        assert_eq!(
            models_endpoint_for_provider("zai-cn"),
            Some("https://open.bigmodel.cn/api/coding/paas/v4/models")
        );
        assert_eq!(
            models_endpoint_for_provider("qwen-intl"),
            Some("https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models")
        );
    }

    #[test]
    fn models_endpoint_for_provider_supports_additional_openai_compatible_providers() {
        assert_eq!(
            models_endpoint_for_provider("openai-codex"),
            Some("https://api.openai.com/v1/models")
        );
        assert_eq!(
            models_endpoint_for_provider("venice"),
            Some("https://api.venice.ai/api/v1/models")
        );
        assert_eq!(
            models_endpoint_for_provider("cohere"),
            Some("https://api.cohere.com/compatibility/v1/models")
        );
        assert_eq!(
            models_endpoint_for_provider("moonshot"),
            Some("https://api.moonshot.ai/v1/models")
        );
        assert_eq!(
            models_endpoint_for_provider("llamacpp"),
            Some("http://localhost:8080/v1/models")
        );
        assert_eq!(
            models_endpoint_for_provider("llama.cpp"),
            Some("http://localhost:8080/v1/models")
        );
        assert_eq!(
            models_endpoint_for_provider("sglang"),
            Some("http://localhost:30000/v1/models")
        );
        assert_eq!(
            models_endpoint_for_provider("vllm"),
            Some("http://localhost:8000/v1/models")
        );
        assert_eq!(
            models_endpoint_for_provider("avian"),
            Some("https://api.avian.io/v1/models")
        );
        assert_eq!(models_endpoint_for_provider("perplexity"), None);
        assert_eq!(models_endpoint_for_provider("unknown-provider"), None);
    }

    #[test]
    fn resolve_live_models_endpoint_prefers_llamacpp_custom_url() {
        assert_eq!(
            resolve_live_models_endpoint("llamacpp", Some("http://127.0.0.1:8033/v1")),
            Some("http://127.0.0.1:8033/v1/models".to_string())
        );
        assert_eq!(
            resolve_live_models_endpoint("llama.cpp", Some("http://127.0.0.1:8033/v1/")),
            Some("http://127.0.0.1:8033/v1/models".to_string())
        );
        assert_eq!(
            resolve_live_models_endpoint("llamacpp", Some("http://127.0.0.1:8033/v1/models")),
            Some("http://127.0.0.1:8033/v1/models".to_string())
        );
    }

    #[test]
    fn resolve_live_models_endpoint_falls_back_to_provider_defaults() {
        assert_eq!(
            resolve_live_models_endpoint("llamacpp", None),
            Some("http://localhost:8080/v1/models".to_string())
        );
        assert_eq!(
            resolve_live_models_endpoint("sglang", None),
            Some("http://localhost:30000/v1/models".to_string())
        );
        assert_eq!(
            resolve_live_models_endpoint("vllm", None),
            Some("http://localhost:8000/v1/models".to_string())
        );
        assert_eq!(
            resolve_live_models_endpoint("venice", Some("http://localhost:9999/v1")),
            Some("https://api.venice.ai/api/v1/models".to_string())
        );
        assert_eq!(resolve_live_models_endpoint("unknown-provider", None), None);
    }

    #[test]
    fn resolve_live_models_endpoint_supports_custom_provider_urls() {
        assert_eq!(
            resolve_live_models_endpoint("custom:https://proxy.example.com/v1", None),
            Some("https://proxy.example.com/v1/models".to_string())
        );
        assert_eq!(
            resolve_live_models_endpoint("custom:https://proxy.example.com/v1/models", None),
            Some("https://proxy.example.com/v1/models".to_string())
        );
    }

    #[test]
    fn normalize_ollama_endpoint_url_strips_api_suffix_and_trailing_slash() {
        assert_eq!(
            normalize_ollama_endpoint_url(" https://ollama.com/api/ "),
            "https://ollama.com".to_string()
        );
        assert_eq!(
            normalize_ollama_endpoint_url("https://ollama.com/"),
            "https://ollama.com".to_string()
        );
        assert_eq!(normalize_ollama_endpoint_url(""), "");
    }

    #[test]
    fn normalize_ollama_endpoint_url_prevents_double_api_path() {
        // Verifies that URLs ending with /api are normalized to prevent
        // /api/api/tags when appending /api/tags for model fetching
        let base_with_api = "http://localhost:11434/api";
        let normalized = normalize_ollama_endpoint_url(base_with_api);
        assert_eq!(normalized, "http://localhost:11434");

        // Constructing the tags URL should now be correct
        let tags_url = format!("{normalized}/api/tags");
        assert_eq!(tags_url, "http://localhost:11434/api/tags");
        assert!(!tags_url.contains("/api/api/"));
    }

    #[test]
    fn ollama_uses_remote_endpoint_distinguishes_local_and_remote_urls() {
        assert!(!ollama_uses_remote_endpoint(None));
        assert!(!ollama_uses_remote_endpoint(Some("http://localhost:11434")));
        assert!(!ollama_uses_remote_endpoint(Some(
            "http://127.0.0.1:11434/api"
        )));
        assert!(ollama_uses_remote_endpoint(Some("https://ollama.com")));
        assert!(ollama_uses_remote_endpoint(Some("https://ollama.com/api")));
    }

    #[test]
    fn ollama_endpoint_is_local_recognizes_docker_internal_host() {
        // Standard localhost variants
        assert!(ollama_endpoint_is_local("http://localhost:11434"));
        assert!(ollama_endpoint_is_local("http://127.0.0.1:11434"));
        assert!(ollama_endpoint_is_local("http://0.0.0.0:11434"));
        assert!(ollama_endpoint_is_local("http://[::1]:11434"));

        // Docker internal hostname (container-to-host communication)
        assert!(ollama_endpoint_is_local(
            "http://host.docker.internal:11434"
        ));
        assert!(ollama_endpoint_is_local(
            "http://HOST.DOCKER.INTERNAL:11434"
        ));

        // Remote endpoints should not be considered local
        assert!(!ollama_endpoint_is_local("https://ollama.example.com"));
        assert!(!ollama_endpoint_is_local("http://192.168.1.100:11434"));
    }

    #[test]
    fn ollama_uses_remote_endpoint_treats_docker_internal_as_local() {
        assert!(!ollama_uses_remote_endpoint(Some(
            "http://host.docker.internal:11434"
        )));
        assert!(!ollama_uses_remote_endpoint(Some(
            "http://host.docker.internal:11434/api"
        )));
    }

    #[test]
    fn default_local_url_builds_correct_format() {
        // This test verifies the URL format without depending on container detection
        // default_local_url returns Option<String>, use unwrap since not in K8s during tests
        if let Some(url) = default_local_url(11434, "/api/tags") {
            assert!(url.starts_with("http://"));
            assert!(url.contains(":11434"));
            assert!(url.ends_with("/api/tags"));
        }
        // Also test the fallback version
        let url = default_local_url_or_localhost(11434, "/api/tags");
        assert!(url.starts_with("http://"));
        assert!(url.contains(":11434"));
        assert!(url.ends_with("/api/tags"));
    }

    #[test]
    fn default_local_url_handles_empty_path() {
        let url = default_local_url_or_localhost(8080, "");
        assert!(url.ends_with(":8080"));
    }

    #[test]
    fn ollama_endpoint_is_local_recognizes_podman_host() {
        // Podman uses host.containers.internal
        assert!(ollama_endpoint_is_local(
            "http://host.containers.internal:11434"
        ));
        assert!(ollama_endpoint_is_local(
            "http://HOST.CONTAINERS.INTERNAL:11434"
        ));
    }

    #[test]
    fn ollama_endpoint_is_local_recognizes_k8s_service_names() {
        // Simple service name (no dots) - HTTP only
        assert!(ollama_endpoint_is_local("http://ollama:11434"));
        // Kubernetes DNS names - HTTP only
        assert!(ollama_endpoint_is_local("http://ollama.default.svc:11434"));
        assert!(ollama_endpoint_is_local(
            "http://ollama.default.svc.cluster.local:11434"
        ));
        // .local and .internal suffixes - HTTP only
        assert!(ollama_endpoint_is_local("http://ollama.local:11434"));
        assert!(ollama_endpoint_is_local("http://myservice.internal:11434"));
        // Real remote endpoints should still be detected as remote
        assert!(!ollama_endpoint_is_local(
            "https://ollama.example.com:11434"
        ));
        assert!(!ollama_endpoint_is_local("https://api.ollama.ai"));
        // HTTPS with internal-looking names should be treated as remote
        // (preserves user's explicit "remote Ollama" choice)
        assert!(!ollama_endpoint_is_local("https://ollama:11434"));
        assert!(!ollama_endpoint_is_local("https://ollama.internal:11434"));
        assert!(!ollama_endpoint_is_local("https://proxy.local:11434"));
    }

    #[test]
    fn detect_container_runtime_from_inputs_detects_unknown_container_env() {
        // Unknown container env var value should return Unknown, not Docker
        let inputs = ContainerRuntimeInputs {
            container_env_var: Some("lxc".to_string()),
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Unknown
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_detects_kubernetes_env() {
        let inputs = ContainerRuntimeInputs {
            kubernetes_service_host_set: true,
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Kubernetes
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_detects_kubernetes_cgroup() {
        let inputs = ContainerRuntimeInputs {
            cgroup_contents: Some("12:cpuset:/kubepods/burstable/pod123".to_string()),
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Kubernetes
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_detects_docker_cgroup() {
        let inputs = ContainerRuntimeInputs {
            cgroup_contents: Some("12:cpuset:/docker/abc123".to_string()),
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Docker
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_detects_containerd_cgroup() {
        let inputs = ContainerRuntimeInputs {
            cgroup_contents: Some("0::/system.slice/containerd.service".to_string()),
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Containerd
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_detects_podman_cgroup() {
        let inputs = ContainerRuntimeInputs {
            cgroup_contents: Some("0::/user.slice/user-1000.slice/podman-abc123".to_string()),
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Podman
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_detects_dockerenv_file() {
        let inputs = ContainerRuntimeInputs {
            dockerenv_exists: true,
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Docker
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_detects_podman_containerenv() {
        let inputs = ContainerRuntimeInputs {
            containerenv_exists: true,
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Podman
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_detects_docker_env_var() {
        // container env var containing "docker" should return Docker
        let inputs = ContainerRuntimeInputs {
            container_env_var: Some("docker".to_string()),
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Docker
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_detects_podman_env_var() {
        let inputs = ContainerRuntimeInputs {
            container_env_var: Some("podman".to_string()),
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Podman
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_returns_none_when_no_signals() {
        let inputs = ContainerRuntimeInputs::default();
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::None
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_kubernetes_takes_priority() {
        // Kubernetes env var should take precedence over Docker/Podman markers
        let inputs = ContainerRuntimeInputs {
            kubernetes_service_host_set: true,
            dockerenv_exists: true,
            containerenv_exists: true,
            cgroup_contents: Some("docker".to_string()),
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Kubernetes
        ));
    }

    #[test]
    fn detect_container_runtime_from_inputs_cgroup_kubepods_takes_priority_over_docker() {
        // kubepods in cgroup should be detected as Kubernetes, not Docker
        let inputs = ContainerRuntimeInputs {
            cgroup_contents: Some("0::/kubepods/burstable/pod123/docker-abc".to_string()),
            dockerenv_exists: true,
            ..Default::default()
        };
        assert!(matches!(
            detect_container_runtime_from_inputs(&inputs),
            ContainerRuntime::Kubernetes
        ));
    }

    #[test]
    fn resolve_live_models_endpoint_prefers_vllm_custom_url() {
        assert_eq!(
            resolve_live_models_endpoint("vllm", Some("http://127.0.0.1:9000/v1")),
            Some("http://127.0.0.1:9000/v1/models".to_string())
        );
        assert_eq!(
            resolve_live_models_endpoint("vllm", Some("http://127.0.0.1:9000/v1/models")),
            Some("http://127.0.0.1:9000/v1/models".to_string())
        );
    }

    #[test]
    fn parse_openai_model_ids_supports_data_array_payload() {
        let payload = json!({
            "data": [
                {"id": "  gpt-5.1  "},
                {"id": "gpt-5-mini"},
                {"id": "gpt-5.1"},
                {"id": ""}
            ]
        });

        let ids = parse_openai_compatible_model_ids(&payload);
        assert_eq!(ids, vec!["gpt-5-mini".to_string(), "gpt-5.1".to_string()]);
    }

    #[test]
    fn parse_openai_model_ids_supports_root_array_payload() {
        let payload = json!([
            {"id": "alpha"},
            {"id": "beta"},
            {"id": "alpha"}
        ]);

        let ids = parse_openai_compatible_model_ids(&payload);
        assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]);
    }

    #[test]
    fn normalize_model_ids_deduplicates_case_insensitively() {
        let ids = normalize_model_ids(vec![
            "GPT-5".to_string(),
            "gpt-5".to_string(),
            "gpt-5-mini".to_string(),
            " GPT-5-MINI ".to_string(),
        ]);
        assert_eq!(ids, vec!["GPT-5".to_string(), "gpt-5-mini".to_string()]);
    }

    #[test]
    fn parse_gemini_model_ids_filters_for_generate_content() {
        let payload = json!({
            "models": [
                {
                    "name": "models/gemini-2.5-pro",
                    "supportedGenerationMethods": ["generateContent", "countTokens"]
                },
                {
                    "name": "models/text-embedding-004",
                    "supportedGenerationMethods": ["embedContent"]
                },
                {
                    "name": "models/gemini-2.5-flash",
                    "supportedGenerationMethods": ["generateContent"]
                }
            ]
        });

        let ids = parse_gemini_model_ids(&payload);
        assert_eq!(
            ids,
            vec!["gemini-2.5-flash".to_string(), "gemini-2.5-pro".to_string()]
        );
    }

    #[test]
    fn parse_ollama_model_ids_extracts_and_deduplicates_names() {
        let payload = json!({
            "models": [
                {"name": "llama3.2:latest"},
                {"name": "mistral:latest"},
                {"name": "llama3.2:latest"}
            ]
        });

        let ids = parse_ollama_model_ids(&payload);
        assert_eq!(
            ids,
            vec!["llama3.2:latest".to_string(), "mistral:latest".to_string()]
        );
    }

    #[tokio::test]
    async fn model_cache_round_trip_returns_fresh_entry() {
        let tmp = TempDir::new().unwrap();
        let models = vec!["gpt-5.1".to_string(), "gpt-5-mini".to_string()];

        cache_live_models_for_provider(tmp.path(), "openai", &models)
            .await
            .unwrap();

        let cached = load_cached_models_for_provider(tmp.path(), "openai", MODEL_CACHE_TTL_SECS)
            .await
            .unwrap();
        let cached = cached.expect("expected fresh cached models");

        assert_eq!(cached.models.len(), 2);
        assert!(cached.models.contains(&"gpt-5.1".to_string()));
        assert!(cached.models.contains(&"gpt-5-mini".to_string()));
    }

    #[tokio::test]
    async fn model_cache_ttl_filters_stale_entries() {
        let tmp = TempDir::new().unwrap();
        let stale = ModelCacheState {
            entries: vec![ModelCacheEntry {
                provider: "openai".to_string(),
                fetched_at_unix: now_unix_secs().saturating_sub(MODEL_CACHE_TTL_SECS + 120),
                models: vec!["gpt-5.1".to_string()],
            }],
        };

        save_model_cache_state(tmp.path(), &stale).await.unwrap();

        let fresh = load_cached_models_for_provider(tmp.path(), "openai", MODEL_CACHE_TTL_SECS)
            .await
            .unwrap();
        assert!(fresh.is_none());

        let stale_any = load_any_cached_models_for_provider(tmp.path(), "openai")
            .await
            .unwrap();
        assert!(stale_any.is_some());
    }

    #[tokio::test]
    async fn run_models_refresh_uses_fresh_cache_without_network() {
        let tmp = TempDir::new().unwrap();

        cache_live_models_for_provider(tmp.path(), "openai", &["gpt-5.1".to_string()])
            .await
            .unwrap();

        let mut config = Config {
            workspace_dir: tmp.path().to_path_buf(),
            ..Default::default()
        };
        config.providers.fallback = Some("openai".to_string());

        run_models_refresh(&config, None, false).await.unwrap();
    }

    #[tokio::test]
    async fn run_models_refresh_rejects_unsupported_provider() {
        let tmp = TempDir::new().unwrap();

        let mut config = Config {
            workspace_dir: tmp.path().to_path_buf(),
            ..Default::default()
        };
        // Use a non-provider channel key to keep this test deterministic and offline.
        config.providers.fallback = Some("imessage".to_string());

        let err = run_models_refresh(&config, None, true).await.unwrap_err();
        assert!(
            err.to_string()
                .contains("does not support live model discovery")
        );
    }

    // ── provider_env_var ────────────────────────────────────────

    #[test]
    fn provider_env_var_known_providers() {
        assert_eq!(provider_env_var("openrouter"), "OPENROUTER_API_KEY");
        assert_eq!(provider_env_var("anthropic"), "ANTHROPIC_API_KEY");
        assert_eq!(provider_env_var("openai-codex"), "OPENAI_API_KEY");
        assert_eq!(provider_env_var("openai"), "OPENAI_API_KEY");
        assert_eq!(provider_env_var("copilot"), "GITHUB_TOKEN");
        assert_eq!(provider_env_var("github-copilot"), "GITHUB_TOKEN"); // alias
        assert_eq!(provider_env_var("ollama"), "OLLAMA_API_KEY");
        assert_eq!(provider_env_var("llamacpp"), "LLAMACPP_API_KEY");
        assert_eq!(provider_env_var("llama.cpp"), "LLAMACPP_API_KEY");
        assert_eq!(provider_env_var("sglang"), "SGLANG_API_KEY");
        assert_eq!(provider_env_var("vllm"), "VLLM_API_KEY");
        assert_eq!(provider_env_var("xai"), "XAI_API_KEY");
        assert_eq!(provider_env_var("grok"), "XAI_API_KEY"); // alias
        assert_eq!(provider_env_var("together"), "TOGETHER_API_KEY"); // alias
        assert_eq!(provider_env_var("together-ai"), "TOGETHER_API_KEY");
        assert_eq!(provider_env_var("google"), "GEMINI_API_KEY"); // alias
        assert_eq!(provider_env_var("google-gemini"), "GEMINI_API_KEY"); // alias
        assert_eq!(provider_env_var("gemini"), "GEMINI_API_KEY");
        assert_eq!(provider_env_var("qwen"), "DASHSCOPE_API_KEY");
        assert_eq!(provider_env_var("qwen-intl"), "DASHSCOPE_API_KEY");
        assert_eq!(provider_env_var("dashscope-us"), "DASHSCOPE_API_KEY");
        assert_eq!(provider_env_var("qwen-code"), "QWEN_OAUTH_TOKEN");
        assert_eq!(provider_env_var("qwen-oauth"), "QWEN_OAUTH_TOKEN");
        assert_eq!(provider_env_var("glm-cn"), "GLM_API_KEY");
        assert_eq!(provider_env_var("minimax-cn"), "MINIMAX_API_KEY");
        assert_eq!(provider_env_var("kimi-code"), "KIMI_CODE_API_KEY");
        assert_eq!(provider_env_var("kimi_coding"), "KIMI_CODE_API_KEY");
        assert_eq!(provider_env_var("kimi_for_coding"), "KIMI_CODE_API_KEY");
        assert_eq!(provider_env_var("minimax-oauth"), "MINIMAX_API_KEY");
        assert_eq!(provider_env_var("minimax-oauth-cn"), "MINIMAX_API_KEY");
        assert_eq!(provider_env_var("moonshot-intl"), "MOONSHOT_API_KEY");
        assert_eq!(provider_env_var("zai-cn"), "ZAI_API_KEY");
        assert_eq!(provider_env_var("nvidia"), "NVIDIA_API_KEY");
        assert_eq!(provider_env_var("nvidia-nim"), "NVIDIA_API_KEY"); // alias
        assert_eq!(provider_env_var("build.nvidia.com"), "NVIDIA_API_KEY"); // alias
        assert_eq!(provider_env_var("astrai"), "ASTRAI_API_KEY");
        assert_eq!(provider_env_var("opencode-go"), "OPENCODE_GO_API_KEY");
        assert_eq!(provider_env_var("avian"), "AVIAN_API_KEY");
    }

    #[test]
    fn provider_supports_keyless_local_usage_for_local_providers() {
        assert!(provider_supports_keyless_local_usage("ollama"));
        assert!(provider_supports_keyless_local_usage("llamacpp"));
        assert!(provider_supports_keyless_local_usage("llama.cpp"));
        assert!(provider_supports_keyless_local_usage("sglang"));
        assert!(provider_supports_keyless_local_usage("vllm"));
        assert!(!provider_supports_keyless_local_usage("openai"));
    }

    #[test]
    fn provider_supports_device_flow_copilot() {
        assert!(provider_supports_device_flow("copilot"));
        assert!(provider_supports_device_flow("github-copilot"));
        assert!(provider_supports_device_flow("gemini"));
        assert!(provider_supports_device_flow("openai-codex"));
        assert!(!provider_supports_device_flow("openai"));
        assert!(!provider_supports_device_flow("openrouter"));
    }

    #[test]
    fn local_provider_choices_include_sglang() {
        let choices = local_provider_choices();
        assert!(choices.iter().any(|(provider, _)| *provider == "sglang"));
    }

    #[test]
    fn provider_env_var_unknown_falls_back() {
        assert_eq!(provider_env_var("some-new-provider"), "API_KEY");
    }

    #[test]
    fn backend_key_from_choice_maps_supported_backends() {
        assert_eq!(backend_key_from_choice(0), "sqlite");
        assert_eq!(backend_key_from_choice(1), "lucid");
        assert_eq!(backend_key_from_choice(2), "markdown");
        assert_eq!(backend_key_from_choice(3), "none");
        assert_eq!(backend_key_from_choice(999), "sqlite");
    }

    #[test]
    fn memory_backend_profile_marks_lucid_as_optional_sqlite_backed() {
        let lucid = memory_backend_profile("lucid");
        assert!(lucid.auto_save_default);
        assert!(lucid.uses_sqlite_hygiene);
        assert!(lucid.sqlite_based);
        assert!(lucid.optional_dependency);

        let markdown = memory_backend_profile("markdown");
        assert!(markdown.auto_save_default);
        assert!(!markdown.uses_sqlite_hygiene);

        let none = memory_backend_profile("none");
        assert!(!none.auto_save_default);
        assert!(!none.uses_sqlite_hygiene);

        let custom = memory_backend_profile("custom-memory");
        assert!(custom.auto_save_default);
        assert!(!custom.uses_sqlite_hygiene);
    }

    #[test]
    fn memory_config_defaults_for_lucid_enable_sqlite_hygiene() {
        let config = memory_config_defaults_for_backend("lucid");
        assert_eq!(config.backend, "lucid");
        assert!(config.auto_save);
        assert!(config.hygiene_enabled);
        assert_eq!(config.archive_after_days, 7);
        assert_eq!(config.purge_after_days, 30);
        assert_eq!(config.embedding_cache_size, 10000);
    }

    #[test]
    fn memory_config_defaults_for_none_disable_sqlite_hygiene() {
        let config = memory_config_defaults_for_backend("none");
        assert_eq!(config.backend, "none");
        assert!(!config.auto_save);
        assert!(!config.hygiene_enabled);
        assert_eq!(config.archive_after_days, 0);
        assert_eq!(config.purge_after_days, 0);
        assert_eq!(config.embedding_cache_size, 0);
    }

    #[test]
    fn channel_menu_choices_include_signal_nextcloud_lark_and_feishu() {
        assert!(channel_menu_choices().contains(&ChannelMenuChoice::Signal));
        assert!(channel_menu_choices().contains(&ChannelMenuChoice::NextcloudTalk));
        assert!(channel_menu_choices().contains(&ChannelMenuChoice::Lark));
        assert!(channel_menu_choices().contains(&ChannelMenuChoice::Feishu));
    }

    #[test]
    fn launchable_channels_include_signal_mattermost_qq_nextcloud_and_feishu() {
        let mut channels = ChannelsConfig::default();
        assert!(!has_launchable_channels(&channels));

        channels.signal = Some(zeroclaw_config::schema::SignalConfig {
            enabled: true,
            http_url: "http://127.0.0.1:8686".into(),
            account: "+1234567890".into(),
            group_id: None,
            allowed_from: vec!["*".into()],
            ignore_attachments: false,
            ignore_stories: true,
            proxy_url: None,
            approval_timeout_secs: 300,
        });
        assert!(has_launchable_channels(&channels));

        channels.signal = None;
        channels.mattermost = Some(zeroclaw_config::schema::MattermostConfig {
            enabled: true,
            url: "https://mattermost.example.com".into(),
            bot_token: "token".into(),
            channel_id: Some("channel".into()),
            allowed_users: vec!["*".into()],
            thread_replies: Some(true),
            mention_only: Some(false),
            interrupt_on_new_message: false,
            proxy_url: None,
        });
        assert!(has_launchable_channels(&channels));

        channels.mattermost = None;
        channels.qq = Some(zeroclaw_config::schema::QQConfig {
            enabled: true,
            app_id: "app-id".into(),
            app_secret: "app-secret".into(),
            allowed_users: vec!["*".into()],
            proxy_url: None,
        });
        assert!(has_launchable_channels(&channels));

        channels.qq = None;
        channels.nextcloud_talk = Some(zeroclaw_config::schema::NextcloudTalkConfig {
            enabled: true,
            base_url: "https://cloud.example.com".into(),
            app_token: "token".into(),
            webhook_secret: Some("secret".into()),
            allowed_users: vec!["*".into()],
            proxy_url: None,
            bot_name: None,
        });
        assert!(has_launchable_channels(&channels));

        channels.nextcloud_talk = None;
        channels.feishu = Some(zeroclaw_config::schema::FeishuConfig {
            enabled: true,
            app_id: "cli_123".into(),
            app_secret: "secret".into(),
            encrypt_key: None,
            verification_token: None,
            allowed_users: vec!["*".into()],
            mention_only: false,
            receive_mode: zeroclaw_config::schema::LarkReceiveMode::Websocket,
            port: None,
            proxy_url: None,
        });
        assert!(has_launchable_channels(&channels));
    }

    #[test]
    fn webhook_only_config_is_launchable() {
        let channels = ChannelsConfig {
            webhook: Some(zeroclaw_config::schema::WebhookConfig {
                enabled: true,
                port: 8080,
                listen_path: None,
                send_url: None,
                send_method: None,
                auth_header: None,
                secret: None,
            }),
            ..Default::default()
        };
        assert!(has_launchable_channels(&channels));
    }

    #[test]
    fn channels_repair_preserves_unmodified_channels() {
        use zeroclaw_config::schema::{DiscordConfig, MatrixConfig, StreamMode};

        let existing = ChannelsConfig {
            discord: Some(DiscordConfig {
                enabled: true,
                bot_token: "keep-me".into(),
                guild_id: None,
                allowed_users: vec![],
                listen_to_bots: false,
                interrupt_on_new_message: false,
                mention_only: false,
                proxy_url: None,
                stream_mode: StreamMode::default(),
                draft_update_interval_ms: 1500,
                multi_message_delay_ms: 800,
                stall_timeout_secs: 0,
                approval_timeout_secs: 300,
            }),
            matrix: Some(MatrixConfig {
                enabled: true,
                homeserver: "https://m.org".into(),
                access_token: "old-token".into(),
                user_id: None,
                device_id: None,
                allowed_users: vec![],
                allowed_rooms: vec!["!r:m".into()],
                interrupt_on_new_message: false,
                stream_mode: StreamMode::default(),
                draft_update_interval_ms: 1500,
                multi_message_delay_ms: 800,
                recovery_key: None,
                mention_only: false,
                password: None,
                approval_timeout_secs: 300,
            }),
            ..Default::default()
        };

        // Simulate the wizard starting from existing config and only updating Matrix
        let mut config = existing;
        config.matrix.as_mut().unwrap().access_token = "new-token".into();

        // Discord should be untouched
        assert!(config.discord.is_some());
        assert_eq!(config.discord.as_ref().unwrap().bot_token, "keep-me");

        // Matrix should reflect the update
        assert_eq!(config.matrix.as_ref().unwrap().access_token, "new-token");
    }

    #[test]
    fn matrix_reconfigure_preserves_non_prompted_fields() {
        use zeroclaw_config::schema::{MatrixConfig, StreamMode};

        let existing = ChannelsConfig {
            matrix: Some(MatrixConfig {
                enabled: true,
                homeserver: "https://m.org".into(),
                access_token: "tok".into(),
                user_id: None,
                device_id: Some("ZEROCLAW".into()),
                allowed_users: vec!["@u:m".into()],
                allowed_rooms: vec!["!r:m".into(), "!keep:m.org".into()],
                interrupt_on_new_message: true,
                stream_mode: StreamMode::Partial,
                draft_update_interval_ms: 2000,
                multi_message_delay_ms: 1000,
                recovery_key: Some("recovery-secret".into()),
                mention_only: false,
                password: None,
                approval_timeout_secs: 300,
            }),
            ..Default::default()
        };

        // Simulate re-configure: wizard preserves non-prompted fields
        let existing_mx = existing.matrix.as_ref();
        let preserved_rooms = existing_mx
            .map(|m| m.allowed_rooms.clone())
            .unwrap_or_default();
        let preserved_interrupt = existing_mx
            .map(|m| m.interrupt_on_new_message)
            .unwrap_or(false);
        let preserved_stream = existing_mx
            .map(|m| m.stream_mode)
            .unwrap_or(StreamMode::Partial);
        let preserved_draft_ms = existing_mx
            .map(|m| m.draft_update_interval_ms)
            .unwrap_or(1500);
        let preserved_multi_ms = existing_mx.map(|m| m.multi_message_delay_ms).unwrap_or(800);

        assert_eq!(
            preserved_rooms,
            vec!["!r:m".to_string(), "!keep:m.org".to_string()]
        );
        assert!(preserved_interrupt);
        assert!(matches!(preserved_stream, StreamMode::Partial));
        assert_eq!(preserved_draft_ms, 2000);
        assert_eq!(preserved_multi_ms, 1000);
    }

    #[test]
    fn matrix_fresh_install_uses_defaults_for_non_prompted_fields() {
        use zeroclaw_config::schema::StreamMode;

        let existing_mx: Option<&zeroclaw_config::schema::MatrixConfig> = None;
        let rooms = existing_mx
            .map(|m| m.allowed_rooms.clone())
            .unwrap_or_default();
        let interrupt = existing_mx
            .map(|m| m.interrupt_on_new_message)
            .unwrap_or(false);
        let stream = existing_mx
            .map(|m| m.stream_mode)
            .unwrap_or(StreamMode::Partial);

        assert!(rooms.is_empty());
        assert!(!interrupt);
        assert!(matches!(stream, StreamMode::Partial));
    }

    #[test]
    fn channels_fresh_install_starts_empty() {
        let config = ChannelsConfig::default();
        assert!(config.discord.is_none());
        assert!(config.matrix.is_none());
        assert!(config.telegram.is_none());
        assert!(config.slack.is_none());
    }
}
