//! Settings management commands for the Tauri frontend.
//!
//! Provides Tauri IPC commands for managing LLM providers, API keys,
//! and general application settings through the UI.

use std::path::PathBuf;

use serde::Serialize;
use tauri::State;
use tracing::{info, warn};

use athen_core::config::{
    AthenConfig, AuthType, EmbeddingMode, ModelsConfig, NotificationChannelKind,
    NotificationConfig, ProviderConfig, QuietHours, SecurityMode,
};
use athen_core::config_loader;

use crate::state::{build_router_for_provider, AppState};

// ---------------------------------------------------------------------------
// Response types
// ---------------------------------------------------------------------------

/// Information about a configured LLM provider.
#[derive(Debug, Clone, Serialize)]
pub struct ProviderInfo {
    pub id: String,
    pub name: String,
    pub provider_type: String,
    pub base_url: String,
    pub model: String,
    pub has_api_key: bool,
    pub api_key_hint: String,
    pub is_active: bool,
    /// Whether the configured `default_model` accepts image input.
    pub supports_vision: bool,
}

/// Email configuration info for the frontend.
#[derive(Debug, Clone, Serialize)]
pub struct EmailSettingsInfo {
    pub enabled: bool,
    pub imap_server: String,
    pub imap_port: u16,
    pub username: String,
    pub has_password: bool,
    pub use_tls: bool,
    pub folders: String,
    pub poll_interval_secs: u64,
    pub lookback_hours: u32,
    pub smtp_server: String,
    pub smtp_port: u16,
    pub smtp_username: String,
    pub has_smtp_password: bool,
    pub smtp_use_tls: bool,
    pub from_address: String,
}

/// Telegram bot configuration info for the frontend.
#[derive(Debug, Clone, Serialize)]
pub struct TelegramSettingsInfo {
    pub enabled: bool,
    pub has_bot_token: bool,
    pub bot_token_hint: String,
    /// The actual bot token so the frontend can re-populate the field.
    pub bot_token: String,
    pub owner_user_id: Option<i64>,
    pub allowed_chat_ids: Vec<i64>,
    pub poll_interval_secs: u64,
}

/// Notification delivery configuration info for the frontend.
#[derive(Debug, Clone, Serialize)]
pub struct NotificationSettingsInfo {
    pub preferred_channels: Vec<String>,
    pub escalation_timeout_secs: u64,
    pub quiet_hours_enabled: bool,
    pub quiet_start_hour: u32,
    pub quiet_start_minute: u32,
    pub quiet_end_hour: u32,
    pub quiet_end_minute: u32,
    pub quiet_allow_critical: bool,
}

/// Web search provider configuration info for the frontend. Keys are not
/// exposed verbatim — the frontend gets a boolean + masked hint so a
/// settings refresh on a shared screen doesn't leak the credential.
#[derive(Debug, Clone, Serialize)]
pub struct WebSearchSettingsInfo {
    pub brave_configured: bool,
    pub brave_hint: String,
    pub tavily_configured: bool,
    pub tavily_hint: String,
}

/// Embedding provider configuration info for the frontend.
#[derive(Debug, Clone, Serialize)]
pub struct EmbeddingSettingsInfo {
    pub mode: String,
    pub provider: Option<String>,
    pub model: Option<String>,
    pub base_url: Option<String>,
    pub has_api_key: bool,
    pub api_key_hint: Option<String>,
}

/// Full settings response for the frontend.
#[derive(Debug, Clone, Serialize)]
pub struct SettingsResponse {
    pub providers: Vec<ProviderInfo>,
    pub active_provider: String,
    pub security_mode: String,
    pub email: EmailSettingsInfo,
    pub telegram: TelegramSettingsInfo,
    pub notifications: NotificationSettingsInfo,
    pub embeddings: EmbeddingSettingsInfo,
    pub web_search: WebSearchSettingsInfo,
}

/// Result of a provider connection test.
#[derive(Debug, Clone, Serialize)]
pub struct TestResult {
    pub success: bool,
    pub message: String,
}

// ---------------------------------------------------------------------------
// Helper: config directory
// ---------------------------------------------------------------------------

/// Resolve Athen's per-user data directory, creating it if needed.
///
/// Path is platform-aware via [`athen_core::paths::athen_data_dir`]:
/// - Unix: `~/.athen`
/// - Windows: `%APPDATA%\Athen`
fn ensure_athen_dir() -> Result<PathBuf, String> {
    let dir = athen_core::paths::athen_data_dir()
        .ok_or_else(|| "Cannot resolve Athen data directory (no home).".to_string())?;
    std::fs::create_dir_all(&dir)
        .map_err(|e| format!("Failed to create {}: {e}", dir.display()))?;
    Ok(dir)
}

/// Load the current models config from `~/.athen/models.toml`, or return
/// an empty `ModelsConfig` if the file does not exist.
pub(crate) fn load_models_config() -> ModelsConfig {
    if let Ok(dir) = ensure_athen_dir() {
        let path = dir.join("models.toml");
        if path.exists() {
            match std::fs::read_to_string(&path) {
                Ok(content) => match toml::from_str::<ModelsConfig>(&content) {
                    Ok(cfg) => return cfg,
                    Err(e) => warn!("Failed to parse models.toml: {e}"),
                },
                Err(e) => warn!("Failed to read models.toml: {e}"),
            }
        }
    }
    ModelsConfig::default()
}

/// Save the models config to `~/.athen/models.toml`.
fn save_models_config(config: &ModelsConfig) -> Result<(), String> {
    let dir = ensure_athen_dir()?;
    let path = dir.join("models.toml");
    let content = toml::to_string_pretty(config)
        .map_err(|e| format!("Failed to serialize models config: {e}"))?;
    std::fs::write(&path, content).map_err(|e| format!("Failed to write models.toml: {e}"))?;
    info!("Saved models config to {}", path.display());
    Ok(())
}

/// Load the main config from `~/.athen/config.toml`, or return defaults.
fn load_main_config() -> AthenConfig {
    if let Ok(dir) = ensure_athen_dir() {
        let path = dir.join("config.toml");
        if path.exists() {
            match config_loader::load_config(&path) {
                Ok(cfg) => return cfg,
                Err(e) => warn!("Failed to load config.toml: {e}"),
            }
        }
    }
    AthenConfig::default()
}

/// Save the main config to `~/.athen/config.toml`.
fn save_main_config(config: &AthenConfig) -> Result<(), String> {
    let dir = ensure_athen_dir()?;
    let path = dir.join("config.toml");
    let content =
        toml::to_string_pretty(config).map_err(|e| format!("Failed to serialize config: {e}"))?;
    std::fs::write(&path, content).map_err(|e| format!("Failed to write config.toml: {e}"))?;
    info!("Saved config to {}", path.display());
    Ok(())
}

// ---------------------------------------------------------------------------
// Onboarding (first-launch detection)
// ---------------------------------------------------------------------------
//
// We treat onboarding-completion conservatively: any sign of an existing
// installation suppresses the wizard. The canonical "done" signal is the
// `.onboarded` sentinel file in `~/.athen/`. Once it exists we never show
// onboarding again. Before it exists, we still suppress the wizard if the
// user has a `models.toml` with at least one provider configured, or a
// `config.toml` from any prior install — this protects users who upgraded
// from a version that predates the sentinel.

/// Sentinel file name marking onboarding as completed.
const ONBOARDED_SENTINEL: &str = ".onboarded";

/// Pure predicate against an explicit Athen directory. Returns `true` only
/// when we are confident this is a fresh install. All ambiguous states (I/O
/// errors, malformed config, partial install) resolve to `false` so we
/// never accidentally re-run onboarding for a returning user.
fn is_first_launch_in(athen_dir: &std::path::Path) -> bool {
    // Sentinel takes priority — if onboarding was ever completed, never again.
    if athen_dir.join(ONBOARDED_SENTINEL).exists() {
        return false;
    }

    // Pre-sentinel returning users: any models.toml with at least one
    // provider means they're already set up.
    let models_path = athen_dir.join("models.toml");
    if models_path.exists() {
        match std::fs::read_to_string(&models_path) {
            Ok(content) => match toml::from_str::<ModelsConfig>(&content) {
                Ok(cfg) if !cfg.providers.is_empty() => return false,
                Ok(_) => {
                    // Empty providers map — could be a stub from a wizard
                    // that bailed midway. Be conservative: don't onboard.
                    return false;
                }
                Err(_) => {
                    // Parse error — file exists but unreadable. Assume
                    // returning user; better to make them visit Settings
                    // than to overwrite their broken config.
                    return false;
                }
            },
            Err(_) => return false,
        }
    }

    // Any pre-existing main config also signals a returning user. Their
    // models config might just live elsewhere (env vars, custom path).
    if athen_dir.join("config.toml").exists() {
        return false;
    }

    true
}

/// Write the onboarding sentinel. Idempotent: re-calling on an existing
/// sentinel is a no-op (not an error).
fn mark_onboarded_in(athen_dir: &std::path::Path) -> Result<(), String> {
    std::fs::create_dir_all(athen_dir)
        .map_err(|e| format!("Failed to create {}: {e}", athen_dir.display()))?;
    let sentinel = athen_dir.join(ONBOARDED_SENTINEL);
    if sentinel.exists() {
        return Ok(());
    }
    std::fs::write(&sentinel, b"")
        .map_err(|e| format!("Failed to write onboarding sentinel: {e}"))?;
    info!("Marked onboarding complete: {}", sentinel.display());
    Ok(())
}

/// Returns `true` when the desktop app should show the onboarding wizard.
/// Conservative: any ambiguity resolves to `false`.
#[tauri::command]
pub async fn is_first_launch() -> std::result::Result<bool, String> {
    // If we can't even resolve `~/.athen/`, assume we're not on a fresh
    // install — better to silently fall through to the main UI than to
    // pop a wizard from a broken state.
    let dir = match ensure_athen_dir() {
        Ok(d) => d,
        Err(e) => {
            warn!("is_first_launch: cannot resolve athen dir, treating as returning: {e}");
            return Ok(false);
        }
    };
    Ok(is_first_launch_in(&dir))
}

/// Mark onboarding as complete. Should be called by the frontend wizard's
/// "Done" handler after the user has either configured a provider or
/// explicitly chosen to skip. Idempotent.
#[tauri::command]
pub async fn complete_onboarding() -> std::result::Result<(), String> {
    let dir = ensure_athen_dir()?;
    mark_onboarded_in(&dir)
}

// ---------------------------------------------------------------------------
// Device capability detection
// ---------------------------------------------------------------------------
//
// Used by the onboarding wizard to recommend a sensible embedding tier
// without requiring the user to know what their machine can handle. We
// inspect total RAM and logical core count and bucket the result into one
// of three tiers. Conservative on purpose — when unsure, recommend the
// lighter option so the user isn't fighting OOM kills on first run.

/// Capability snapshot returned to the frontend.
#[derive(Serialize)]
pub struct DeviceCapabilities {
    /// Total system RAM, in gigabytes (rounded down).
    pub total_ram_gb: u64,
    /// Logical CPU cores.
    pub cpu_cores: usize,
    /// OS family: "linux", "macos", "windows", or "other".
    pub os: &'static str,
    /// Recommended embedding tier: "standard" | "small" | "skip".
    /// - `standard`: 16 GB+ RAM, 4+ cores. Local 80–250 MB models are fine.
    /// - `small`:    8–16 GB RAM. Stick to ~80 MB MiniLM-class models.
    /// - `skip`:     <8 GB RAM. Recommend cloud or keyword fallback.
    pub recommended_tier: &'static str,
    /// Short human-readable explanation for the chosen tier.
    pub tier_reason: String,
}

/// Probe the host machine and return a capability snapshot. Cheap — only
/// reads RAM/CPU info via `sysinfo`, no network or disk scans.
#[tauri::command]
pub async fn detect_device_capabilities() -> std::result::Result<DeviceCapabilities, String> {
    use sysinfo::System;

    let mut sys = System::new();
    sys.refresh_memory();

    let total_ram_gb = sys.total_memory() / (1024 * 1024 * 1024);
    let cpu_cores = std::thread::available_parallelism()
        .map(|n| n.get())
        .unwrap_or(1);

    let os = if cfg!(target_os = "linux") {
        "linux"
    } else if cfg!(target_os = "macos") {
        "macos"
    } else if cfg!(target_os = "windows") {
        "windows"
    } else {
        "other"
    };

    let (recommended_tier, tier_reason) = if total_ram_gb >= 16 && cpu_cores >= 4 {
        (
            "standard",
            format!("{total_ram_gb} GB RAM and {cpu_cores} cores — comfortable for a local embedding model."),
        )
    } else if total_ram_gb >= 8 {
        (
            "small",
            format!("{total_ram_gb} GB RAM — a small embedding model (~80 MB) will run fine."),
        )
    } else {
        (
            "skip",
            format!("Only {total_ram_gb} GB RAM detected — recommend cloud embeddings or the keyword fallback."),
        )
    };

    Ok(DeviceCapabilities {
        total_ram_gb,
        cpu_cores,
        os,
        recommended_tier,
        tier_reason,
    })
}

// ---------------------------------------------------------------------------
// Helpers for provider info
// ---------------------------------------------------------------------------

/// Default base URL for a provider ID.
fn default_base_url(id: &str) -> &str {
    match id {
        "deepseek" => "https://api.deepseek.com",
        "openai" => "https://api.openai.com",
        "anthropic" => "https://api.anthropic.com",
        "mistral" => "https://api.mistral.ai",
        "openrouter" => "https://openrouter.ai/api",
        "ollama" => "http://localhost:11434",
        "llamacpp" => "http://localhost:8080",
        _ => "",
    }
}

/// Default model for a provider ID.
fn default_model(id: &str) -> &str {
    match id {
        "deepseek" => "deepseek-chat",
        "openai" => "gpt-4o",
        "anthropic" => "claude-sonnet-4-20250514",
        "mistral" => "mistral-large-latest",
        "openrouter" => "openai/gpt-4o-mini",
        "ollama" => "llama3",
        "llamacpp" => "default",
        _ => "",
    }
}

/// Display name for a provider ID.
fn display_name(id: &str) -> &str {
    match id {
        "deepseek" => "DeepSeek",
        "openai" => "OpenAI",
        "anthropic" => "Anthropic",
        "mistral" => "Mistral",
        "openrouter" => "OpenRouter",
        "ollama" => "Ollama",
        "llamacpp" => "llama.cpp",
        _ => id,
    }
}

/// Whether a provider is cloud-based (needs API key) or local.
fn provider_type(id: &str) -> &str {
    match id {
        "ollama" | "llamacpp" => "local",
        _ => "cloud",
    }
}

/// Placeholder text for the API key field in the UI. Empty for local
/// providers (no key needed).
fn api_key_hint(id: &str) -> &str {
    match id {
        "anthropic" => "sk-ant-...",
        "openrouter" => "sk-or-...",
        "deepseek" | "openai" | "mistral" => "sk-...",
        _ => "",
    }
}

/// Canonical list of provider IDs the backend knows how to talk to.
/// Adding a new provider only requires adding it here plus implementing
/// the matching `match` arms in `default_*`/`display_name`/`provider_type`/
/// `api_key_hint` and `build_router_for_provider`. The frontend renders
/// onboarding pickers and settings templates entirely from this list.
const PROVIDER_IDS: &[&str] = &[
    "deepseek",
    "anthropic",
    "openai",
    "mistral",
    "openrouter",
    "ollama",
    "llamacpp",
];

/// One entry in the provider catalog returned to the frontend.
#[derive(Serialize)]
pub struct ProviderCatalogEntry {
    pub id: &'static str,
    pub name: &'static str,
    /// "cloud" or "local".
    pub provider_type: &'static str,
    pub default_base_url: &'static str,
    pub default_model: &'static str,
    /// Placeholder text for the API key input. Empty for local providers.
    pub api_key_hint: &'static str,
}

/// Return the canonical list of providers the app supports. Single source
/// of truth — onboarding and settings UI both render from this.
#[tauri::command]
pub async fn list_provider_catalog() -> std::result::Result<Vec<ProviderCatalogEntry>, String> {
    Ok(PROVIDER_IDS
        .iter()
        .map(|id| ProviderCatalogEntry {
            id,
            name: display_name(id),
            provider_type: provider_type(id),
            default_base_url: default_base_url(id),
            default_model: default_model(id),
            api_key_hint: api_key_hint(id),
        })
        .collect())
}

/// Mask an API key, showing only the last 4 characters.
fn mask_api_key(key: &str) -> String {
    if key.len() <= 4 {
        return "*".repeat(key.len());
    }
    let visible = &key[key.len() - 4..];
    format!("{}...{}", &key[..3], visible)
}

/// Convert a `ProviderConfig` entry into `ProviderInfo` for the frontend.
fn provider_config_to_info(id: &str, config: &ProviderConfig, active_id: &str) -> ProviderInfo {
    let base_url = config
        .endpoint
        .as_deref()
        .unwrap_or_else(|| default_base_url(id))
        .to_string();

    // Config file key takes priority over env var.
    let (has_key, hint) = match &config.auth {
        AuthType::ApiKey(key) if !key.is_empty() && !key.starts_with("${") => {
            (true, mask_api_key(key))
        }
        _ => {
            // Fall back to env var (e.g. DEEPSEEK_API_KEY, OPENAI_API_KEY).
            let env_var = format!("{}_API_KEY", id.to_uppercase());
            if let Ok(env_key) = std::env::var(&env_var) {
                if !env_key.is_empty() {
                    (true, format!("{}  (env)", mask_api_key(&env_key)))
                } else {
                    (false, String::new())
                }
            } else {
                (false, String::new())
            }
        }
    };

    ProviderInfo {
        id: id.to_string(),
        name: display_name(id).to_string(),
        provider_type: provider_type(id).to_string(),
        base_url,
        model: if config.default_model.is_empty() {
            default_model(id).to_string()
        } else {
            config.default_model.clone()
        },
        has_api_key: has_key,
        api_key_hint: hint,
        is_active: id == active_id,
        supports_vision: config.supports_vision,
    }
}

// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------

/// Return the current settings to populate the settings page.
#[tauri::command]
pub async fn get_settings(
    state: State<'_, AppState>,
) -> std::result::Result<SettingsResponse, String> {
    let models = load_models_config();
    let main_config = load_main_config();

    // Read the active provider from runtime state.
    let active = state.active_provider_id.lock().await.clone();

    let mut providers: Vec<ProviderInfo> = models
        .providers
        .iter()
        .map(|(id, cfg)| provider_config_to_info(id, cfg, &active))
        .collect();

    // Note: we deliberately don't synthesize a placeholder card when the
    // provider map is empty. Doing so caused a UI bug where deleting the
    // last provider made it look like the delete failed (the synthesizer
    // re-created a card for the now-orphan active id). The frontend's
    // "Add provider" button already covers the empty-list state.

    // Sort: active first, then alphabetical.
    providers.sort_by(|a, b| b.is_active.cmp(&a.is_active).then(a.id.cmp(&b.id)));

    let security_mode = format!("{:?}", main_config.security.mode).to_lowercase();

    let email = EmailSettingsInfo {
        enabled: main_config.email.enabled,
        imap_server: main_config.email.imap_server.clone(),
        imap_port: main_config.email.imap_port,
        username: main_config.email.username.clone(),
        has_password: !main_config.email.password.is_empty(),
        use_tls: main_config.email.use_tls,
        folders: main_config.email.folders.join(", "),
        poll_interval_secs: main_config.email.poll_interval_secs,
        lookback_hours: main_config.email.lookback_hours,
        smtp_server: main_config.email.smtp_server.clone(),
        smtp_port: main_config.email.smtp_port,
        smtp_username: main_config.email.smtp_username.clone(),
        has_smtp_password: !main_config.email.smtp_password.is_empty(),
        smtp_use_tls: main_config.email.smtp_use_tls,
        from_address: main_config.email.from_address.clone(),
    };

    let telegram = TelegramSettingsInfo {
        enabled: main_config.telegram.enabled,
        has_bot_token: !main_config.telegram.bot_token.is_empty(),
        bot_token_hint: if main_config.telegram.bot_token.is_empty() {
            String::new()
        } else {
            mask_api_key(&main_config.telegram.bot_token)
        },
        bot_token: main_config.telegram.bot_token.clone(),
        owner_user_id: main_config.telegram.owner_user_id,
        allowed_chat_ids: main_config.telegram.allowed_chat_ids.clone(),
        poll_interval_secs: main_config.telegram.poll_interval_secs,
    };

    let notifications = {
        let nc = &main_config.notifications;
        let (qh_enabled, qh_start_h, qh_start_m, qh_end_h, qh_end_m, qh_critical) =
            match &nc.quiet_hours {
                Some(qh) => (
                    true,
                    qh.start_hour,
                    qh.start_minute,
                    qh.end_hour,
                    qh.end_minute,
                    qh.allow_critical,
                ),
                None => (false, 22, 0, 8, 0, true),
            };

        NotificationSettingsInfo {
            preferred_channels: nc
                .preferred_channels
                .iter()
                .map(|k| format!("{k:?}"))
                .collect(),
            escalation_timeout_secs: nc.escalation_timeout_secs,
            quiet_hours_enabled: qh_enabled,
            quiet_start_hour: qh_start_h,
            quiet_start_minute: qh_start_m,
            quiet_end_hour: qh_end_h,
            quiet_end_minute: qh_end_m,
            quiet_allow_critical: qh_critical,
        }
    };

    let embeddings = {
        let ec = &main_config.embeddings;
        let mode_str = format!("{:?}", ec.mode);
        let (has_key, hint) = match &ec.api_key {
            Some(key) if !key.is_empty() => (true, Some(mask_api_key(key))),
            _ => (false, None),
        };
        EmbeddingSettingsInfo {
            mode: mode_str,
            provider: ec.provider.clone(),
            model: ec.model.clone(),
            base_url: ec.base_url.clone(),
            has_api_key: has_key,
            api_key_hint: hint,
        }
    };

    let web_search = {
        let ws = &main_config.web_search;
        let brave_configured = !ws.brave_api_key.is_empty();
        let tavily_configured = !ws.tavily_api_key.is_empty();
        WebSearchSettingsInfo {
            brave_configured,
            brave_hint: if brave_configured {
                mask_api_key(&ws.brave_api_key)
            } else {
                String::new()
            },
            tavily_configured,
            tavily_hint: if tavily_configured {
                mask_api_key(&ws.tavily_api_key)
            } else {
                String::new()
            },
        }
    };

    Ok(SettingsResponse {
        providers,
        active_provider: active,
        security_mode,
        email,
        telegram,
        notifications,
        embeddings,
        web_search,
    })
}

/// Save or update a provider configuration.
///
/// If `api_key` is `None`, the existing key is preserved.
/// If `api_key` is `Some("")`, the key is removed.
/// If `api_key` is `Some("sk-...")`, the key is updated.
///
/// Saves to `~/.athen/models.toml`. Changes require an app restart.
#[tauri::command]
pub async fn save_provider(
    id: String,
    base_url: String,
    model: String,
    api_key: Option<String>,
    supports_vision: Option<bool>,
    state: State<'_, AppState>,
) -> std::result::Result<String, String> {
    let mut models = load_models_config();

    let existing = models.providers.get(&id);
    let auth = match api_key {
        Some(key) if key.is_empty() => AuthType::None,
        Some(key) => AuthType::ApiKey(key),
        None => existing.map(|p| p.auth.clone()).unwrap_or(AuthType::None),
    };

    let resolved_base_url = if base_url.is_empty() {
        default_base_url(&id).to_string()
    } else {
        base_url.clone()
    };

    let endpoint = if base_url.is_empty() || base_url == default_base_url(&id) {
        None
    } else {
        Some(base_url)
    };

    let resolved_model = if model.is_empty() {
        default_model(&id).to_string()
    } else {
        model.clone()
    };

    let (context_window_tokens, compaction_trigger_pct, compaction_target_pct) = existing
        .map(|p| {
            (
                p.context_window_tokens,
                p.compaction_trigger_pct,
                p.compaction_target_pct,
            )
        })
        .unwrap_or((128_000, 65, 30));

    // If the caller didn't pass a flag, preserve the existing value
    // (so editing other fields doesn't accidentally clear vision).
    let supports_vision_resolved =
        supports_vision.unwrap_or_else(|| existing.is_some_and(|p| p.supports_vision));

    let provider = ProviderConfig {
        auth: auth.clone(),
        default_model: model,
        endpoint,
        context_window_tokens,
        compaction_trigger_pct,
        compaction_target_pct,
        supports_vision: supports_vision_resolved,
    };

    models.providers.insert(id.clone(), provider);
    save_models_config(&models)?;

    // Hot-reload if saving the currently active provider.
    let active_id = state.active_provider_id.lock().await.clone();
    if id == active_id {
        // Resolve the API key: saved key takes priority over env var.
        let router_api_key = match &auth {
            AuthType::ApiKey(key) if !key.is_empty() && !key.starts_with("${") => Some(key.clone()),
            _ => {
                let env_var = format!("{}_API_KEY", id.to_uppercase());
                std::env::var(&env_var).ok().filter(|k| !k.is_empty())
            }
        };

        let supports_vision = models.providers.get(&id).is_some_and(|c| c.supports_vision);
        let new_router = build_router_for_provider(
            &id,
            &resolved_base_url,
            &resolved_model,
            router_api_key.as_deref(),
            supports_vision,
        );

        {
            let mut router_guard = state.router.write().await;
            *router_guard = new_router;
        }
        *state.model_name.lock().await = resolved_model.clone();

        let name = display_name(&id);
        info!("Hot-reloaded active provider {} ({})", name, resolved_model);
        Ok(format!(
            "Provider saved and activated ({} / {}).",
            name, resolved_model
        ))
    } else {
        Ok("Provider saved.".to_string())
    }
}

/// Delete a provider configuration.
///
/// If the deleted provider is the currently active one, automatically
/// switches to the first remaining provider (or "deepseek" as fallback)
/// and hot-reloads the router.
#[tauri::command]
pub async fn delete_provider(
    id: String,
    state: State<'_, AppState>,
) -> std::result::Result<String, String> {
    let mut models = load_models_config();

    if models.providers.remove(&id).is_none() {
        return Err(format!("Provider '{}' not found.", id));
    }

    save_models_config(&models)?;

    // If deleting the active provider, switch to a fallback.
    let active_id = state.active_provider_id.lock().await.clone();
    if id == active_id {
        let fallback_id = models
            .providers
            .keys()
            .next()
            .cloned()
            .unwrap_or_else(|| "deepseek".to_string());

        let fallback_cfg = models.providers.get(&fallback_id);
        let base_url = fallback_cfg
            .and_then(|c| c.endpoint.as_deref())
            .unwrap_or_else(|| default_base_url(&fallback_id))
            .to_string();
        let model = fallback_cfg
            .map(|c| c.default_model.as_str())
            .filter(|m| !m.is_empty())
            .unwrap_or_else(|| default_model(&fallback_id))
            .to_string();

        let api_key = fallback_cfg
            .and_then(|c| match &c.auth {
                AuthType::ApiKey(key) if !key.is_empty() && !key.starts_with("${") => {
                    Some(key.clone())
                }
                _ => None,
            })
            .or_else(|| {
                let env_var = format!("{}_API_KEY", fallback_id.to_uppercase());
                std::env::var(&env_var).ok().filter(|k| !k.is_empty())
            });

        let supports_vision = fallback_cfg.is_some_and(|c| c.supports_vision);
        let new_router = build_router_for_provider(
            &fallback_id,
            &base_url,
            &model,
            api_key.as_deref(),
            supports_vision,
        );

        {
            let mut router_guard = state.router.write().await;
            *router_guard = new_router;
        }
        *state.active_provider_id.lock().await = fallback_id.clone();
        *state.model_name.lock().await = model;

        if let Err(e) = persist_active_provider(&fallback_id) {
            warn!("Failed to persist active provider after delete: {e}");
        }

        let fallback_name = display_name(&fallback_id);
        info!("Deleted provider '{}', switched to {}", id, fallback_name);
        Ok(format!(
            "Provider '{}' deleted. Switched to {}.",
            id, fallback_name
        ))
    } else {
        info!("Deleted provider '{}'", id);
        Ok(format!("Provider '{}' deleted.", id))
    }
}

/// Test connectivity to an LLM provider by sending a simple request.
#[tauri::command]
pub async fn test_provider(
    id: String,
    base_url: String,
    model: String,
    api_key: Option<String>,
) -> std::result::Result<TestResult, String> {
    // For local providers, just check if the endpoint responds.
    let url = if base_url.is_empty() {
        default_base_url(&id).to_string()
    } else {
        base_url
    };

    let model = if model.is_empty() {
        default_model(&id).to_string()
    } else {
        model
    };

    // Resolve the API key: use provided, or fall back to env var, or config.
    let key = api_key.unwrap_or_else(|| {
        if id == "deepseek" {
            std::env::var("DEEPSEEK_API_KEY").unwrap_or_default()
        } else {
            let models = load_models_config();
            models
                .providers
                .get(&id)
                .and_then(|p| match &p.auth {
                    AuthType::ApiKey(k) if !k.is_empty() && !k.starts_with("${") => Some(k.clone()),
                    _ => None,
                })
                .unwrap_or_default()
        }
    });

    // Build a minimal test request based on the provider type.
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .build()
        .map_err(|e| format!("Failed to create HTTP client: {e}"))?;

    let result = match id.as_str() {
        "ollama" => test_ollama(&client, &url).await,
        "llamacpp" => test_llamacpp(&client, &url).await,
        "anthropic" => test_anthropic(&client, &url, &key, &model).await,
        _ => test_openai_compatible(&client, &url, &key, &model).await,
    };

    match result {
        Ok(msg) => Ok(TestResult {
            success: true,
            message: msg,
        }),
        Err(msg) => Ok(TestResult {
            success: false,
            message: msg,
        }),
    }
}

/// Switch the active LLM provider at runtime without restarting the app.
///
/// Builds a new `DefaultLlmRouter` for the requested provider and swaps it
/// into the shared `RwLock`, so all subsequent LLM calls use the new provider.
/// Also persists the choice to `~/.athen/models.toml` so it survives restarts.
#[tauri::command]
pub async fn set_active_provider(
    id: String,
    state: State<'_, AppState>,
) -> std::result::Result<String, String> {
    let models = load_models_config();
    let provider_cfg = models.providers.get(&id);

    let base_url = provider_cfg
        .and_then(|c| c.endpoint.as_deref())
        .unwrap_or_else(|| default_base_url(&id))
        .to_string();

    let model = provider_cfg
        .map(|c| c.default_model.as_str())
        .filter(|m| !m.is_empty())
        .unwrap_or_else(|| default_model(&id))
        .to_string();

    // Resolve API key: saved config takes priority over env var.
    let api_key = provider_cfg
        .and_then(|c| match &c.auth {
            AuthType::ApiKey(key) if !key.is_empty() && !key.starts_with("${") => Some(key.clone()),
            _ => None,
        })
        .or_else(|| {
            let env_var = format!("{}_API_KEY", id.to_uppercase());
            std::env::var(&env_var).ok().filter(|k| !k.is_empty())
        });

    // Cloud providers require an API key.
    let is_local = matches!(id.as_str(), "ollama" | "llamacpp");
    if !is_local && api_key.is_none() {
        let env_var = format!("{}_API_KEY", id.to_uppercase());
        return Err(format!(
            "No API key found for '{}'. Set {} env var or configure a key in settings first.",
            id, env_var,
        ));
    }

    // Build the new router.
    let supports_vision = provider_cfg.is_some_and(|c| c.supports_vision);
    let new_router =
        build_router_for_provider(&id, &base_url, &model, api_key.as_deref(), supports_vision);

    // Swap the router atomically.
    {
        let mut router_guard = state.router.write().await;
        *router_guard = new_router;
    }

    // Update runtime state.
    *state.active_provider_id.lock().await = id.clone();
    *state.model_name.lock().await = model.clone();

    // Persist the active provider choice.
    if let Err(e) = persist_active_provider(&id) {
        warn!("Failed to persist active provider: {e}");
    }

    let name = display_name(&id);
    info!("Switched active provider to {} ({})", name, model);
    Ok(format!("Switched to {} ({})", name, model))
}

/// Persist the active provider choice to `~/.athen/models.toml`.
fn persist_active_provider(provider_id: &str) -> Result<(), String> {
    let mut models = load_models_config();
    models
        .assignments
        .insert("active_provider".to_string(), provider_id.to_string());
    save_models_config(&models)
}

/// Save general settings (security mode, etc.).
#[tauri::command]
pub async fn save_settings(security_mode: String) -> std::result::Result<String, String> {
    let mut config = load_main_config();

    config.security.mode = match security_mode.to_lowercase().as_str() {
        "bunker" => SecurityMode::Bunker,
        "yolo" => SecurityMode::Yolo,
        _ => SecurityMode::Assistant,
    };

    save_main_config(&config)?;
    Ok("Settings saved. Restart the app to apply changes.".to_string())
}

// ---------------------------------------------------------------------------
// Email settings commands
// ---------------------------------------------------------------------------

/// Save email monitor settings.
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn save_email_settings(
    enabled: bool,
    imap_server: String,
    imap_port: u16,
    username: String,
    password: Option<String>,
    use_tls: bool,
    folders: String,
    poll_interval_secs: u64,
    lookback_hours: u32,
) -> std::result::Result<String, String> {
    let mut config = load_main_config();

    config.email.enabled = enabled;
    config.email.imap_server = imap_server;
    config.email.imap_port = imap_port;
    config.email.username = username;
    if let Some(pw) = password {
        if !pw.is_empty() {
            config.email.password = pw;
        }
    }
    config.email.use_tls = use_tls;
    config.email.folders = folders
        .split(',')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect();
    config.email.poll_interval_secs = poll_interval_secs;
    config.email.lookback_hours = lookback_hours;

    save_main_config(&config)?;
    Ok("Email settings saved. Restart to apply.".to_string())
}

/// Test email connection with the provided IMAP settings.
#[tauri::command]
pub async fn test_email_connection(
    imap_server: String,
    imap_port: u16,
    username: String,
    password: String,
    use_tls: bool,
) -> std::result::Result<TestResult, String> {
    // Run the blocking IMAP test in spawn_blocking
    let result = tokio::task::spawn_blocking(move || {
        test_imap_connection(&imap_server, imap_port, &username, &password, use_tls)
    })
    .await
    .map_err(|e| format!("Test task failed: {e}"))?;

    match result {
        Ok(msg) => Ok(TestResult {
            success: true,
            message: msg,
        }),
        Err(msg) => Ok(TestResult {
            success: false,
            message: msg,
        }),
    }
}

fn test_imap_connection(
    server: &str,
    port: u16,
    username: &str,
    password: &str,
    use_tls: bool,
) -> Result<String, String> {
    use std::net::TcpStream;
    use std::time::Duration;

    let addr = (server, port);
    let tcp = TcpStream::connect(addr).map_err(|e| format!("Connection failed: {e}"))?;
    tcp.set_read_timeout(Some(Duration::from_secs(10)))
        .map_err(|e| format!("Failed to set timeout: {e}"))?;
    tcp.set_write_timeout(Some(Duration::from_secs(10)))
        .map_err(|e| format!("Failed to set timeout: {e}"))?;

    if use_tls {
        let connector = rustls_connector::RustlsConnector::new_with_native_certs()
            .map_err(|e| format!("TLS setup failed: {e}"))?;
        let tls_stream = connector
            .connect(server, tcp)
            .map_err(|e| format!("TLS handshake failed: {e}"))?;
        let client = imap::Client::new(tls_stream);
        let mut session = client
            .login(username, password)
            .map_err(|(e, _)| format!("Login failed: {e}"))?;
        let mailbox = session
            .select("INBOX")
            .map_err(|e| format!("Failed to select INBOX: {e}"))?;
        let count = mailbox.exists;
        session
            .logout()
            .map_err(|e| format!("Logout failed: {e}"))?;
        Ok(format!(
            "Connected successfully. INBOX has {} messages.",
            count
        ))
    } else {
        let client = imap::Client::new(tcp);
        let mut session = client
            .login(username, password)
            .map_err(|(e, _)| format!("Login failed: {e}"))?;
        let mailbox = session
            .select("INBOX")
            .map_err(|e| format!("Failed to select INBOX: {e}"))?;
        let count = mailbox.exists;
        session
            .logout()
            .map_err(|e| format!("Logout failed: {e}"))?;
        Ok(format!(
            "Connected successfully. INBOX has {} messages.",
            count
        ))
    }
}

/// Save SMTP outbound settings.
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn save_smtp_settings(
    smtp_server: String,
    smtp_port: u16,
    smtp_username: String,
    smtp_password: Option<String>,
    smtp_use_tls: bool,
    from_address: String,
) -> std::result::Result<String, String> {
    let mut config = load_main_config();
    config.email.smtp_server = smtp_server;
    config.email.smtp_port = smtp_port;
    config.email.smtp_username = smtp_username;
    if let Some(pw) = smtp_password {
        if !pw.is_empty() {
            config.email.smtp_password = pw;
        }
    }
    config.email.smtp_use_tls = smtp_use_tls;
    config.email.from_address = from_address;
    save_main_config(&config)?;
    Ok("SMTP settings saved. Restart to apply.".to_string())
}

/// Test SMTP connection with the provided settings.
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn test_smtp_connection(
    smtp_server: String,
    smtp_port: u16,
    smtp_username: String,
    smtp_password: String,
    smtp_use_tls: bool,
    from_address: String,
) -> std::result::Result<TestResult, String> {
    use athen_core::traits::email_sender::EmailSender;
    use athen_sentidos::email_send::{LettreSmtpSender, SmtpSettings};

    if smtp_server.trim().is_empty() {
        return Ok(TestResult {
            success: false,
            message: "SMTP server required".into(),
        });
    }
    if from_address.trim().is_empty() {
        return Ok(TestResult {
            success: false,
            message: "From address required".into(),
        });
    }

    let settings = SmtpSettings {
        server: smtp_server,
        port: smtp_port,
        username: smtp_username,
        password: smtp_password,
        use_implicit_tls: smtp_use_tls,
        from_address,
    };
    let sender = match LettreSmtpSender::new(settings) {
        Ok(s) => s,
        Err(e) => {
            return Ok(TestResult {
                success: false,
                message: format!("Setup failed: {e}"),
            })
        }
    };
    match sender.test_connection().await {
        Ok(()) => Ok(TestResult {
            success: true,
            message: "SMTP authenticated successfully.".into(),
        }),
        Err(e) => Ok(TestResult {
            success: false,
            message: format!("Connection failed: {e}"),
        }),
    }
}

// ---------------------------------------------------------------------------
// Telegram settings commands
// ---------------------------------------------------------------------------

/// Save Telegram bot monitor settings.
#[tauri::command]
pub async fn save_telegram_settings(
    enabled: bool,
    bot_token: Option<String>,
    owner_user_id: Option<i64>,
    allowed_chat_ids: Vec<i64>,
    poll_interval_secs: Option<u64>,
) -> std::result::Result<String, String> {
    let mut config = load_main_config();

    config.telegram.enabled = enabled;
    if let Some(token) = bot_token {
        if !token.is_empty() {
            config.telegram.bot_token = token;
        }
    }
    config.telegram.owner_user_id = owner_user_id;
    config.telegram.allowed_chat_ids = allowed_chat_ids;
    if let Some(interval) = poll_interval_secs {
        config.telegram.poll_interval_secs = interval;
    }

    save_main_config(&config)?;
    Ok("Telegram settings saved. Restart to apply.".to_string())
}

/// Test Telegram bot connectivity by calling the `getMe` API endpoint.
#[tauri::command]
pub async fn test_telegram_connection(
    bot_token: String,
) -> std::result::Result<TestResult, String> {
    if bot_token.is_empty() {
        return Ok(TestResult {
            success: false,
            message: "Bot token is required.".to_string(),
        });
    }

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .build()
        .map_err(|e| format!("Failed to create HTTP client: {e}"))?;

    let url = format!("https://api.telegram.org/bot{}/getMe", bot_token);

    let resp = client
        .get(&url)
        .send()
        .await
        .map_err(|e| format!("Connection failed: {e}"));

    match resp {
        Ok(r) if r.status().is_success() => {
            let body: serde_json::Value = r
                .json()
                .await
                .map_err(|e| format!("Invalid response: {e}"))?;
            let username = body
                .get("result")
                .and_then(|r| r.get("username"))
                .and_then(|u| u.as_str())
                .unwrap_or("unknown");
            Ok(TestResult {
                success: true,
                message: format!("Connected! Bot: @{}", username),
            })
        }
        Ok(r) => {
            let status = r.status();
            let text = r.text().await.unwrap_or_default();
            let detail = serde_json::from_str::<serde_json::Value>(&text)
                .ok()
                .and_then(|v| {
                    v.get("description")
                        .and_then(|d| d.as_str())
                        .map(|s| s.to_string())
                })
                .unwrap_or_else(|| text.chars().take(200).collect());
            Ok(TestResult {
                success: false,
                message: format!("HTTP {}: {}", status, detail),
            })
        }
        Err(msg) => Ok(TestResult {
            success: false,
            message: msg,
        }),
    }
}

// ---------------------------------------------------------------------------
// Web search settings commands
// ---------------------------------------------------------------------------

/// Save web search provider keys.
///
/// Each key follows the same convention as the LLM provider commands:
/// - `None` keeps the existing value untouched.
/// - `Some("")` clears the key.
/// - `Some("key")` updates it.
///
/// Changes take effect after a restart — the runtime builds the
/// MultiSearchProvider chain from this config in `AppState::new`.
#[tauri::command]
pub async fn save_web_search_settings(
    brave_api_key: Option<String>,
    tavily_api_key: Option<String>,
) -> std::result::Result<String, String> {
    let mut config = load_main_config();

    if let Some(key) = brave_api_key {
        config.web_search.brave_api_key = key;
    }
    if let Some(key) = tavily_api_key {
        config.web_search.tavily_api_key = key;
    }

    save_main_config(&config)?;
    Ok("Web search settings saved. Restart to apply.".to_string())
}

/// Test a web search provider key with a tiny smoke query. The provider
/// `id` must be one of `"brave"` or `"tavily"`; DDG isn't keyed and
/// doesn't need a test path.
#[tauri::command]
pub async fn test_web_search_provider(
    provider: String,
    api_key: String,
) -> std::result::Result<TestResult, String> {
    use athen_web::WebSearchProvider;

    if api_key.trim().is_empty() {
        return Ok(TestResult {
            success: false,
            message: "API key is required.".to_string(),
        });
    }

    let backend: Box<dyn WebSearchProvider> = match provider.as_str() {
        "brave" => Box::new(athen_web::BraveSearch::new(api_key)),
        "tavily" => Box::new(athen_web::TavilySearch::new(api_key)),
        other => {
            return Ok(TestResult {
                success: false,
                message: format!("Unknown provider '{other}'. Use 'brave' or 'tavily'."),
            });
        }
    };

    match backend.search("athen ai agent", 1).await {
        Ok(hits) if hits.is_empty() => Ok(TestResult {
            success: true,
            message: "Connected — provider returned no hits for the smoke query, but auth works."
                .to_string(),
        }),
        Ok(hits) => Ok(TestResult {
            success: true,
            message: format!("Connected. Top result: {}", hits[0].title),
        }),
        Err(e) => Ok(TestResult {
            success: false,
            message: e.to_string(),
        }),
    }
}

// ---------------------------------------------------------------------------
// Provider-specific test functions
// ---------------------------------------------------------------------------

async fn test_ollama(client: &reqwest::Client, base_url: &str) -> Result<String, String> {
    let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
    let resp = client
        .get(&url)
        .send()
        .await
        .map_err(|e| format!("Connection failed: {e}"))?;

    if resp.status().is_success() {
        let body: serde_json::Value = resp
            .json()
            .await
            .map_err(|e| format!("Invalid response: {e}"))?;
        let count = body
            .get("models")
            .and_then(|m| m.as_array())
            .map(|a| a.len())
            .unwrap_or(0);
        Ok(format!("Connected. {} model(s) available.", count))
    } else {
        Err(format!("Server returned HTTP {}", resp.status()))
    }
}

async fn test_llamacpp(client: &reqwest::Client, base_url: &str) -> Result<String, String> {
    let url = format!("{}/health", base_url.trim_end_matches('/'));
    let resp = client
        .get(&url)
        .send()
        .await
        .map_err(|e| format!("Connection failed: {e}"))?;

    if resp.status().is_success() {
        Ok("Connected. Server is healthy.".to_string())
    } else {
        Err(format!("Server returned HTTP {}", resp.status()))
    }
}

async fn test_openai_compatible(
    client: &reqwest::Client,
    base_url: &str,
    api_key: &str,
    model: &str,
) -> Result<String, String> {
    if api_key.is_empty() {
        return Err("API key is required.".to_string());
    }

    let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));

    let body = serde_json::json!({
        "model": model,
        "messages": [{"role": "user", "content": "Say hello in one word."}],
        "max_tokens": 10,
    });

    let resp = client
        .post(&url)
        .header("Authorization", format!("Bearer {api_key}"))
        .header("Content-Type", "application/json")
        .json(&body)
        .send()
        .await
        .map_err(|e| format!("Connection failed: {e}"))?;

    if resp.status().is_success() {
        Ok(format!("Connected to {} successfully.", model))
    } else {
        let status = resp.status();
        let text = resp.text().await.unwrap_or_default();
        // Try to extract an error message from JSON response.
        let detail = serde_json::from_str::<serde_json::Value>(&text)
            .ok()
            .and_then(|v| {
                v.get("error")
                    .and_then(|e| e.get("message"))
                    .and_then(|m| m.as_str())
                    .map(|s| s.to_string())
            })
            .unwrap_or_else(|| text.chars().take(200).collect());
        Err(format!("HTTP {}: {}", status, detail))
    }
}

// ---------------------------------------------------------------------------
// Notification settings commands
// ---------------------------------------------------------------------------

/// Return the current notification settings.
#[tauri::command]
pub async fn get_notification_settings(
    _state: State<'_, AppState>,
) -> std::result::Result<NotificationSettingsInfo, String> {
    let main_config = load_main_config();
    let nc = &main_config.notifications;

    let (qh_enabled, qh_start_h, qh_start_m, qh_end_h, qh_end_m, qh_critical) =
        match &nc.quiet_hours {
            Some(qh) => (
                true,
                qh.start_hour,
                qh.start_minute,
                qh.end_hour,
                qh.end_minute,
                qh.allow_critical,
            ),
            None => (false, 22, 0, 8, 0, true),
        };

    Ok(NotificationSettingsInfo {
        preferred_channels: nc
            .preferred_channels
            .iter()
            .map(|k| format!("{k:?}"))
            .collect(),
        escalation_timeout_secs: nc.escalation_timeout_secs,
        quiet_hours_enabled: qh_enabled,
        quiet_start_hour: qh_start_h,
        quiet_start_minute: qh_start_m,
        quiet_end_hour: qh_end_h,
        quiet_end_minute: qh_end_m,
        quiet_allow_critical: qh_critical,
    })
}

/// Save notification delivery settings.
#[allow(clippy::too_many_arguments)]
#[tauri::command]
pub async fn save_notification_settings(
    _state: State<'_, AppState>,
    preferred_channels: Vec<String>,
    escalation_timeout_secs: u64,
    quiet_hours_enabled: bool,
    quiet_start_hour: Option<u32>,
    quiet_start_minute: Option<u32>,
    quiet_end_hour: Option<u32>,
    quiet_end_minute: Option<u32>,
    quiet_allow_critical: Option<bool>,
) -> std::result::Result<String, String> {
    let mut config = load_main_config();

    let channels: Vec<NotificationChannelKind> = preferred_channels
        .iter()
        .filter_map(|s| match s.to_lowercase().as_str() {
            "inapp" | "in_app" => Some(NotificationChannelKind::InApp),
            "telegram" => Some(NotificationChannelKind::Telegram),
            _ => None,
        })
        .collect();

    config.notifications = NotificationConfig {
        preferred_channels: if channels.is_empty() {
            vec![
                NotificationChannelKind::InApp,
                NotificationChannelKind::Telegram,
            ]
        } else {
            channels
        },
        escalation_timeout_secs,
        quiet_hours: if quiet_hours_enabled {
            Some(QuietHours {
                start_hour: quiet_start_hour.unwrap_or(22),
                start_minute: quiet_start_minute.unwrap_or(0),
                end_hour: quiet_end_hour.unwrap_or(8),
                end_minute: quiet_end_minute.unwrap_or(0),
                allow_critical: quiet_allow_critical.unwrap_or(true),
            })
        } else {
            None
        },
    };

    save_main_config(&config)?;
    Ok("Notification settings saved. Restart to apply.".to_string())
}

// ---------------------------------------------------------------------------
// Embedding settings commands
// ---------------------------------------------------------------------------

/// Save embedding / memory provider settings.
#[tauri::command]
pub async fn save_embedding_settings(
    _state: State<'_, AppState>,
    mode: String,
    provider: Option<String>,
    model: Option<String>,
    base_url: Option<String>,
    api_key: Option<String>,
) -> std::result::Result<String, String> {
    let mut config = load_main_config();

    config.embeddings.mode = match mode.as_str() {
        "Cloud" => EmbeddingMode::Cloud,
        "LocalOnly" => EmbeddingMode::LocalOnly,
        "Specific" => EmbeddingMode::Specific,
        "Off" => EmbeddingMode::Off,
        _ => EmbeddingMode::Automatic,
    };

    config.embeddings.provider = provider.filter(|s| !s.is_empty());
    config.embeddings.model = model.filter(|s| !s.is_empty());
    config.embeddings.base_url = base_url.filter(|s| !s.is_empty());

    // API key handling: None preserves existing, Some("") removes, Some("sk-...") updates.
    match api_key {
        Some(key) if key.is_empty() => {
            config.embeddings.api_key = None;
        }
        Some(key) => {
            config.embeddings.api_key = Some(key);
        }
        None => {
            // Preserve existing key.
        }
    }

    save_main_config(&config)?;
    Ok("Embedding settings saved. Restart to apply.".to_string())
}

/// Test connectivity to an embedding provider.
#[tauri::command]
pub async fn test_embedding_provider(
    _state: State<'_, AppState>,
    provider: String,
    model: Option<String>,
    base_url: Option<String>,
    api_key: Option<String>,
) -> std::result::Result<TestResult, String> {
    // Keyword provider always works — no connectivity needed.
    if provider == "keyword" {
        return Ok(TestResult {
            success: true,
            message: "Keyword fallback is always available.".to_string(),
        });
    }

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .build()
        .map_err(|e| format!("Failed to create HTTP client: {e}"))?;

    let result = match provider.as_str() {
        "ollama" => {
            let url = base_url.as_deref().unwrap_or("http://localhost:11434");
            let model_name = model.as_deref().unwrap_or("nomic-embed-text");
            test_ollama_embedding(&client, url, model_name).await
        }
        _ => {
            // OpenAI-compatible embedding endpoint.
            let url = base_url.as_deref().unwrap_or("https://api.openai.com");
            let model_name = model.as_deref().unwrap_or("text-embedding-3-small");
            let key = api_key
                .or_else(|| {
                    // Fall back to saved config key.
                    let cfg = load_main_config();
                    cfg.embeddings.api_key.clone()
                })
                .unwrap_or_default();
            test_openai_embedding(&client, url, model_name, &key).await
        }
    };

    match result {
        Ok(msg) => Ok(TestResult {
            success: true,
            message: msg,
        }),
        Err(msg) => Ok(TestResult {
            success: false,
            message: msg,
        }),
    }
}

async fn test_ollama_embedding(
    client: &reqwest::Client,
    base_url: &str,
    model: &str,
) -> Result<String, String> {
    let url = format!("{}/api/embed", base_url.trim_end_matches('/'));

    let body = serde_json::json!({
        "model": model,
        "input": "test embedding connection",
    });

    let resp = client
        .post(&url)
        .json(&body)
        .send()
        .await
        .map_err(|e| format!("Connection failed: {e}"))?;

    if resp.status().is_success() {
        Ok(format!(
            "Connected. Model '{}' is available for embeddings.",
            model
        ))
    } else {
        let status = resp.status();
        let text = resp.text().await.unwrap_or_default();
        Err(format!(
            "HTTP {}: {}",
            status,
            text.chars().take(200).collect::<String>()
        ))
    }
}

async fn test_openai_embedding(
    client: &reqwest::Client,
    base_url: &str,
    model: &str,
    api_key: &str,
) -> Result<String, String> {
    if api_key.is_empty() {
        return Err("API key is required for cloud embedding providers.".to_string());
    }

    let url = format!("{}/v1/embeddings", base_url.trim_end_matches('/'));

    let body = serde_json::json!({
        "model": model,
        "input": "test embedding connection",
    });

    let resp = client
        .post(&url)
        .header("Authorization", format!("Bearer {api_key}"))
        .header("Content-Type", "application/json")
        .json(&body)
        .send()
        .await
        .map_err(|e| format!("Connection failed: {e}"))?;

    if resp.status().is_success() {
        Ok(format!(
            "Connected. Model '{}' returned embeddings successfully.",
            model
        ))
    } else {
        let status = resp.status();
        let text = resp.text().await.unwrap_or_default();
        let detail = serde_json::from_str::<serde_json::Value>(&text)
            .ok()
            .and_then(|v| {
                v.get("error")
                    .and_then(|e| e.get("message"))
                    .and_then(|m| m.as_str())
                    .map(|s| s.to_string())
            })
            .unwrap_or_else(|| text.chars().take(200).collect());
        Err(format!("HTTP {}: {}", status, detail))
    }
}

// ---------------------------------------------------------------------------
// Provider-specific test functions (continued)
// ---------------------------------------------------------------------------

async fn test_anthropic(
    client: &reqwest::Client,
    base_url: &str,
    api_key: &str,
    model: &str,
) -> Result<String, String> {
    if api_key.is_empty() {
        return Err("API key is required.".to_string());
    }

    let url = format!("{}/v1/messages", base_url.trim_end_matches('/'));

    let body = serde_json::json!({
        "model": model,
        "max_tokens": 10,
        "messages": [{"role": "user", "content": "Say hello in one word."}],
    });

    let resp = client
        .post(&url)
        .header("x-api-key", api_key)
        .header("anthropic-version", "2023-06-01")
        .header("Content-Type", "application/json")
        .json(&body)
        .send()
        .await
        .map_err(|e| format!("Connection failed: {e}"))?;

    if resp.status().is_success() {
        Ok(format!("Connected to {} successfully.", model))
    } else {
        let status = resp.status();
        let text = resp.text().await.unwrap_or_default();
        let detail = serde_json::from_str::<serde_json::Value>(&text)
            .ok()
            .and_then(|v| {
                v.get("error")
                    .and_then(|e| e.get("message"))
                    .and_then(|m| m.as_str())
                    .map(|s| s.to_string())
            })
            .unwrap_or_else(|| text.chars().take(200).collect());
        Err(format!("HTTP {}: {}", status, detail))
    }
}

// ---------------------------------------------------------------------------
// Tests — onboarding non-destructive behavior
// ---------------------------------------------------------------------------
//
// These tests target the path-explicit helpers (`is_first_launch_in`,
// `mark_onboarded_in`) so they can run with isolated `TempDir` state
// without mutating the global `HOME` env var. The Tauri commands wrapping
// these helpers just resolve `~/.athen/` from `HOME` and delegate.

#[cfg(test)]
mod onboarding_tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn fresh_dir() -> TempDir {
        TempDir::new().expect("tempdir creation should never fail in tests")
    }

    fn write_models_with_provider(dir: &std::path::Path, provider_id: &str) {
        let toml = format!(
            r#"
[providers.{provider_id}]
auth = {{ ApiKey = "sk-test-not-real" }}
default_model = "test-model"
"#
        );
        fs::write(dir.join("models.toml"), toml).unwrap();
    }

    // ── is_first_launch_in: positive cases ─────────────────────────────────

    #[test]
    fn empty_dir_is_first_launch() {
        let dir = fresh_dir();
        assert!(
            is_first_launch_in(dir.path()),
            "fresh empty directory should be detected as first launch"
        );
    }

    #[test]
    fn nonexistent_dir_is_first_launch() {
        // Predicate must not panic on a path that doesn't exist yet —
        // `ensure_athen_dir` would have created it before this fires, but
        // defense in depth: confirm the predicate itself is robust.
        let parent = fresh_dir();
        let ghost = parent.path().join("nonexistent-subdir");
        assert!(
            is_first_launch_in(&ghost),
            "missing directory should be treated as first launch"
        );
    }

    // ── is_first_launch_in: sentinel takes priority ────────────────────────

    #[test]
    fn sentinel_suppresses_first_launch() {
        let dir = fresh_dir();
        fs::write(dir.path().join(ONBOARDED_SENTINEL), b"").unwrap();
        assert!(
            !is_first_launch_in(dir.path()),
            "sentinel must always suppress onboarding"
        );
    }

    #[test]
    fn sentinel_suppresses_even_when_models_toml_is_empty() {
        // Belt and suspenders: sentinel + empty models. Sentinel wins.
        let dir = fresh_dir();
        fs::write(dir.path().join(ONBOARDED_SENTINEL), b"").unwrap();
        fs::write(dir.path().join("models.toml"), b"").unwrap();
        assert!(!is_first_launch_in(dir.path()));
    }

    // ── is_first_launch_in: returning users without sentinel ───────────────

    #[test]
    fn existing_provider_in_models_suppresses_onboarding() {
        let dir = fresh_dir();
        write_models_with_provider(dir.path(), "deepseek");
        assert!(
            !is_first_launch_in(dir.path()),
            "a configured provider must suppress onboarding even without sentinel"
        );
    }

    #[test]
    fn empty_models_toml_suppresses_onboarding() {
        // A models.toml exists but has no providers. Conservative: assume
        // the user got partway through some prior setup; don't re-prompt.
        let dir = fresh_dir();
        fs::write(dir.path().join("models.toml"), b"").unwrap();
        assert!(!is_first_launch_in(dir.path()));
    }

    #[test]
    fn malformed_models_toml_suppresses_onboarding() {
        // Better to leave a corrupt config alone than to walk a user
        // through onboarding that might overwrite it.
        let dir = fresh_dir();
        fs::write(
            dir.path().join("models.toml"),
            b"this is not valid {[ toml ]]",
        )
        .unwrap();
        assert!(
            !is_first_launch_in(dir.path()),
            "malformed config must NOT trigger onboarding (don't overwrite user data)"
        );
    }

    #[test]
    fn config_toml_alone_suppresses_onboarding() {
        // User has main config but never configured an LLM via UI (e.g.
        // they set a key via env var). They're a returning user.
        let dir = fresh_dir();
        fs::write(dir.path().join("config.toml"), b"# real config goes here").unwrap();
        assert!(!is_first_launch_in(dir.path()));
    }

    // ── mark_onboarded_in ──────────────────────────────────────────────────

    #[test]
    fn mark_onboarded_writes_sentinel() {
        let dir = fresh_dir();
        mark_onboarded_in(dir.path()).unwrap();
        assert!(dir.path().join(ONBOARDED_SENTINEL).exists());
    }

    #[test]
    fn mark_onboarded_creates_missing_dir() {
        let parent = fresh_dir();
        let nested = parent.path().join("does-not-exist-yet");
        mark_onboarded_in(&nested).unwrap();
        assert!(nested.join(ONBOARDED_SENTINEL).exists());
    }

    #[test]
    fn mark_onboarded_is_idempotent() {
        let dir = fresh_dir();
        mark_onboarded_in(dir.path()).unwrap();
        mark_onboarded_in(dir.path()).unwrap();
        mark_onboarded_in(dir.path()).unwrap();
        assert!(dir.path().join(ONBOARDED_SENTINEL).exists());
    }

    #[test]
    fn mark_onboarded_does_not_touch_other_files() {
        // CRITICAL: completing onboarding must never overwrite or delete
        // an existing models.toml, config.toml, or any other state.
        let dir = fresh_dir();
        write_models_with_provider(dir.path(), "anthropic");
        let original_models = fs::read(dir.path().join("models.toml")).unwrap();

        fs::write(dir.path().join("config.toml"), b"# user main config").unwrap();
        let original_config = fs::read(dir.path().join("config.toml")).unwrap();

        let user_db_path = dir.path().join("athen.db");
        fs::write(&user_db_path, b"pretend-sqlite-bytes").unwrap();
        let original_db = fs::read(&user_db_path).unwrap();

        mark_onboarded_in(dir.path()).unwrap();

        assert_eq!(
            fs::read(dir.path().join("models.toml")).unwrap(),
            original_models,
            "models.toml must not be modified by onboarding completion"
        );
        assert_eq!(
            fs::read(dir.path().join("config.toml")).unwrap(),
            original_config,
            "config.toml must not be modified by onboarding completion"
        );
        assert_eq!(
            fs::read(&user_db_path).unwrap(),
            original_db,
            "user database must not be modified by onboarding completion"
        );
    }

    // ── End-to-end flow: completing onboarding suppresses re-prompts ───────

    #[test]
    fn completing_onboarding_suppresses_future_prompts() {
        let dir = fresh_dir();
        assert!(is_first_launch_in(dir.path()), "starts as first launch");

        mark_onboarded_in(dir.path()).unwrap();
        assert!(
            !is_first_launch_in(dir.path()),
            "after completion, must never prompt again"
        );

        // And again on a "subsequent boot"
        assert!(!is_first_launch_in(dir.path()));
    }

    #[test]
    fn returning_user_with_provider_then_marked_onboarded_stays_marked() {
        // Migration scenario: pre-sentinel returning user with a configured
        // provider. is_first_launch_in returns false (provider present).
        // The frontend may then call complete_onboarding to upgrade them
        // to the sentinel. After that, the sentinel keeps things stable
        // even if their models.toml is later edited or briefly empty.
        let dir = fresh_dir();
        write_models_with_provider(dir.path(), "openai");
        assert!(!is_first_launch_in(dir.path()));

        mark_onboarded_in(dir.path()).unwrap();

        // Simulate the user clearing their provider config later.
        fs::write(dir.path().join("models.toml"), b"").unwrap();
        assert!(
            !is_first_launch_in(dir.path()),
            "sentinel must override an emptied models.toml — \
             a user clearing their config should NOT re-trigger onboarding"
        );
    }

    #[test]
    fn save_then_complete_then_verify_provider_intact() {
        // Realistic flow: wizard saves a provider, then immediately calls
        // complete_onboarding. The provider must survive completion.
        let dir = fresh_dir();
        write_models_with_provider(dir.path(), "deepseek");
        let snapshot = fs::read_to_string(dir.path().join("models.toml")).unwrap();

        mark_onboarded_in(dir.path()).unwrap();

        let after = fs::read_to_string(dir.path().join("models.toml")).unwrap();
        assert_eq!(snapshot, after);
        let parsed: ModelsConfig = toml::from_str(&after).unwrap();
        assert!(parsed.providers.contains_key("deepseek"));
    }
}
