use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};

/// Helper to format a redacted secret field for Debug output.
/// Shows "[REDACTED]" if the value is non-empty, "\"\"" if empty.
fn redact(secret: &str) -> &'static str {
    if secret.is_empty() {
        "\"\""
    } else {
        "[REDACTED]"
    }
}

/// Helper to format an optional redacted secret field for Debug output.
fn redact_option(secret: &Option<String>) -> &'static str {
    match secret {
        Some(s) if !s.is_empty() => "Some([REDACTED])",
        Some(_) => "Some(\"\")",
        None => "None",
    }
}

pub(crate) const KEYCHAIN_SERVICE: &str = "aidaemon";

/// Expand `${ENV_VAR}` references in a string.
/// Returns an error listing all undefined variables (does not fail on first miss).
pub fn expand_env_vars(content: &str) -> anyhow::Result<String> {
    let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
    let mut missing: Vec<String> = Vec::new();
    let result = re.replace_all(content, |caps: &regex::Captures| {
        let var_name = &caps[1];
        match std::env::var(var_name) {
            Ok(val) => val,
            Err(_) => {
                missing.push(var_name.to_string());
                caps[0].to_string()
            }
        }
    });
    if !missing.is_empty() {
        anyhow::bail!(
            "Config references undefined environment variable(s): {}",
            missing.join(", ")
        );
    }
    Ok(result.into_owned())
}

/// Returns true if keychain access is disabled via `AIDAEMON_NO_KEYCHAIN=1`.
fn keychain_disabled() -> bool {
    std::env::var("AIDAEMON_NO_KEYCHAIN").is_ok_and(|v| v == "1" || v == "true")
}

fn resolve_env_file_path() -> PathBuf {
    if let Ok(path) = std::env::var(crate::RUNTIME_ENV_FILE_ENV_KEY) {
        if !path.trim().is_empty() {
            return PathBuf::from(path);
        }
    }
    if let Ok(path) = std::env::var("AIDAEMON_ENV_FILE") {
        if !path.trim().is_empty() {
            return PathBuf::from(path);
        }
    }
    PathBuf::from(".env")
}

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

fn is_env_key_assignment(line: &str, env_key: &str) -> bool {
    let trimmed = line.trim_start();
    if trimmed.starts_with('#') {
        return false;
    }
    let Some((k, _)) = trimmed.split_once('=') else {
        return false;
    };
    k.trim() == env_key
}

fn encode_env_value(value: &str) -> String {
    let is_simple = value.bytes().all(|b| {
        matches!(
            b,
            b'A'..=b'Z'
                | b'a'..=b'z'
                | b'0'..=b'9'
                | b'_'
                | b'-'
                | b'.'
                | b'/'
                | b':'
                | b'@'
                | b'+'
                | b'='
        )
    });
    if is_simple {
        return value.to_string();
    }
    let mut escaped = String::with_capacity(value.len());
    for ch in value.chars() {
        match ch {
            '\\' => escaped.push_str("\\\\"),
            '"' => escaped.push_str("\\\""),
            '$' => escaped.push_str("\\$"),
            '\n' => escaped.push_str("\\n"),
            '\r' => escaped.push_str("\\r"),
            _ => escaped.push(ch),
        }
    }
    format!("\"{}\"", escaped)
}

#[cfg(unix)]
fn set_file_mode_0600(path: &Path) -> anyhow::Result<()> {
    use std::os::unix::fs::PermissionsExt;
    let mut perms = std::fs::metadata(path)?.permissions();
    perms.set_mode(0o600);
    std::fs::set_permissions(path, perms)?;
    Ok(())
}

#[cfg(not(unix))]
fn set_file_mode_0600(_path: &Path) -> anyhow::Result<()> {
    Ok(())
}

fn upsert_env_secret(field_name: &str, value: &str) -> anyhow::Result<()> {
    let _guard = env_file_lock()
        .lock()
        .map_err(|_| anyhow::anyhow!("Failed to lock env file writer"))?;
    let env_path = resolve_env_file_path();
    let env_key = field_name.to_uppercase();
    let assignment = format!("{}={}", env_key, encode_env_value(value));

    let mut lines: Vec<String> = if env_path.exists() {
        std::fs::read_to_string(&env_path)?
            .lines()
            .map(|l| l.to_string())
            .collect()
    } else {
        Vec::new()
    };

    let mut replaced = false;
    for line in &mut lines {
        if is_env_key_assignment(line, &env_key) {
            *line = assignment.clone();
            replaced = true;
        }
    }
    if !replaced {
        lines.push(assignment);
    }

    if let Some(parent) = env_path.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent)?;
        }
    }

    let mut body = lines.join("\n");
    if !body.is_empty() {
        body.push('\n');
    }
    std::fs::write(&env_path, body)?;
    set_file_mode_0600(&env_path)?;
    Ok(())
}

fn remove_env_secret(field_name: &str) -> anyhow::Result<()> {
    let _guard = env_file_lock()
        .lock()
        .map_err(|_| anyhow::anyhow!("Failed to lock env file writer"))?;
    let env_path = resolve_env_file_path();
    if !env_path.exists() {
        return Ok(());
    }

    let env_key = field_name.to_uppercase();
    let mut lines: Vec<String> = std::fs::read_to_string(&env_path)?
        .lines()
        .map(|l| l.to_string())
        .collect();
    let original_len = lines.len();
    lines.retain(|line| !is_env_key_assignment(line, &env_key));

    if lines.len() == original_len {
        return Ok(());
    }

    let mut body = lines.join("\n");
    if !body.is_empty() {
        body.push('\n');
    }
    std::fs::write(&env_path, body)?;
    set_file_mode_0600(&env_path)?;
    Ok(())
}

fn decode_env_value_loose(raw: &str) -> String {
    let trimmed = raw.trim();
    let inner = if (trimmed.starts_with('"') && trimmed.ends_with('"'))
        || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
    {
        &trimmed[1..trimmed.len().saturating_sub(1)]
    } else {
        trimmed
    };

    let mut out = String::with_capacity(inner.len());
    let mut chars = inner.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch == '\\' {
            match chars.next() {
                Some('n') => out.push('\n'),
                Some('r') => out.push('\r'),
                Some('"') => out.push('"'),
                Some('\\') => out.push('\\'),
                Some('$') => out.push('$'),
                Some(other) => {
                    out.push('\\');
                    out.push(other);
                }
                None => out.push('\\'),
            }
        } else {
            out.push(ch);
        }
    }
    out
}

fn read_env_secret_from_file(env_path: &Path, env_key: &str) -> anyhow::Result<Option<String>> {
    if !env_path.exists() {
        return Ok(None);
    }

    let mut found_via_dotenv: Option<String> = None;
    if let Ok(iter) = dotenvy::from_path_iter(env_path) {
        for entry in iter {
            match entry {
                Ok((key, value)) => {
                    if key == env_key {
                        found_via_dotenv = Some(value);
                    }
                }
                Err(_) => {
                    // Ignore malformed lines and continue parsing other entries.
                    continue;
                }
            }
        }
    }
    if found_via_dotenv.is_some() {
        return Ok(found_via_dotenv);
    }

    let content = std::fs::read_to_string(env_path)?;
    let mut found_via_loose_scan: Option<String> = None;
    for line in content.lines() {
        if is_env_key_assignment(line, env_key) {
            if let Some((_, raw_value)) = line.split_once('=') {
                found_via_loose_scan = Some(decode_env_value_loose(raw_value));
            }
        }
    }
    Ok(found_via_loose_scan)
}

pub fn resolve_from_keychain(field_name: &str) -> anyhow::Result<String> {
    let env_key = field_name.to_uppercase();

    if keychain_disabled() {
        let env_path = resolve_env_file_path();
        if env_path.exists() {
            if let Some(val) = read_env_secret_from_file(&env_path, &env_key)? {
                if !val.is_empty() {
                    return Ok(val);
                }
            }
            anyhow::bail!(
                "Keychain disabled and env var '{}' not found in '{}'",
                env_key,
                env_path.display()
            );
        }

        if let Ok(val) = std::env::var(&env_key) {
            if !val.is_empty() {
                return Ok(val);
            }
        }

        anyhow::bail!(
            "Keychain disabled and env var '{}' not set for '{}'",
            env_key,
            field_name
        );
    }
    let entry = keyring::Entry::new(KEYCHAIN_SERVICE, field_name)?;
    entry
        .get_password()
        .map_err(|e| anyhow::anyhow!("Failed to read '{}' from OS keychain: {}", field_name, e))
}

/// Store a secret in the OS keychain under the `aidaemon` service.
pub fn store_in_keychain(field_name: &str, value: &str) -> anyhow::Result<()> {
    if keychain_disabled() {
        return upsert_env_secret(field_name, value);
    }
    let entry = keyring::Entry::new(KEYCHAIN_SERVICE, field_name)?;
    entry.set_password(value)?;
    Ok(())
}

/// Delete a secret from the OS keychain.
pub fn delete_from_keychain(field_name: &str) -> anyhow::Result<()> {
    if keychain_disabled() {
        return remove_env_secret(field_name);
    }
    let entry = keyring::Entry::new(KEYCHAIN_SERVICE, field_name)?;
    entry
        .delete_credential()
        .map_err(|e| anyhow::anyhow!("Failed to delete '{}' from OS keychain: {}", field_name, e))
}

#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
    pub provider: ProviderConfig,
    /// Legacy single Telegram bot config (for backward compatibility).
    #[serde(default)]
    pub telegram: Option<TelegramConfig>,
    /// Array of named Telegram bots (preferred for multi-bot setups).
    #[serde(default)]
    pub telegram_bots: Vec<TelegramBotConfig>,
    /// Global webhook defaults — auto-applied to any Telegram bot without
    /// an explicit `[webhook]` section with `enabled = true`.
    #[serde(default)]
    pub telegram_webhook_defaults: TelegramWebhookDefaults,
    #[cfg(feature = "discord")]
    #[serde(default)]
    pub discord: Option<DiscordConfig>,
    /// Array of named Discord bots (preferred for multi-bot setups).
    #[cfg(feature = "discord")]
    #[serde(default)]
    pub discord_bots: Vec<DiscordBotConfig>,
    #[cfg(feature = "slack")]
    #[serde(default)]
    pub slack: Option<SlackConfig>,
    /// Array of named Slack bots (preferred for multi-bot setups).
    #[cfg(feature = "slack")]
    #[serde(default)]
    pub slack_bots: Vec<SlackBotConfig>,
    #[serde(default)]
    pub state: StateConfig,
    #[serde(default)]
    pub terminal: TerminalConfig,
    #[serde(default)]
    pub path_aliases: PathAliasConfig,
    #[serde(default)]
    pub daemon: DaemonConfig,
    #[serde(default)]
    pub triggers: TriggersConfig,
    #[serde(default)]
    pub mcp: HashMap<String, McpServerConfig>,
    #[serde(default)]
    pub browser: BrowserConfig,
    #[serde(default)]
    pub computer_use: ComputerUseConfig,
    #[serde(default)]
    pub skills: SkillsConfig,
    #[serde(default)]
    pub subagents: SubagentsConfig,
    #[serde(default)]
    pub cli_agents: CliAgentsConfig,
    #[serde(default)]
    pub search: SearchConfig,
    #[serde(default)]
    pub files: FilesConfig,
    #[serde(default)]
    pub health: HealthConfig,
    #[serde(default)]
    pub updates: UpdateConfig,
    #[serde(default)]
    pub users: UsersConfig,
    #[serde(default)]
    pub people: PeopleConfig,
    #[serde(default)]
    pub oauth: OAuthConfig,
    #[serde(default)]
    pub http_auth: HashMap<String, HttpAuthProfile>,
    #[serde(default)]
    pub heartbeat: HeartbeatConfig,
    #[serde(default)]
    pub policy: PolicyConfig,
    #[serde(default)]
    pub tools: ToolsConfig,
    #[serde(default)]
    pub diagnostics: DiagnosticsConfig,
}

#[derive(Deserialize, Clone)]
pub struct ProviderConfig {
    #[serde(default)]
    pub kind: ProviderKind,
    pub api_key: String,
    #[serde(default)]
    pub gateway_token: Option<String>,
    #[serde(default = "default_base_url")]
    pub base_url: String,
    #[serde(default)]
    pub extra_headers: Option<HashMap<String, String>>,
    #[serde(default)]
    pub max_tokens: Option<u32>,
    /// Reasoning effort level for thinking models. When set, includes the
    /// `reasoning` parameter in requests to enable thinking/reasoning tokens.
    /// Values: "low", "medium", "high"
    #[serde(default)]
    pub reasoning_effort: Option<String>,
    #[serde(default)]
    pub models: ModelsConfig,
    /// Ordered cross-provider fallback chain.
    /// TOML key: `[[provider.fallbacks]]`
    #[serde(default, alias = "failover")]
    pub fallbacks: Vec<ProviderConfig>,
    /// Opt-in llama.cpp slot routing (`id_slot` pinning). Disabled by default.
    #[serde(default)]
    pub slot_routing: SlotRoutingConfig,
}

impl fmt::Debug for ProviderConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("ProviderConfig")
            .field("kind", &self.kind)
            .field("api_key", &redact(&self.api_key))
            .field("gateway_token", &redact_option(&self.gateway_token))
            .field("base_url", &self.base_url)
            .field(
                "extra_headers",
                &self
                    .extra_headers
                    .as_ref()
                    .map(|h| h.keys().map(String::as_str).collect::<Vec<_>>()),
            )
            .field("max_tokens", &self.max_tokens)
            .field("models", &self.models)
            .field("fallback_provider_count", &self.fallbacks.len())
            .field("slot_routing", &self.slot_routing)
            .finish()
    }
}

impl ProviderConfig {
    pub fn apply_model_defaults_recursive(&mut self) {
        self.models.apply_defaults(&self.kind);
        for fallback in &mut self.fallbacks {
            fallback.apply_model_defaults_recursive();
        }
    }

    fn resolve_secrets_recursive(&mut self, key_prefix: Option<&str>) -> anyhow::Result<()> {
        let api_key_key = key_prefix
            .map(|prefix| format!("{}_api_key", prefix))
            .unwrap_or_else(|| "api_key".to_string());
        if self.api_key == "keychain" {
            self.api_key = resolve_from_keychain(&api_key_key)?;
        }

        let gateway_token_key = key_prefix
            .map(|prefix| format!("{}_gateway_token", prefix))
            .unwrap_or_else(|| "gateway_token".to_string());
        if self.gateway_token.as_deref() == Some("keychain") {
            self.gateway_token = Some(resolve_from_keychain(&gateway_token_key)?);
        }

        if let Some(headers) = self.extra_headers.as_mut() {
            for (name, value) in headers.iter_mut() {
                if value == "keychain" {
                    let normalized_name = name
                        .to_ascii_lowercase()
                        .chars()
                        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
                        .collect::<String>();
                    let key = key_prefix
                        .map(|prefix| format!("{}_header_{}", prefix, normalized_name))
                        .unwrap_or_else(|| format!("provider_header_{}", normalized_name));
                    *value = resolve_from_keychain(&key)?;
                }
            }
        }

        for (idx, fallback) in self.fallbacks.iter_mut().enumerate() {
            let nested_prefix = key_prefix
                .map(|prefix| format!("{}_fallback_{}", prefix, idx))
                .unwrap_or_else(|| format!("provider_fallback_{}", idx));
            fallback.resolve_secrets_recursive(Some(&nested_prefix))?;
        }

        Ok(())
    }
}

#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
    #[default]
    OpenaiCompatible,
    XaiNative,
    GoogleGenai,
    Anthropic,
}

fn default_true() -> bool {
    true
}

fn default_base_url() -> String {
    "https://api.openai.com/v1".to_string()
}

fn default_background_slot() -> u32 {
    1
}

/// Opt-in llama.cpp slot routing (`id_slot` pinning).
///
/// When a local llama.cpp server runs with `--parallel N` (N KV cache slots),
/// llama assigns each request to the best-prefix-match-or-LRU slot by default.
/// Constant background tasks (memory consolidation, summarization, extraction)
/// then evict the idle interactive conversation's slot, forcing a full
/// re-evaluation on the user's next turn.
///
/// When `enabled`, the interactive generation loop is pinned to
/// `interactive_slot` and every other call defaults to `background_slot`,
/// keeping the conversation's KV cache warm. The `id_slot` request field is
/// ONLY emitted when `enabled == true` — cloud APIs reject unknown params, so
/// the default (disabled) is safe for all providers.
#[derive(Debug, Deserialize, Clone)]
pub struct SlotRoutingConfig {
    /// Master switch. Default `false` (no `id_slot` ever sent).
    #[serde(default)]
    pub enabled: bool,
    /// Slot pinned for the interactive conversation. Default `0`.
    #[serde(default)]
    pub interactive_slot: u32,
    /// Slot used for all background/non-interactive calls. Default `1`.
    #[serde(default = "default_background_slot")]
    pub background_slot: u32,
}

impl Default for SlotRoutingConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            interactive_slot: 0,
            background_slot: default_background_slot(),
        }
    }
}

#[derive(Debug, Deserialize, Clone, Default)]
pub struct ModelsConfig {
    /// Canonical default model used for normal requests.
    /// TOML key: `provider.models.default`
    #[serde(default, rename = "default")]
    pub default_model: String,
    /// Ordered fallback models used only when calls fail.
    /// TOML key: `provider.models.fallback = [\"...\"]`
    #[serde(default, rename = "fallback")]
    pub fallback_models: Vec<String>,
    /// Legacy key kept for backward compatibility.
    #[serde(default)]
    pub primary: String,
    /// Legacy key kept for backward compatibility.
    #[serde(default)]
    pub fast: String,
    /// Legacy key kept for backward compatibility.
    #[serde(default)]
    pub smart: String,
}

impl ModelsConfig {
    /// Normalize legacy and new model keys into:
    /// - `default_model`
    /// - `fallback_models` (ordered, deduped, excluding default)
    ///
    /// Legacy fields (`primary`, `fast`, `smart`) remain populated for
    /// compatibility with older code paths and existing config tooling.
    pub fn apply_defaults(&mut self, provider_kind: &ProviderKind) {
        let provider_default = match provider_kind {
            ProviderKind::GoogleGenai => "gemini-3-flash-preview".to_string(),
            ProviderKind::Anthropic => "claude-sonnet-4-20250514".to_string(),
            ProviderKind::XaiNative => "grok-4".to_string(),
            ProviderKind::OpenaiCompatible => "openai/gpt-4o".to_string(),
        };

        let default_model = if !self.default_model.trim().is_empty() {
            self.default_model.trim().to_string()
        } else if !self.primary.trim().is_empty() {
            self.primary.trim().to_string()
        } else {
            provider_default
        };

        // If fallback[] was not explicitly provided, derive it from legacy tier keys.
        if self.fallback_models.is_empty() {
            for legacy in [&self.smart, &self.fast] {
                let candidate = legacy.trim();
                if candidate.is_empty() || candidate == default_model {
                    continue;
                }
                if !self.fallback_models.iter().any(|m| m == candidate) {
                    self.fallback_models.push(candidate.to_string());
                }
            }
        } else {
            // Sanitize explicit fallback[].
            let mut deduped = Vec::new();
            for raw in &self.fallback_models {
                let candidate = raw.trim();
                if candidate.is_empty() || candidate == default_model {
                    continue;
                }
                if !deduped.iter().any(|m: &String| m == candidate) {
                    deduped.push(candidate.to_string());
                }
            }
            self.fallback_models = deduped;
        }

        self.default_model = default_model.clone();
        self.primary = default_model.clone();

        let first_fallback = self
            .fallback_models
            .first()
            .cloned()
            .unwrap_or_else(|| default_model.clone());
        if self.fast.trim().is_empty() {
            self.fast = first_fallback.clone();
        }
        if self.smart.trim().is_empty() {
            self.smart = first_fallback;
        }
    }
}

/// Single Telegram bot configuration (legacy format).
#[derive(Deserialize, Clone)]
pub struct TelegramConfig {
    pub bot_token: String,
    #[serde(default)]
    pub allowed_user_ids: Vec<u64>,
    #[serde(default)]
    pub webhook: TelegramWebhookConfig,
}

impl fmt::Debug for TelegramConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("TelegramConfig")
            .field("bot_token", &redact(&self.bot_token))
            .field("allowed_user_ids", &self.allowed_user_ids)
            .field("webhook", &self.webhook)
            .finish()
    }
}

/// Global defaults for Telegram webhook mode. When `enabled = true`, any
/// Telegram bot (config-based or dynamic) that does NOT have an explicit
/// `[webhook]` section with `enabled = true` will auto-derive webhook
/// settings from these defaults at startup.
#[derive(Debug, Deserialize, Clone)]
pub struct TelegramWebhookDefaults {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub base_domain: Option<String>,
    #[serde(default = "default_webhook_port_start")]
    pub port_start: u16,
    #[serde(default = "default_webhook_max_connections")]
    pub max_connections: u8,
    #[serde(default)]
    pub drop_pending_updates: bool,
    #[serde(default = "default_true")]
    pub bind_local_only: bool,
}

fn default_webhook_port_start() -> u16 {
    8443
}

fn default_webhook_max_connections() -> u8 {
    40
}

impl Default for TelegramWebhookDefaults {
    fn default() -> Self {
        Self {
            enabled: false,
            base_domain: None,
            port_start: 8443,
            max_connections: 40,
            drop_pending_updates: false,
            bind_local_only: true,
        }
    }
}

impl TelegramWebhookDefaults {
    /// Derive a full `TelegramWebhookConfig` for a bot given its slug and port.
    pub fn derive_webhook_config(&self, slug: &str, port: u16) -> TelegramWebhookConfig {
        let listen_host = if self.bind_local_only {
            "127.0.0.1"
        } else {
            "0.0.0.0"
        };
        let path = format!("/telegram/{}", slug);
        let public_url = self
            .base_domain
            .as_ref()
            .map(|domain| format!("https://{}.{}{}", slug, domain, path));
        TelegramWebhookConfig {
            enabled: true,
            public_url,
            listen_addr: Some(format!("{}:{}", listen_host, port)),
            path: Some(path),
            max_connections: Some(self.max_connections),
            drop_pending_updates: self.drop_pending_updates,
        }
    }

    /// Find the next available port starting from `port_start`, skipping occupied ones.
    pub fn next_available_port(&self, occupied: &mut std::collections::HashSet<u16>) -> u16 {
        let mut port = self.port_start;
        while occupied.contains(&port) {
            port = port.saturating_add(1);
        }
        occupied.insert(port);
        port
    }
}

#[derive(Debug, Deserialize, Clone, Default)]
pub struct TelegramWebhookConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub public_url: Option<String>,
    #[serde(default)]
    pub listen_addr: Option<String>,
    #[serde(default)]
    pub path: Option<String>,
    #[serde(default)]
    pub max_connections: Option<u8>,
    #[serde(default)]
    pub drop_pending_updates: bool,
}

/// Telegram bot configuration for multi-bot support.
/// Bot username is auto-detected from Telegram API at startup.
#[derive(Deserialize, Clone)]
pub struct TelegramBotConfig {
    pub bot_token: String,
    #[serde(default)]
    pub allowed_user_ids: Vec<u64>,
    #[serde(default)]
    pub webhook: TelegramWebhookConfig,
}

impl fmt::Debug for TelegramBotConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("TelegramBotConfig")
            .field("bot_token", &redact(&self.bot_token))
            .field("allowed_user_ids", &self.allowed_user_ids)
            .field("webhook", &self.webhook)
            .finish()
    }
}

#[cfg(feature = "discord")]
#[derive(Deserialize, Clone, Default)]
pub struct DiscordConfig {
    #[serde(default)]
    pub bot_token: String,
    #[serde(default)]
    pub allowed_user_ids: Vec<u64>,
    #[serde(default)]
    pub guild_id: Option<u64>,
}

#[cfg(feature = "discord")]
impl fmt::Debug for DiscordConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("DiscordConfig")
            .field("bot_token", &redact(&self.bot_token))
            .field("allowed_user_ids", &self.allowed_user_ids)
            .field("guild_id", &self.guild_id)
            .finish()
    }
}

/// Discord bot configuration for multi-bot support.
/// Bot username is auto-detected from Discord API at startup.
#[cfg(feature = "discord")]
#[derive(Deserialize, Clone)]
pub struct DiscordBotConfig {
    pub bot_token: String,
    #[serde(default)]
    pub allowed_user_ids: Vec<u64>,
    #[serde(default)]
    pub guild_id: Option<u64>,
}

#[cfg(feature = "discord")]
impl fmt::Debug for DiscordBotConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("DiscordBotConfig")
            .field("bot_token", &redact(&self.bot_token))
            .field("allowed_user_ids", &self.allowed_user_ids)
            .field("guild_id", &self.guild_id)
            .finish()
    }
}

#[cfg(feature = "slack")]
#[derive(Deserialize, Clone, Default)]
pub struct SlackConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub app_token: String,
    #[serde(default)]
    pub bot_token: String,
    #[serde(default)]
    pub allowed_user_ids: Vec<String>,
    #[serde(default = "default_slack_use_threads")]
    pub use_threads: bool,
}

#[cfg(feature = "slack")]
impl fmt::Debug for SlackConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SlackConfig")
            .field("enabled", &self.enabled)
            .field("app_token", &redact(&self.app_token))
            .field("bot_token", &redact(&self.bot_token))
            .field("allowed_user_ids", &self.allowed_user_ids)
            .field("use_threads", &self.use_threads)
            .finish()
    }
}

#[cfg(feature = "slack")]
fn default_slack_use_threads() -> bool {
    true
}

/// Slack bot configuration for multi-bot support.
/// Bot name is auto-detected from Slack API at startup.
#[cfg(feature = "slack")]
#[derive(Deserialize, Clone)]
pub struct SlackBotConfig {
    pub app_token: String,
    pub bot_token: String,
    #[serde(default)]
    pub allowed_user_ids: Vec<String>,
    #[serde(default = "default_slack_use_threads")]
    pub use_threads: bool,
}

#[cfg(feature = "slack")]
impl fmt::Debug for SlackBotConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SlackBotConfig")
            .field("app_token", &redact(&self.app_token))
            .field("bot_token", &redact(&self.bot_token))
            .field("allowed_user_ids", &self.allowed_user_ids)
            .field("use_threads", &self.use_threads)
            .finish()
    }
}

#[derive(Deserialize, Clone)]
pub struct StateConfig {
    #[serde(default = "default_db_path")]
    pub db_path: String,
    #[serde(default = "default_working_memory_cap")]
    pub working_memory_cap: usize,
    #[serde(default = "default_consolidation_interval_hours")]
    pub consolidation_interval_hours: u64,
    /// Encryption key for SQLCipher database encryption.
    /// Requires the `encryption` cargo feature to be enabled.
    /// If set, all database contents are AES-256 encrypted at rest.
    #[serde(default)]
    pub encryption_key: Option<String>,
    /// Maximum number of facts to inject into the system prompt.
    /// Facts are ordered by most recently updated first; older facts
    /// remain in the database but are not included in the prompt.
    #[serde(default = "default_max_facts")]
    pub max_facts: usize,
    /// Optional daily token budget. When set, cumulative daily usage is checked
    /// before additional LLM work; productive runs may auto-extend temporarily,
    /// otherwise further calls are rejected. Resets at midnight UTC.
    #[serde(default)]
    pub daily_token_budget: Option<u64>,
    /// Retention policies for automatic cleanup of old data.
    #[serde(default)]
    pub retention: RetentionConfig,
    /// Context window management (trimming, summarization, progressive facts).
    #[serde(default)]
    pub context_window: ContextWindowConfig,
}

/// Retention policies for automatic cleanup of old data.
/// All fields are in days. Set to 0 to disable cleanup for that table.
#[derive(Debug, Deserialize, Clone)]
pub struct RetentionConfig {
    /// Delete consolidated messages older than N days (default: 90)
    #[serde(default = "default_retention_90")]
    pub messages_days: u32,
    /// Delete superseded fact versions older than N days (default: 90)
    #[serde(default = "default_retention_90")]
    pub superseded_facts_days: u32,
    /// Aggregate raw token_usage into daily summaries after N days (default: 30)
    #[serde(default = "default_retention_30")]
    pub token_usage_aggregate_days: u32,
    /// Delete episodes with recall_count=0 older than N days (default: 365)
    #[serde(default = "default_retention_365")]
    pub episodes_days: u32,
    /// Delete behavior patterns at confidence floor older than N days (default: 90)
    #[serde(default = "default_retention_90")]
    pub behavior_patterns_days: u32,
    /// Delete completed/abandoned goals older than N days (default: 180)
    #[serde(default = "default_retention_180")]
    pub goals_days: u32,
    /// Delete zero-success procedures older than N days (default: 180)
    #[serde(default = "default_retention_180")]
    pub procedures_days: u32,
    /// Delete net-negative error solutions older than N days (default: 90)
    #[serde(default = "default_retention_90")]
    pub error_solutions_days: u32,
}

impl Default for RetentionConfig {
    fn default() -> Self {
        Self {
            messages_days: 90,
            superseded_facts_days: 90,
            token_usage_aggregate_days: 30,
            episodes_days: 365,
            behavior_patterns_days: 90,
            goals_days: 180,
            procedures_days: 180,
            error_solutions_days: 90,
        }
    }
}

fn default_retention_30() -> u32 {
    30
}
fn default_retention_90() -> u32 {
    90
}
fn default_retention_180() -> u32 {
    180
}
fn default_retention_365() -> u32 {
    365
}

impl fmt::Debug for StateConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("StateConfig")
            .field("db_path", &self.db_path)
            .field("working_memory_cap", &self.working_memory_cap)
            .field(
                "consolidation_interval_hours",
                &self.consolidation_interval_hours,
            )
            .field("encryption_key", &redact_option(&self.encryption_key))
            .field("max_facts", &self.max_facts)
            .field("daily_token_budget", &self.daily_token_budget)
            .field("retention", &self.retention)
            .field("context_window", &self.context_window)
            .finish()
    }
}

impl Default for StateConfig {
    fn default() -> Self {
        Self {
            db_path: default_db_path(),
            working_memory_cap: default_working_memory_cap(),
            consolidation_interval_hours: default_consolidation_interval_hours(),
            encryption_key: None,
            max_facts: default_max_facts(),
            daily_token_budget: None,
            retention: RetentionConfig::default(),
            context_window: ContextWindowConfig::default(),
        }
    }
}

fn default_db_path() -> String {
    "aidaemon.db".to_string()
}
fn default_working_memory_cap() -> usize {
    50
}
fn default_consolidation_interval_hours() -> u64 {
    6
}
fn default_max_facts() -> usize {
    20
}

/// Permission mode re-exported for config deserialization.
pub use crate::tools::command_risk::PermissionMode;

#[derive(Deserialize, Clone)]
pub struct TerminalConfig {
    #[serde(default = "default_allowed_prefixes")]
    pub allowed_prefixes: Vec<String>,
    #[serde(default = "default_terminal_web_app_url")]
    pub web_app_url: String,
    #[serde(default = "default_terminal_bridge_enabled")]
    pub bridge_enabled: bool,
    #[serde(default = "default_terminal_bridge_ws_url")]
    pub daemon_ws_url: String,
    #[serde(default)]
    pub daemon_connect_token: Option<String>,
    #[serde(default)]
    pub allow_static_token_fallback: bool,
    #[serde(default)]
    pub daemon_user_id: Option<u64>,
    #[serde(default)]
    pub daemon_device_id: Option<String>,
    #[serde(default)]
    pub daemon_shell: Option<String>,
    #[serde(default = "default_initial_timeout_secs")]
    pub initial_timeout_secs: u64,
    #[serde(default = "default_max_output_chars")]
    pub max_output_chars: usize,
    /// Permission persistence mode:
    /// - "default": Critical commands per-session, others persist forever
    /// - "cautious": All approvals per-session only (resets on restart)
    /// - "yolo": All approvals persist forever, including critical commands
    #[serde(default)]
    pub permission_mode: PermissionMode,
}

impl Default for TerminalConfig {
    fn default() -> Self {
        Self {
            allowed_prefixes: default_allowed_prefixes(),
            web_app_url: default_terminal_web_app_url(),
            bridge_enabled: default_terminal_bridge_enabled(),
            daemon_ws_url: default_terminal_bridge_ws_url(),
            daemon_connect_token: None,
            allow_static_token_fallback: false,
            daemon_user_id: None,
            daemon_device_id: None,
            daemon_shell: None,
            initial_timeout_secs: default_initial_timeout_secs(),
            max_output_chars: default_max_output_chars(),
            permission_mode: PermissionMode::default(),
        }
    }
}

impl fmt::Debug for TerminalConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("TerminalConfig")
            .field("allowed_prefixes", &self.allowed_prefixes)
            .field("web_app_url", &self.web_app_url)
            .field("bridge_enabled", &self.bridge_enabled)
            .field("daemon_ws_url", &self.daemon_ws_url)
            .field(
                "daemon_connect_token",
                &self.daemon_connect_token.as_ref().map(|v| {
                    if v.is_empty() {
                        "\"\""
                    } else {
                        "[REDACTED]"
                    }
                }),
            )
            .field(
                "allow_static_token_fallback",
                &self.allow_static_token_fallback,
            )
            .field("daemon_user_id", &self.daemon_user_id)
            .field("daemon_device_id", &self.daemon_device_id)
            .field("daemon_shell", &self.daemon_shell)
            .field("initial_timeout_secs", &self.initial_timeout_secs)
            .field("max_output_chars", &self.max_output_chars)
            .field("permission_mode", &self.permission_mode)
            .finish()
    }
}

impl TerminalConfig {
    /// Returns the effective Telegram terminal web app URL.
    /// Environment variable override is useful for local/staging validation.
    pub fn effective_web_app_url(&self) -> String {
        let from_env = std::env::var("AIDAEMON_TERMINAL_WEB_APP_URL")
            .ok()
            .map(|v| v.trim().to_string())
            .filter(|v| !v.is_empty());
        from_env.unwrap_or_else(|| self.web_app_url.trim().to_string())
    }

    pub fn effective_bridge_enabled(&self) -> bool {
        let from_env = std::env::var("AIDAEMON_TERMINAL_BRIDGE_ENABLED")
            .ok()
            .and_then(|v| parse_bool_like(&v));
        from_env.unwrap_or(self.bridge_enabled)
    }

    pub fn effective_daemon_ws_url(&self) -> String {
        let from_env = std::env::var("AIDAEMON_TERMINAL_BROKER_URL")
            .ok()
            .map(|v| v.trim().to_string())
            .filter(|v| !v.is_empty());
        from_env.unwrap_or_else(|| self.daemon_ws_url.trim().to_string())
    }

    pub fn effective_daemon_connect_token(&self) -> Option<String> {
        let from_env = std::env::var("AIDAEMON_TERMINAL_DAEMON_TOKEN")
            .ok()
            .map(|v| v.trim().to_string())
            .filter(|v| !v.is_empty());
        from_env.or_else(|| {
            self.daemon_connect_token
                .as_ref()
                .map(|v| v.trim().to_string())
                .filter(|v| !v.is_empty())
        })
    }

    pub fn effective_allow_static_token_fallback(&self) -> bool {
        let from_env = std::env::var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK")
            .ok()
            .and_then(|v| parse_bool_like(&v));
        from_env.unwrap_or(self.allow_static_token_fallback)
    }

    pub fn effective_daemon_user_id(&self) -> Option<u64> {
        let from_env = std::env::var("AIDAEMON_TERMINAL_USER_ID")
            .ok()
            .and_then(|v| v.trim().parse::<u64>().ok());
        from_env.or(self.daemon_user_id)
    }

    pub fn effective_daemon_device_id(&self) -> Option<String> {
        let from_env = std::env::var("AIDAEMON_TERMINAL_DEVICE_ID")
            .ok()
            .map(|v| v.trim().to_string())
            .filter(|v| !v.is_empty());
        from_env.or_else(|| {
            self.daemon_device_id
                .as_ref()
                .map(|v| v.trim().to_string())
                .filter(|v| !v.is_empty())
        })
    }

    pub fn effective_daemon_shell(&self) -> Option<String> {
        let from_env = std::env::var("AIDAEMON_TERMINAL_SHELL")
            .ok()
            .map(|v| v.trim().to_string())
            .filter(|v| !v.is_empty());
        from_env.or_else(|| {
            self.daemon_shell
                .as_ref()
                .map(|v| v.trim().to_string())
                .filter(|v| !v.is_empty())
        })
    }
}

#[derive(Debug, Deserialize, Clone, Default)]
pub struct PathAliasConfig {
    /// Optional alias roots for "projects/..." paths.
    /// Example:
    /// [path_aliases]
    /// projects = ["~/projects"]
    #[serde(default)]
    pub projects: Vec<String>,
}

fn default_initial_timeout_secs() -> u64 {
    30
}
fn default_max_output_chars() -> usize {
    4000
}
fn parse_bool_like(value: &str) -> Option<bool> {
    let normalized = value.trim().to_ascii_lowercase();
    match normalized.as_str() {
        "1" | "true" | "yes" | "on" => Some(true),
        "0" | "false" | "no" | "off" => Some(false),
        _ => None,
    }
}
fn default_terminal_web_app_url() -> String {
    "https://terminal.aidaemon.ai".to_string()
}
fn default_terminal_bridge_enabled() -> bool {
    true
}
fn default_terminal_bridge_ws_url() -> String {
    "wss://terminal.aidaemon.ai/v1/ws/daemon".to_string()
}

fn default_allowed_prefixes() -> Vec<String> {
    vec![
        "ls".into(),
        "cat".into(),
        "head".into(),
        "tail".into(),
        "echo".into(),
        "date".into(),
        "whoami".into(),
        "pwd".into(),
        "find".into(),
        "wc".into(),
        "grep".into(),
        "tree".into(),
        "file".into(),
        "stat".into(),
        "uname".into(),
        "df".into(),
        "du".into(),
        "ps".into(),
        "which".into(),
        "env".into(),
        "printenv".into(),
    ]
}

#[derive(Debug, Deserialize, Clone)]
pub struct WatchdogConfig {
    #[serde(default = "default_watchdog_enabled")]
    pub enabled: bool,
    /// Seconds of no heartbeat before declaring stuck (default: 300 = 5 min).
    #[serde(default = "default_watchdog_stale_secs")]
    pub stale_threshold_secs: u64,
    /// Per-LLM-call timeout in seconds (default: 300 = 5 min).
    #[serde(default = "default_llm_call_timeout_secs")]
    pub llm_call_timeout_secs: u64,
}

impl Default for WatchdogConfig {
    fn default() -> Self {
        Self {
            enabled: default_watchdog_enabled(),
            stale_threshold_secs: default_watchdog_stale_secs(),
            llm_call_timeout_secs: default_llm_call_timeout_secs(),
        }
    }
}

fn default_watchdog_enabled() -> bool {
    true
}
fn default_watchdog_stale_secs() -> u64 {
    300
}
fn default_llm_call_timeout_secs() -> u64 {
    300
}

#[derive(Debug, Deserialize, Clone)]
pub struct DaemonConfig {
    #[serde(default = "default_health_port")]
    pub health_port: u16,
    /// IP address to bind the health server to (default: "127.0.0.1").
    /// Set to "0.0.0.0" to listen on all interfaces.
    #[serde(default = "default_health_bind")]
    pub health_bind: String,
    /// Enable the embedded web dashboard on the health server port (default: true).
    #[serde(default = "default_dashboard_enabled")]
    pub dashboard_enabled: bool,
    /// Watchdog configuration for detecting stuck agent tasks.
    #[serde(default)]
    pub watchdog: WatchdogConfig,
    /// Queue policy for approval/media/trigger pipelines.
    #[serde(default)]
    pub queue_policy: QueuePolicyConfig,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        Self {
            health_port: default_health_port(),
            health_bind: default_health_bind(),
            dashboard_enabled: default_dashboard_enabled(),
            watchdog: WatchdogConfig::default(),
            queue_policy: QueuePolicyConfig::default(),
        }
    }
}

fn default_health_port() -> u16 {
    8080
}

fn default_health_bind() -> String {
    "127.0.0.1".to_string()
}

fn default_dashboard_enabled() -> bool {
    true
}

fn default_queue_capacity_approval() -> usize {
    16
}

fn default_queue_capacity_media() -> usize {
    16
}

fn default_queue_capacity_trigger_events() -> usize {
    64
}

fn default_queue_warning_ratio() -> f32 {
    0.75
}

fn default_queue_overload_ratio() -> f32 {
    0.90
}

fn default_queue_adaptive_shedding() -> bool {
    true
}

fn default_queue_fair_trigger_sessions() -> bool {
    true
}

fn default_queue_fair_window_secs() -> u64 {
    60
}

fn default_queue_fair_max_events_per_session() -> u32 {
    4
}

#[derive(Debug, Deserialize, Clone, PartialEq)]
pub struct QueueLanePolicyConfig {
    #[serde(default = "default_queue_adaptive_shedding")]
    pub adaptive_shedding: bool,
    #[serde(default = "default_queue_fair_trigger_sessions")]
    pub fair_sessions: bool,
    #[serde(default = "default_queue_fair_window_secs")]
    pub fair_session_window_secs: u64,
    #[serde(default = "default_queue_fair_max_events_per_session")]
    pub fair_max_events_per_session: u32,
}

impl Default for QueueLanePolicyConfig {
    fn default() -> Self {
        Self {
            adaptive_shedding: default_queue_adaptive_shedding(),
            fair_sessions: default_queue_fair_trigger_sessions(),
            fair_session_window_secs: default_queue_fair_window_secs(),
            fair_max_events_per_session: default_queue_fair_max_events_per_session(),
        }
    }
}

impl QueueLanePolicyConfig {
    pub fn normalized(&self) -> Self {
        let mut lane = self.clone();
        lane.fair_session_window_secs = lane.fair_session_window_secs.max(1);
        lane.fair_max_events_per_session = lane.fair_max_events_per_session.max(1);
        lane
    }
}

#[derive(Debug, Deserialize, Clone, Default)]
pub struct QueueLanePoliciesConfig {
    #[serde(default)]
    pub approval: QueueLanePolicyConfig,
    #[serde(default)]
    pub media: QueueLanePolicyConfig,
    #[serde(default)]
    pub trigger: QueueLanePolicyConfig,
}

impl QueueLanePoliciesConfig {
    pub fn normalized(&self) -> Self {
        Self {
            approval: self.approval.normalized(),
            media: self.media.normalized(),
            trigger: self.trigger.normalized(),
        }
    }
}

#[derive(Debug, Deserialize, Clone)]
pub struct QueuePolicyConfig {
    #[serde(default = "default_queue_capacity_approval")]
    pub approval_capacity: usize,
    #[serde(default = "default_queue_capacity_media")]
    pub media_capacity: usize,
    #[serde(default = "default_queue_capacity_trigger_events")]
    pub trigger_event_capacity: usize,
    #[serde(default = "default_queue_warning_ratio")]
    pub warning_ratio: f32,
    #[serde(default = "default_queue_overload_ratio")]
    pub overload_ratio: f32,
    #[serde(default)]
    pub lanes: QueueLanePoliciesConfig,

    // Legacy shared overload knobs retained for backward compatibility.
    #[serde(default)]
    pub adaptive_shedding: Option<bool>,
    #[serde(default)]
    pub fair_trigger_sessions: Option<bool>,
    #[serde(default)]
    pub fair_trigger_session_window_secs: Option<u64>,
    #[serde(default)]
    pub fair_trigger_max_events_per_session: Option<u32>,
}

impl Default for QueuePolicyConfig {
    fn default() -> Self {
        Self {
            approval_capacity: default_queue_capacity_approval(),
            media_capacity: default_queue_capacity_media(),
            trigger_event_capacity: default_queue_capacity_trigger_events(),
            warning_ratio: default_queue_warning_ratio(),
            overload_ratio: default_queue_overload_ratio(),
            lanes: QueueLanePoliciesConfig::default(),
            adaptive_shedding: None,
            fair_trigger_sessions: None,
            fair_trigger_session_window_secs: None,
            fair_trigger_max_events_per_session: None,
        }
    }
}

impl QueuePolicyConfig {
    pub fn normalized(&self) -> Self {
        let mut policy = self.clone();
        policy.approval_capacity = policy.approval_capacity.max(1);
        policy.media_capacity = policy.media_capacity.max(1);
        policy.trigger_event_capacity = policy.trigger_event_capacity.max(1);
        policy.warning_ratio = if policy.warning_ratio.is_finite() {
            policy.warning_ratio.clamp(0.0, 1.0)
        } else {
            default_queue_warning_ratio()
        };
        policy.overload_ratio = if policy.overload_ratio.is_finite() {
            policy.overload_ratio.clamp(policy.warning_ratio, 1.0)
        } else {
            default_queue_overload_ratio().clamp(policy.warning_ratio, 1.0)
        };

        let default_lane = QueueLanePolicyConfig::default().normalized();
        let legacy_lane = QueueLanePolicyConfig {
            adaptive_shedding: policy
                .adaptive_shedding
                .unwrap_or(default_queue_adaptive_shedding()),
            fair_sessions: policy
                .fair_trigger_sessions
                .unwrap_or(default_queue_fair_trigger_sessions()),
            fair_session_window_secs: policy
                .fair_trigger_session_window_secs
                .unwrap_or(default_queue_fair_window_secs()),
            fair_max_events_per_session: policy
                .fair_trigger_max_events_per_session
                .unwrap_or(default_queue_fair_max_events_per_session()),
        }
        .normalized();

        policy.lanes = policy.lanes.normalized();
        if policy.lanes.approval == default_lane {
            policy.lanes.approval = legacy_lane.clone();
        }
        if policy.lanes.media == default_lane {
            policy.lanes.media = legacy_lane.clone();
        }
        if policy.lanes.trigger == default_lane {
            policy.lanes.trigger = legacy_lane;
        }

        policy
    }
}

#[derive(Debug, Deserialize, Clone, Default)]
pub struct TriggersConfig {
    pub email: Option<EmailTriggerConfig>,
}

#[derive(Deserialize, Clone)]
pub struct EmailTriggerConfig {
    pub host: String,
    pub port: u16,
    pub username: String,
    pub password: String,
    #[serde(default = "default_folder")]
    pub folder: String,
}

impl fmt::Debug for EmailTriggerConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("EmailTriggerConfig")
            .field("host", &self.host)
            .field("port", &self.port)
            .field("username", &self.username)
            .field("password", &redact(&self.password))
            .field("folder", &self.folder)
            .finish()
    }
}

fn default_folder() -> String {
    "INBOX".to_string()
}

#[derive(Debug, Deserialize, Clone)]
pub struct McpServerConfig {
    pub command: String,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default)]
    pub env: HashMap<String, String>,
}

/// How browser sessions are isolated from one another with respect to cookies,
/// cache, and other browsing state.
///
/// HONESTY CONTRACT: this enum must never claim isolation that isn't actually
/// delivered. `Page` mode explicitly SHARES cookies across sessions;
/// `BrowserContext` mode creates a real, per-session incognito browser context
/// (no shared cookies/cache). The two modes are mutually exclusive with a
/// shared persistent profile or an attached remote-debugging Chrome — see
/// [`BrowserConfig::resolve_session_isolation`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(not(feature = "browser"), allow(dead_code))]
pub enum SessionIsolation {
    /// All sessions share one default browser context: cookies/cache are SHARED.
    /// This is the only honest option when attached to a persistent profile or a
    /// remote-debugging Chrome instance.
    Page,
    /// Each session gets its own incognito browser context: cookies/cache are
    /// ISOLATED per session. Requires dedicated ephemeral browsing (no
    /// `user_data_dir`, no `remote_debugging_port`).
    BrowserContext,
}

#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(not(feature = "browser"), allow(dead_code))]
pub struct BrowserConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_headless")]
    pub headless: bool,
    #[serde(default = "default_screenshot_width")]
    pub screenshot_width: u32,
    #[serde(default = "default_screenshot_height")]
    pub screenshot_height: u32,
    /// Connect to an existing Chrome instance on this debugging port instead of launching a new one.
    /// Start Chrome with: --remote-debugging-port=9222
    /// This shares your existing login sessions, cookies, and tabs.
    pub remote_debugging_port: Option<u16>,
    /// Path to Chrome user data directory for persistent sessions/cookies.
    /// Defaults to "~/.aidaemon/chrome-profile" so logins survive restarts.
    #[serde(default = "default_browser_user_data_dir")]
    pub user_data_dir: Option<String>,
    /// Chrome profile directory name within user_data_dir (default: "Default").
    /// Other profiles are typically "Profile 1", "Profile 2", etc.
    pub profile: Option<String>,
    /// How browser sessions are isolated from one another.
    ///
    /// `None` (the default) means "auto": dedicated ephemeral browsing resolves
    /// to `browser_context` (per-session isolated cookies), while a shared
    /// persistent profile (`user_data_dir`) or an attached remote-debugging
    /// Chrome (`remote_debugging_port`) resolves to `page` (shared cookies).
    /// Set explicitly to fail fast on incompatible combinations rather than
    /// silently claiming isolation. See [`Self::resolve_session_isolation`].
    #[serde(default)]
    pub session_isolation: Option<SessionIsolation>,
    /// Maximum seconds to wait for a navigation/DOM-ready signal after `goto`
    /// (and for a click-triggered navigation to settle). Clamped to 1..=120 by
    /// [`Self::nav_timeout`]. Default: 30.
    #[serde(default = "default_nav_timeout_secs")]
    pub nav_timeout_secs: u64,
    /// Default maximum seconds the `wait` action polls for its element
    /// condition. Clamped to 1..=120 by [`Self::element_timeout`]. A per-call
    /// `timeout_secs` argument overrides this (also clamped). Default: 10.
    #[serde(default = "default_element_timeout_secs")]
    pub element_timeout_secs: u64,
    /// Overall per-action ceiling for the click nav-race (the short
    /// stable-DOM fallback for a non-navigating click is derived from this but
    /// kept small). Clamped to 1..=300 by [`Self::action_timeout`]. Default: 30.
    #[serde(default = "default_action_timeout_secs")]
    pub action_timeout_secs: u64,
}

impl Default for BrowserConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            headless: default_headless(),
            screenshot_width: default_screenshot_width(),
            screenshot_height: default_screenshot_height(),
            remote_debugging_port: None,
            user_data_dir: default_browser_user_data_dir(),
            profile: None,
            session_isolation: None,
            nav_timeout_secs: default_nav_timeout_secs(),
            element_timeout_secs: default_element_timeout_secs(),
            action_timeout_secs: default_action_timeout_secs(),
        }
    }
}

#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct ComputerUseConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_cu_screenshot_max_width")]
    pub screenshot_max_width: u32,
    #[serde(default = "default_cu_screenshot_max_height")]
    pub screenshot_max_height: u32,
    #[serde(default = "default_cu_screenshot_max_bytes")]
    pub screenshot_max_bytes: u64,
    #[serde(default = "default_cu_ax_max_depth")]
    pub ax_max_depth: u32,
    #[serde(default = "default_cu_ax_max_nodes")]
    pub ax_max_nodes: u32,
    #[serde(default = "default_cu_action_timeout_secs")]
    pub action_timeout_secs: u64,
    #[serde(default = "default_cu_max_mutating_actions")]
    pub max_mutating_actions: u32,
    #[serde(default = "default_cu_max_consecutive_observations")]
    pub max_consecutive_observations: u32,
    #[serde(default = "default_cu_max_wall_clock_secs")]
    pub max_wall_clock_secs: u64,
    #[serde(default)]
    pub always_allowed_apps: Vec<String>,
    #[serde(default)]
    pub mirror_screenshots_to_channel: bool,
}

fn default_cu_screenshot_max_width() -> u32 {
    1280
}
fn default_cu_screenshot_max_height() -> u32 {
    800
}
fn default_cu_screenshot_max_bytes() -> u64 {
    2_000_000
}
fn default_cu_ax_max_depth() -> u32 {
    12
}
fn default_cu_ax_max_nodes() -> u32 {
    500
}
fn default_cu_action_timeout_secs() -> u64 {
    10
}
fn default_cu_max_mutating_actions() -> u32 {
    15
}
fn default_cu_max_consecutive_observations() -> u32 {
    3
}
fn default_cu_max_wall_clock_secs() -> u64 {
    600
}

impl Default for ComputerUseConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            screenshot_max_width: default_cu_screenshot_max_width(),
            screenshot_max_height: default_cu_screenshot_max_height(),
            screenshot_max_bytes: default_cu_screenshot_max_bytes(),
            ax_max_depth: default_cu_ax_max_depth(),
            ax_max_nodes: default_cu_ax_max_nodes(),
            action_timeout_secs: default_cu_action_timeout_secs(),
            max_mutating_actions: default_cu_max_mutating_actions(),
            max_consecutive_observations: default_cu_max_consecutive_observations(),
            max_wall_clock_secs: default_cu_max_wall_clock_secs(),
            always_allowed_apps: Vec::new(),
            mirror_screenshots_to_channel: false,
        }
    }
}

impl ComputerUseConfig {
    #[cfg_attr(not(feature = "computer_use"), allow(dead_code))]
    pub fn action_timeout(&self) -> std::time::Duration {
        std::time::Duration::from_secs(self.action_timeout_secs.clamp(1, 120))
    }
}

impl BrowserConfig {
    /// Resolved navigation/DOM-ready timeout, clamped to a sane bounded range
    /// (1..=120s) so a typo'd or absurd config value can neither hang the tool
    /// (huge value) nor make navigation give up before any page can load
    /// (zero/tiny value).
    #[cfg_attr(not(feature = "browser"), allow(dead_code))]
    pub fn nav_timeout(&self) -> std::time::Duration {
        std::time::Duration::from_secs(self.nav_timeout_secs.clamp(1, 120))
    }

    /// Resolved default `wait`-action element-poll timeout, clamped to 1..=120s.
    #[cfg_attr(not(feature = "browser"), allow(dead_code))]
    pub fn element_timeout(&self) -> std::time::Duration {
        std::time::Duration::from_secs(self.element_timeout_secs.clamp(1, 120))
    }

    /// Resolved overall per-action ceiling (used by the click nav-race),
    /// clamped to 1..=300s.
    #[cfg_attr(not(feature = "browser"), allow(dead_code))]
    pub fn action_timeout(&self) -> std::time::Duration {
        std::time::Duration::from_secs(self.action_timeout_secs.clamp(1, 300))
    }

    /// Resolve the effective session-isolation mode, rejecting configurations
    /// that would falsely claim per-session cookie isolation.
    ///
    /// HONESTY CONTRACT: a shared persistent profile (`user_data_dir`) or an
    /// attached remote-debugging Chrome (`remote_debugging_port`) cannot give
    /// per-session isolated cookies — they always share one cookie jar. So:
    ///
    /// - `Some(BrowserContext)` + shared/attached → **Err** (don't lie).
    /// - `Some(Page)` → always `Ok(Page)` (honest: shared cookies).
    /// - `Some(BrowserContext)` + ephemeral → `Ok(BrowserContext)`.
    /// - `None` (auto): shared/attached → `Ok(Page)`; ephemeral →
    ///   `Ok(BrowserContext)`.
    #[cfg_attr(not(feature = "browser"), allow(dead_code))]
    pub fn resolve_session_isolation(&self) -> Result<SessionIsolation, String> {
        let shared_or_attached =
            self.user_data_dir.is_some() || self.remote_debugging_port.is_some();

        match self.session_isolation {
            Some(SessionIsolation::BrowserContext) if shared_or_attached => Err(
                "browser.session_isolation = \"browser_context\" requires dedicated ephemeral \
                 browsing, but a shared persistent profile (user_data_dir) or an attached \
                 remote-debugging Chrome (remote_debugging_port) is configured. These share a \
                 single cookie jar and CANNOT provide per-session isolated cookies. Either use \
                 session_isolation = \"page\" (sessions share cookies), or remove user_data_dir \
                 and remote_debugging_port to enable real per-session isolation."
                    .to_string(),
            ),
            Some(mode) => Ok(mode),
            None if shared_or_attached => Ok(SessionIsolation::Page),
            None => Ok(SessionIsolation::BrowserContext),
        }
    }
}

fn default_browser_user_data_dir() -> Option<String> {
    dirs::home_dir().map(|h| {
        h.join(".aidaemon")
            .join("chrome-profile")
            .to_string_lossy()
            .into_owned()
    })
}

fn default_headless() -> bool {
    true
}
fn default_screenshot_width() -> u32 {
    1280
}
fn default_screenshot_height() -> u32 {
    720
}
fn default_nav_timeout_secs() -> u64 {
    30
}
fn default_element_timeout_secs() -> u64 {
    10
}
fn default_action_timeout_secs() -> u64 {
    30
}

#[derive(Debug, Deserialize, Clone)]
pub struct SkillsConfig {
    #[serde(default = "default_skills_dir")]
    pub dir: String,
    #[serde(default = "default_skills_enabled")]
    pub enabled: bool,
    /// URLs of skill registries (JSON manifests) to browse and install from.
    #[serde(default)]
    pub registries: Vec<String>,
}

impl Default for SkillsConfig {
    fn default() -> Self {
        Self {
            dir: default_skills_dir(),
            enabled: default_skills_enabled(),
            registries: Vec::new(),
        }
    }
}

fn default_skills_dir() -> String {
    "skills".to_string()
}

fn default_skills_enabled() -> bool {
    true
}

/// Iteration limit configuration for agent loops.
/// Controls how iteration limits are enforced (or not enforced).
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum IterationLimitConfig {
    /// No iteration limit - run until natural completion, token budget, or timeout.
    /// This is the recommended default for complex tasks.
    #[default]
    Unlimited,
    /// Soft limit with warnings but no forced termination.
    /// Agent will be warned at `warn_at` iterations and continue until natural completion.
    Soft { threshold: usize, warn_at: usize },
    /// Legacy hard limit behavior (backward compatibility).
    /// Agent will be forced to stop at `cap` iterations.
    Hard { initial: usize, cap: usize },
}

#[derive(Debug, Deserialize, Clone)]
pub struct SubagentsConfig {
    #[serde(default = "default_subagents_enabled")]
    pub enabled: bool,
    #[serde(default = "default_subagents_max_depth")]
    pub max_depth: usize,
    /// Legacy field - use iteration_limit instead. Kept for backward compatibility.
    #[serde(default = "default_subagents_max_iterations")]
    pub max_iterations: usize,
    /// Legacy field - use iteration_limit instead. Kept for backward compatibility.
    #[serde(default = "default_subagents_max_iterations_cap")]
    pub max_iterations_cap: usize,
    #[serde(default = "default_subagents_max_response_chars")]
    pub max_response_chars: usize,
    #[serde(default = "default_subagents_timeout_secs")]
    pub timeout_secs: u64,
    /// Iteration limit configuration. Defaults to Unlimited.
    /// If not set but max_iterations/max_iterations_cap are set, auto-migrates to Hard mode.
    #[serde(default)]
    pub iteration_limit: IterationLimitConfig,
    /// Optional time limit per task in seconds. Default: 1800 (30 minutes).
    #[serde(default = "default_task_timeout_secs")]
    pub task_timeout_secs: Option<u64>,
    /// Token budget per task. When exceeded, productive runs may auto-extend
    /// temporarily; otherwise the agent gracefully summarizes and stops.
    /// Defaults to 500,000. Set to 0 to disable (unlimited).
    #[serde(default = "default_task_token_budget")]
    pub task_token_budget: Option<u64>,
    /// Optional directory of user-override specialist `.md` files.
    /// Default: `~/.aidaemon/specialists/` (resolved at construction time if absent).
    #[serde(default)]
    pub specialists_override_dir: Option<PathBuf>,
}

impl SubagentsConfig {
    /// Returns the effective iteration limit config, handling backward compatibility.
    /// If iteration_limit is Unlimited but legacy max_iterations fields are set to
    /// non-default values, automatically migrates to Hard mode.
    pub fn effective_iteration_limit(&self) -> IterationLimitConfig {
        // Check if using non-default legacy values
        let legacy_initial_changed = self.max_iterations != default_subagents_max_iterations();
        let legacy_cap_changed = self.max_iterations_cap != default_subagents_max_iterations_cap();

        match &self.iteration_limit {
            IterationLimitConfig::Unlimited if legacy_initial_changed || legacy_cap_changed => {
                // Auto-migrate legacy config to Hard mode
                IterationLimitConfig::Hard {
                    initial: self.max_iterations,
                    cap: self.max_iterations_cap,
                }
            }
            other => other.clone(),
        }
    }
}

impl Default for SubagentsConfig {
    fn default() -> Self {
        Self {
            enabled: default_subagents_enabled(),
            max_depth: default_subagents_max_depth(),
            max_iterations: default_subagents_max_iterations(),
            max_iterations_cap: default_subagents_max_iterations_cap(),
            max_response_chars: default_subagents_max_response_chars(),
            timeout_secs: default_subagents_timeout_secs(),
            iteration_limit: IterationLimitConfig::default(),
            task_timeout_secs: default_task_timeout_secs(),
            task_token_budget: default_task_token_budget(),
            specialists_override_dir: None,
        }
    }
}

fn default_subagents_enabled() -> bool {
    true
}
fn default_subagents_max_depth() -> usize {
    3
}
fn default_subagents_max_iterations() -> usize {
    10
}
fn default_subagents_max_iterations_cap() -> usize {
    25
}
fn default_subagents_max_response_chars() -> usize {
    8000
}
fn default_subagents_timeout_secs() -> u64 {
    300
}
fn default_task_timeout_secs() -> Option<u64> {
    Some(1800) // 30 minutes default
}
fn default_task_token_budget() -> Option<u64> {
    Some(500_000)
}

#[derive(Debug, Deserialize, Clone)]
pub struct CliAgentsConfig {
    #[serde(default = "default_cli_agents_enabled")]
    pub enabled: bool,
    #[serde(default = "default_cli_agents_timeout_secs")]
    pub timeout_secs: u64,
    #[serde(default = "default_cli_agents_max_output_chars")]
    pub max_output_chars: usize,
    #[serde(default)]
    pub tools: HashMap<String, CliToolConfig>,
}

impl Default for CliAgentsConfig {
    fn default() -> Self {
        Self {
            enabled: default_cli_agents_enabled(),
            timeout_secs: default_cli_agents_timeout_secs(),
            max_output_chars: default_cli_agents_max_output_chars(),
            tools: HashMap::new(),
        }
    }
}

fn default_cli_agents_enabled() -> bool {
    false
}

fn default_cli_agents_timeout_secs() -> u64 {
    600
}
fn default_cli_agents_max_output_chars() -> usize {
    16000
}

#[derive(Debug, Deserialize, Clone)]
pub struct CliToolConfig {
    pub command: String,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub timeout_secs: Option<u64>,
    #[serde(default)]
    pub max_output_chars: Option<usize>,
}

#[derive(Deserialize, Clone, Default)]
pub struct SearchConfig {
    #[serde(default)]
    pub backend: SearchBackendKind,
    #[serde(default)]
    pub api_key: String,
}

impl fmt::Debug for SearchConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("SearchConfig")
            .field("backend", &self.backend)
            .field("api_key", &redact(&self.api_key))
            .finish()
    }
}

#[derive(Debug, Deserialize, Clone, Default)]
#[serde(rename_all = "snake_case")]
pub enum SearchBackendKind {
    #[default]
    DuckDuckGo,
    Brave,
}

#[derive(Debug, Deserialize, Clone, Default)]
pub struct HealthConfig {
    #[serde(default = "default_health_enabled")]
    pub enabled: bool,
    #[serde(default = "default_health_tick_interval_secs")]
    pub tick_interval_secs: u64,
    #[serde(default = "default_health_result_retention_days")]
    pub result_retention_days: u32,
    #[serde(default)]
    pub probes: Vec<HealthProbeConfig>,
}

fn default_health_enabled() -> bool {
    false
}

fn default_health_tick_interval_secs() -> u64 {
    30
}

fn default_health_result_retention_days() -> u32 {
    7
}

#[derive(Debug, Deserialize, Clone)]
pub struct HealthProbeConfig {
    pub name: String,
    #[serde(rename = "type")]
    pub probe_type: String,
    pub target: String,
    pub schedule: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub config: HealthProbeConfigOptions,
    #[serde(default)]
    pub consecutive_failures_alert: Option<u32>,
    #[serde(default)]
    pub latency_threshold_ms: Option<u32>,
    #[serde(default)]
    pub alert_session_ids: Vec<String>,
}

#[derive(Debug, Deserialize, Clone, Default)]
pub struct HealthProbeConfigOptions {
    #[serde(default)]
    pub timeout_secs: Option<u64>,
    #[serde(default)]
    pub expected_status: Option<u16>,
    #[serde(default)]
    pub expected_body: Option<String>,
    #[serde(default)]
    pub method: Option<String>,
    #[serde(default)]
    pub headers: Option<std::collections::HashMap<String, String>>,
    #[serde(default)]
    pub max_age_secs: Option<u64>,
    #[serde(default)]
    pub expected_exit_code: Option<i32>,
}

#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum UpdateMode {
    /// Automatically download and apply updates, then restart.
    Enable,
    /// Check for updates and notify the user; wait for confirmation before applying.
    #[default]
    CheckOnly,
    /// Do not check for updates at all.
    Disable,
}

#[derive(Debug, Deserialize, Clone)]
pub struct UpdateConfig {
    /// Update behavior mode: "enable", "check_only", or "disable".
    #[serde(default)]
    pub mode: UpdateMode,
    /// How often to check for updates, in hours. Default: 24.
    #[serde(default = "default_update_check_interval_hours")]
    pub check_interval_hours: u64,
    /// Specific UTC hour to check (0-23). If set, overrides interval-based checks.
    #[serde(default)]
    pub check_at_utc_hour: Option<u8>,
    /// How long to wait for user confirmation in check_only mode, in minutes. Default: 60.
    #[serde(default = "default_update_confirmation_timeout_mins")]
    pub confirmation_timeout_mins: u64,
}

impl Default for UpdateConfig {
    fn default() -> Self {
        Self {
            mode: UpdateMode::default(),
            check_interval_hours: default_update_check_interval_hours(),
            check_at_utc_hour: None,
            confirmation_timeout_mins: default_update_confirmation_timeout_mins(),
        }
    }
}

fn default_update_check_interval_hours() -> u64 {
    24
}

fn default_update_confirmation_timeout_mins() -> u64 {
    60
}

#[derive(Debug, Deserialize, Clone, Default)]
pub struct UsersConfig {
    #[serde(default)]
    pub owner_ids: HashMap<String, Vec<String>>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct FilesConfig {
    #[serde(default = "default_files_enabled")]
    pub enabled: bool,
    #[serde(default = "default_inbox_dir")]
    pub inbox_dir: String,
    #[serde(default = "default_outbox_dirs")]
    pub outbox_dirs: Vec<String>,
    #[serde(default = "default_max_file_size_mb")]
    pub max_file_size_mb: u64,
    #[serde(default = "default_retention_hours")]
    pub retention_hours: u64,
    #[serde(default = "default_vision_enabled")]
    pub vision_enabled: bool,
    #[serde(default = "default_max_vision_image_mb")]
    pub max_vision_image_mb: u64,
    #[serde(default = "default_vision_mime_types")]
    pub vision_mime_types: Vec<String>,
    #[serde(default = "default_vision_model_patterns")]
    pub vision_model_patterns: Vec<String>,
    #[serde(default = "default_audio_enabled")]
    pub audio_enabled: bool,
    #[serde(default = "default_max_audio_mb")]
    pub max_audio_mb: u64,
    #[serde(default = "default_audio_mime_types")]
    pub audio_mime_types: Vec<String>,
    #[serde(default = "default_audio_model_patterns")]
    pub audio_model_patterns: Vec<String>,
    #[serde(default)]
    pub stt: SttFilesConfig,
}

/// Local Whisper STT settings under `[files.stt]`.
#[derive(Debug, Deserialize, Clone)]
pub struct SttFilesConfig {
    #[serde(default = "default_stt_enabled")]
    pub enabled: bool,
    #[serde(default = "default_stt_cli_path")]
    pub cli_path: String,
    #[serde(default = "default_stt_model_path")]
    pub model_path: String,
    #[serde(default = "default_stt_ffmpeg_path")]
    pub ffmpeg_path: String,
    #[serde(default = "default_stt_language")]
    pub language: String,
    #[serde(default = "default_stt_max_audio_mb")]
    pub max_audio_mb: u64,
    #[serde(default = "default_stt_timeout_secs")]
    pub timeout_secs: u64,
    #[serde(default = "default_stt_mime_types")]
    pub mime_types: Vec<String>,
}

/// Vision/image encoding settings derived from `[files]` config.
#[derive(Debug, Clone)]
pub struct VisionConfig {
    pub enabled: bool,
    pub max_image_bytes: u64,
    pub mime_types: Vec<String>,
    #[cfg_attr(not(feature = "computer_use"), allow(dead_code))]
    pub model_patterns: Vec<String>,
}

impl VisionConfig {
    pub fn from_files(files: &FilesConfig) -> Self {
        Self {
            enabled: files.vision_enabled,
            max_image_bytes: files.max_vision_image_mb.saturating_mul(1_048_576),
            mime_types: files.vision_mime_types.clone(),
            model_patterns: files.vision_model_patterns.clone(),
        }
    }

    #[cfg_attr(not(feature = "computer_use"), allow(dead_code))]
    pub fn model_supports_vision(&self, model: &str) -> bool {
        if !self.enabled {
            return false;
        }
        let model_lower = model.to_ascii_lowercase();
        self.model_patterns
            .iter()
            .any(|pattern| model_lower.contains(&pattern.to_ascii_lowercase()))
    }

    pub fn mime_allowed(&self, mime: &str) -> bool {
        self.mime_types.iter().any(|allowed| allowed == mime)
    }
}

/// Native audio input settings derived from `[files]` config.
#[derive(Debug, Clone)]
pub struct AudioConfig {
    pub enabled: bool,
    pub max_audio_bytes: u64,
    pub mime_types: Vec<String>,
    pub model_patterns: Vec<String>,
}

impl AudioConfig {
    pub fn from_files(files: &FilesConfig) -> Self {
        Self {
            enabled: files.audio_enabled,
            max_audio_bytes: files.max_audio_mb.saturating_mul(1_048_576),
            mime_types: files.audio_mime_types.clone(),
            model_patterns: files.audio_model_patterns.clone(),
        }
    }

    pub fn mime_allowed(&self, mime: &str) -> bool {
        self.mime_types.iter().any(|allowed| allowed == mime)
    }

    pub fn model_supports_audio(&self, model: &str) -> bool {
        let model_lower = model.to_ascii_lowercase();
        self.model_patterns
            .iter()
            .any(|pattern| model_lower.contains(&pattern.to_ascii_lowercase()))
    }
}

/// Resolved Whisper STT settings for runtime use.
#[derive(Debug, Clone)]
pub struct SttConfig {
    pub enabled: bool,
    pub cli_path: std::path::PathBuf,
    pub model_path: std::path::PathBuf,
    pub ffmpeg_path: std::path::PathBuf,
    pub language: String,
    pub max_audio_bytes: u64,
    pub timeout_secs: u64,
    pub mime_types: Vec<String>,
}

impl SttConfig {
    pub fn from_files(files: &FilesConfig) -> Self {
        Self {
            enabled: files.stt.enabled,
            cli_path: std::path::PathBuf::from(
                shellexpand::tilde(&files.stt.cli_path).into_owned(),
            ),
            model_path: std::path::PathBuf::from(
                shellexpand::tilde(&files.stt.model_path).into_owned(),
            ),
            ffmpeg_path: std::path::PathBuf::from(
                shellexpand::tilde(&files.stt.ffmpeg_path).into_owned(),
            ),
            language: files.stt.language.clone(),
            max_audio_bytes: files.stt.max_audio_mb.saturating_mul(1_048_576),
            timeout_secs: files.stt.timeout_secs,
            mime_types: files.stt.mime_types.clone(),
        }
    }

    pub fn mime_allowed(&self, mime: &str) -> bool {
        self.mime_types.iter().any(|allowed| allowed == mime)
    }
}

impl Default for SttFilesConfig {
    fn default() -> Self {
        Self {
            enabled: default_stt_enabled(),
            cli_path: default_stt_cli_path(),
            model_path: default_stt_model_path(),
            ffmpeg_path: default_stt_ffmpeg_path(),
            language: default_stt_language(),
            max_audio_mb: default_stt_max_audio_mb(),
            timeout_secs: default_stt_timeout_secs(),
            mime_types: default_stt_mime_types(),
        }
    }
}

impl Default for FilesConfig {
    fn default() -> Self {
        Self {
            enabled: default_files_enabled(),
            inbox_dir: default_inbox_dir(),
            outbox_dirs: default_outbox_dirs(),
            max_file_size_mb: default_max_file_size_mb(),
            retention_hours: default_retention_hours(),
            vision_enabled: default_vision_enabled(),
            max_vision_image_mb: default_max_vision_image_mb(),
            vision_mime_types: default_vision_mime_types(),
            vision_model_patterns: default_vision_model_patterns(),
            audio_enabled: default_audio_enabled(),
            max_audio_mb: default_max_audio_mb(),
            audio_mime_types: default_audio_mime_types(),
            audio_model_patterns: default_audio_model_patterns(),
            stt: SttFilesConfig::default(),
        }
    }
}

fn default_files_enabled() -> bool {
    true
}
fn default_inbox_dir() -> String {
    "~/.aidaemon/files/inbox".to_string()
}
fn default_outbox_dirs() -> Vec<String> {
    vec!["~".to_string()]
}
fn default_max_file_size_mb() -> u64 {
    10
}
fn default_retention_hours() -> u64 {
    24
}
fn default_vision_enabled() -> bool {
    true
}
fn default_max_vision_image_mb() -> u64 {
    4
}
fn default_vision_mime_types() -> Vec<String> {
    vec![
        "image/jpeg".to_string(),
        "image/png".to_string(),
        "image/gif".to_string(),
        "image/webp".to_string(),
    ]
}
fn default_vision_model_patterns() -> Vec<String> {
    vec![
        "gpt-4o".to_string(),
        "gpt-4".to_string(),
        "gpt-5".to_string(),
        "o1".to_string(),
        "o3".to_string(),
        "o4".to_string(),
        "gemini".to_string(),
        "claude-3".to_string(),
        "claude-sonnet".to_string(),
        "claude-opus".to_string(),
        "claude-haiku".to_string(),
        "fable".to_string(),
        "gemma".to_string(),
        "llava".to_string(),
        "qwen-vl".to_string(),
        "qwen2-vl".to_string(),
        "qwen2.5-vl".to_string(),
        "qwen3-vl".to_string(),
        "internvl".to_string(),
        "vision".to_string(),
        "pixtral".to_string(),
        "mistral-large".to_string(),
    ]
}
fn default_audio_enabled() -> bool {
    true
}
fn default_max_audio_mb() -> u64 {
    10
}
fn default_audio_mime_types() -> Vec<String> {
    vec![
        "audio/ogg".to_string(),
        "audio/mpeg".to_string(),
        "audio/mp3".to_string(),
        "audio/wav".to_string(),
        "audio/flac".to_string(),
        "audio/aac".to_string(),
    ]
}
fn default_audio_model_patterns() -> Vec<String> {
    vec![
        "audio".to_string(),
        "gemini-2".to_string(),
        "gemini-3".to_string(),
    ]
}
fn default_stt_enabled() -> bool {
    false
}
fn default_stt_cli_path() -> String {
    "/opt/homebrew/bin/whisper-cli".to_string()
}
fn default_stt_model_path() -> String {
    "~/models/whisper/ggml-medium.en.bin".to_string()
}
fn default_stt_ffmpeg_path() -> String {
    "ffmpeg".to_string()
}
fn default_stt_language() -> String {
    "en".to_string()
}
fn default_stt_max_audio_mb() -> u64 {
    25
}
fn default_stt_timeout_secs() -> u64 {
    120
}
fn default_stt_mime_types() -> Vec<String> {
    vec![
        "audio/ogg".to_string(),
        "audio/mpeg".to_string(),
        "audio/mp3".to_string(),
        "audio/wav".to_string(),
        "audio/flac".to_string(),
        "audio/aac".to_string(),
        "audio/webm".to_string(),
    ]
}

#[derive(Debug, Deserialize, Clone, Default)]
pub struct OAuthConfig {
    // Retained for backward-compatible config parsing; OAuth tooling is always available at runtime.
    #[allow(dead_code)]
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub callback_url: Option<String>,
    #[serde(default)]
    pub providers: HashMap<String, OAuthProviderConfig>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct OAuthProviderConfig {
    #[serde(default)]
    pub display_name: Option<String>,
    pub auth_type: String,
    pub authorize_url: String,
    pub token_url: String,
    #[serde(default)]
    pub scopes: Vec<String>,
    #[serde(default)]
    pub allowed_domains: Vec<String>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct PeopleConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_people_auto_extract")]
    pub auto_extract: bool,
    #[serde(default = "default_people_auto_extract_categories")]
    pub auto_extract_categories: Vec<String>,
    #[serde(default = "default_people_restricted_categories")]
    pub restricted_categories: Vec<String>,
    #[serde(default = "default_people_fact_retention_days")]
    pub fact_retention_days: u32,
    #[serde(default = "default_people_reconnect_reminder_days")]
    pub reconnect_reminder_days: u32,
}

impl Default for PeopleConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            auto_extract: default_people_auto_extract(),
            auto_extract_categories: default_people_auto_extract_categories(),
            restricted_categories: default_people_restricted_categories(),
            fact_retention_days: default_people_fact_retention_days(),
            reconnect_reminder_days: default_people_reconnect_reminder_days(),
        }
    }
}

fn default_people_auto_extract() -> bool {
    true
}
fn default_people_auto_extract_categories() -> Vec<String> {
    vec![
        "birthday".into(),
        "preference".into(),
        "interest".into(),
        "work".into(),
        "family".into(),
        "important_date".into(),
    ]
}
fn default_people_restricted_categories() -> Vec<String> {
    vec![
        "health".into(),
        "finance".into(),
        "political".into(),
        "religious".into(),
    ]
}
fn default_people_fact_retention_days() -> u32 {
    180
}
fn default_people_reconnect_reminder_days() -> u32 {
    30
}

#[derive(Deserialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum HttpAuthType {
    Oauth1a,
    Bearer,
    Header,
    Basic,
}

impl fmt::Debug for HttpAuthType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            HttpAuthType::Oauth1a => write!(f, "oauth1a"),
            HttpAuthType::Bearer => write!(f, "bearer"),
            HttpAuthType::Header => write!(f, "header"),
            HttpAuthType::Basic => write!(f, "basic"),
        }
    }
}

#[derive(Deserialize, Clone)]
pub struct HttpAuthProfile {
    pub auth_type: HttpAuthType,
    pub allowed_domains: Vec<String>,
    // OAuth 1.0a fields
    #[serde(default)]
    pub api_key: Option<String>,
    #[serde(default)]
    pub api_secret: Option<String>,
    #[serde(default)]
    pub access_token: Option<String>,
    #[serde(default)]
    pub access_token_secret: Option<String>,
    #[serde(default)]
    pub user_id: Option<String>,
    // Bearer token
    #[serde(default)]
    pub token: Option<String>,
    // Header auth
    #[serde(default)]
    pub header_name: Option<String>,
    #[serde(default)]
    pub header_value: Option<String>,
    // Basic auth
    #[serde(default)]
    pub username: Option<String>,
    #[serde(default)]
    pub password: Option<String>,
}

impl fmt::Debug for HttpAuthProfile {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("HttpAuthProfile")
            .field("auth_type", &self.auth_type)
            .field("allowed_domains", &self.allowed_domains)
            .field("api_key", &redact_option(&self.api_key))
            .field("api_secret", &redact_option(&self.api_secret))
            .field("access_token", &redact_option(&self.access_token))
            .field(
                "access_token_secret",
                &redact_option(&self.access_token_secret),
            )
            .field("user_id", &self.user_id)
            .field("token", &redact_option(&self.token))
            .field("header_name", &self.header_name)
            .field("header_value", &redact_option(&self.header_value))
            .field("username", &self.username)
            .field("password", &redact_option(&self.password))
            .finish()
    }
}

impl HttpAuthProfile {
    /// Collect all credential values for this profile (for stripping from error messages).
    pub fn credential_values(&self) -> Vec<&str> {
        let mut vals = Vec::new();
        for v in [
            &self.api_key,
            &self.api_secret,
            &self.access_token,
            &self.access_token_secret,
            &self.token,
            &self.header_value,
            &self.password,
        ]
        .into_iter()
        .flatten()
        {
            if !v.is_empty() {
                vals.push(v.as_str());
            }
        }
        vals
    }
}

/// Context window management configuration.
/// Controls conversation history trimming, summarization, and progressive fact extraction.
#[derive(Debug, Deserialize, Clone)]
pub struct ContextWindowConfig {
    /// Enable context window management (default: true).
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Default token budget for conversation history (default: 24000).
    #[serde(default = "default_context_budget")]
    pub default_budget: usize,
    /// Per-model token budgets (model name → budget). Overrides default_budget.
    #[serde(default)]
    pub model_budgets: HashMap<String, usize>,
    /// Maximum characters for tool results before truncation (default: 2000).
    #[serde(default = "default_tool_result_chars")]
    pub max_tool_result_chars: usize,
    /// Per-model tool result character caps (model name → max chars).
    /// Overrides `max_tool_result_chars` for the named model. Models listed
    /// in `model_budgets` but not here get a cap scaled proportionally to
    /// their budget.
    #[serde(default)]
    pub model_tool_result_chars: HashMap<String, usize>,
    /// Number of recent messages to always keep in the window (default: 6).
    #[serde(default = "default_summary_window")]
    pub summary_window: usize,
    /// Enable progressive fact extraction after each interaction (default: true).
    #[serde(default = "default_true")]
    pub progressive_facts: bool,
    /// Message count threshold before summarization kicks in (default: 12).
    #[serde(default = "default_summarize_threshold")]
    pub summarize_threshold: usize,
}

impl Default for ContextWindowConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            default_budget: default_context_budget(),
            model_budgets: HashMap::new(),
            max_tool_result_chars: default_tool_result_chars(),
            model_tool_result_chars: HashMap::new(),
            summary_window: default_summary_window(),
            progressive_facts: true,
            summarize_threshold: default_summarize_threshold(),
        }
    }
}

impl ContextWindowConfig {
    /// Effective tool-result character cap for a specific model.
    ///
    /// Resolution order: explicit `model_tool_result_chars` entry → cap
    /// scaled by the model's `model_budgets` ratio against `default_budget`
    /// (floored so tiny budgets stay usable) → global `max_tool_result_chars`.
    pub fn tool_result_chars_for(&self, model: &str) -> usize {
        const MIN_TOOL_RESULT_CHARS: usize = 1_000;
        if let Some(&chars) = self.model_tool_result_chars.get(model) {
            return chars;
        }
        if let Some(&budget) = self.model_budgets.get(model) {
            if let Some(scaled) = self
                .max_tool_result_chars
                .saturating_mul(budget)
                .checked_div(self.default_budget)
            {
                return scaled.max(MIN_TOOL_RESULT_CHARS);
            }
        }
        self.max_tool_result_chars
    }
}

fn default_context_budget() -> usize {
    48000
}
fn default_tool_result_chars() -> usize {
    4000
}
fn default_summary_window() -> usize {
    6
}
fn default_summarize_threshold() -> usize {
    12
}

fn default_heartbeat_tick() -> u64 {
    30
}

fn default_max_concurrent() -> usize {
    3
}

fn default_policy_shadow_mode() -> bool {
    true
}
fn default_policy_enforce() -> bool {
    true
}
fn default_tool_filter_enforce() -> bool {
    true
}
fn default_uncertainty_clarify_enforce() -> bool {
    true
}
fn default_context_refresh_enforce() -> bool {
    true
}
fn default_learning_evidence_gate_enforce() -> bool {
    true
}
fn default_autotune_shadow() -> bool {
    true
}
fn default_autotune_enforce() -> bool {
    true
}
fn default_uncertainty_threshold() -> f32 {
    0.55
}
fn default_write_consistency_max_abs_global_delta() -> u64 {
    3
}
fn default_write_consistency_max_session_mismatch_count() -> u64 {
    0
}
fn default_write_consistency_max_stale_task_starts() -> u64 {
    0
}
fn default_write_consistency_max_missing_message_id_events() -> u64 {
    0
}
fn default_diagnostics_max_events() -> usize {
    200
}

fn default_disabled_tools() -> Vec<String> {
    vec![
        "git_info".to_string(),
        "git_commit".to_string(),
        "policy_metrics".to_string(),
        "check_environment".to_string(),
        "service_status".to_string(),
        "project_inspect".to_string(),
        "read_channel_history".to_string(),
        "tool_trace".to_string(),
    ]
}

/// Startup tool registration controls.
#[derive(Debug, Deserialize, Clone)]
pub struct ToolsConfig {
    /// Built-in tool machine names to omit at daemon startup (requires `/restart`).
    /// Off by default: `git_info`, `git_commit` (use `run_command`/`terminal`), plus
    /// `policy_metrics`, `check_environment`, `service_status`, `project_inspect` (use
    /// `read_file`/`search_files`/`terminal`), `read_channel_history` (Slack opt-in),
    /// `tool_trace` (use `goal_trace` with `action: "tool_trace"` instead). Set `disabled = []`
    /// to register everything. Example extras: `["goal_trace"]` to disable forensics entirely.
    #[serde(default = "default_disabled_tools")]
    pub disabled: Vec<String>,
}

impl Default for ToolsConfig {
    fn default() -> Self {
        Self {
            disabled: default_disabled_tools(),
        }
    }
}

impl ToolsConfig {
    pub fn is_enabled(&self, tool_name: &str) -> bool {
        !self.disabled.iter().any(|name| name == tool_name)
    }
}

fn default_diagnostics_enabled() -> bool {
    false
}

#[derive(Debug, Deserialize, Clone)]
pub struct DiagnosticsConfig {
    #[serde(default = "default_diagnostics_enabled")]
    pub enabled: bool,
    #[serde(default = "default_true")]
    pub record_decision_points: bool,
    #[serde(default = "default_diagnostics_max_events")]
    pub max_events: usize,
    #[serde(default)]
    pub include_raw_tool_args: bool,
    #[serde(default)]
    pub harness_eval: DiagnosticsHarnessEvalConfig,
}

#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)] // warn_* thresholds used in Phase C diagnose/db_probe
pub struct DiagnosticsHarnessEvalConfig {
    #[serde(default = "default_true")]
    pub enabled: bool,
    #[serde(default = "default_true")]
    pub persist_on_task_end: bool,
    #[serde(default)]
    pub emit_separate_event: bool,
    #[serde(default = "default_harness_weight_routing")]
    pub weight_routing: f32,
    #[serde(default = "default_harness_weight_progress")]
    pub weight_progress: f32,
    #[serde(default = "default_harness_weight_quality")]
    pub weight_quality: f32,
    #[serde(default = "default_harness_weight_cost")]
    pub weight_cost: f32,
    #[serde(default = "default_harness_cost_tier_cheap")]
    pub cost_tier_cheap: f32,
    #[serde(default = "default_harness_cost_tier_balanced")]
    pub cost_tier_balanced: f32,
    #[serde(default = "default_harness_cost_tier_strong")]
    pub cost_tier_strong: f32,
    #[serde(default = "default_harness_cost_tier_unknown")]
    pub cost_tier_unknown: f32,
    #[serde(default = "default_harness_warn_overall")]
    pub warn_overall_below: f32,
    #[serde(default = "default_harness_warn_routing")]
    pub warn_routing_below: f32,
}

impl Default for DiagnosticsHarnessEvalConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            persist_on_task_end: true,
            emit_separate_event: false,
            weight_routing: default_harness_weight_routing(),
            weight_progress: default_harness_weight_progress(),
            weight_quality: default_harness_weight_quality(),
            weight_cost: default_harness_weight_cost(),
            cost_tier_cheap: default_harness_cost_tier_cheap(),
            cost_tier_balanced: default_harness_cost_tier_balanced(),
            cost_tier_strong: default_harness_cost_tier_strong(),
            cost_tier_unknown: default_harness_cost_tier_unknown(),
            warn_overall_below: default_harness_warn_overall(),
            warn_routing_below: default_harness_warn_routing(),
        }
    }
}

fn default_harness_weight_routing() -> f32 {
    0.30
}
fn default_harness_weight_progress() -> f32 {
    0.25
}
fn default_harness_weight_quality() -> f32 {
    0.30
}
fn default_harness_weight_cost() -> f32 {
    0.15
}
fn default_harness_cost_tier_cheap() -> f32 {
    1.0
}
fn default_harness_cost_tier_balanced() -> f32 {
    2.5
}
fn default_harness_cost_tier_strong() -> f32 {
    5.0
}
fn default_harness_cost_tier_unknown() -> f32 {
    3.0
}
fn default_harness_warn_overall() -> f32 {
    0.6
}
fn default_harness_warn_routing() -> f32 {
    0.7
}

impl Default for DiagnosticsConfig {
    fn default() -> Self {
        Self {
            enabled: default_diagnostics_enabled(),
            record_decision_points: true,
            max_events: default_diagnostics_max_events(),
            include_raw_tool_args: false,
            harness_eval: DiagnosticsHarnessEvalConfig::default(),
        }
    }
}

#[derive(Debug, Deserialize, Clone)]
pub struct HeartbeatConfig {
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// Tick interval in seconds (default: 30)
    #[serde(default = "default_heartbeat_tick")]
    pub tick_interval_secs: u64,
    /// Max concurrent LLM tasks spawned by heartbeat (default: 3)
    #[serde(default = "default_max_concurrent")]
    pub max_concurrent_llm_tasks: usize,
}

impl Default for HeartbeatConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            tick_interval_secs: 30,
            max_concurrent_llm_tasks: 3,
        }
    }
}

#[derive(Debug, Deserialize, Clone)]
pub struct WriteConsistencyConfig {
    #[serde(default = "default_write_consistency_max_abs_global_delta")]
    pub max_abs_global_delta: u64,
    #[serde(default = "default_write_consistency_max_session_mismatch_count")]
    pub max_session_mismatch_count: u64,
    #[serde(default = "default_write_consistency_max_stale_task_starts")]
    pub max_stale_task_starts: u64,
    #[serde(default = "default_write_consistency_max_missing_message_id_events")]
    pub max_missing_message_id_events: u64,
}

impl Default for WriteConsistencyConfig {
    fn default() -> Self {
        Self {
            max_abs_global_delta: default_write_consistency_max_abs_global_delta(),
            max_session_mismatch_count: default_write_consistency_max_session_mismatch_count(),
            max_stale_task_starts: default_write_consistency_max_stale_task_starts(),
            max_missing_message_id_events: default_write_consistency_max_missing_message_id_events(
            ),
        }
    }
}

impl WriteConsistencyConfig {
    pub fn thresholds(&self) -> crate::events::WriteConsistencyThresholds {
        crate::events::WriteConsistencyThresholds {
            max_abs_global_delta: self.max_abs_global_delta,
            max_session_mismatch_count: self.max_session_mismatch_count,
            max_stale_task_starts: self.max_stale_task_starts,
            max_missing_message_id_events: self.max_missing_message_id_events,
        }
    }
}

#[derive(Debug, Deserialize, Clone)]
pub struct PolicyConfig {
    #[serde(default = "default_policy_shadow_mode")]
    pub policy_shadow_mode: bool,
    /// Enforce policy-derived context budget caps. When false, message trimming
    /// only respects the model provider budget.
    #[serde(default = "default_policy_enforce")]
    pub policy_enforce: bool,
    #[serde(default = "default_tool_filter_enforce")]
    pub tool_filter_enforce: bool,
    #[serde(default = "default_uncertainty_clarify_enforce")]
    pub uncertainty_clarify_enforce: bool,
    #[serde(default = "default_context_refresh_enforce")]
    pub context_refresh_enforce: bool,
    #[serde(default = "default_learning_evidence_gate_enforce")]
    pub learning_evidence_gate_enforce: bool,
    #[serde(default = "default_autotune_shadow")]
    pub autotune_shadow: bool,
    #[serde(default = "default_autotune_enforce")]
    pub autotune_enforce: bool,
    #[serde(default = "default_uncertainty_threshold")]
    pub uncertainty_clarify_threshold: f32,
    #[serde(default)]
    pub write_consistency: WriteConsistencyConfig,
}

impl Default for PolicyConfig {
    fn default() -> Self {
        Self {
            policy_shadow_mode: default_policy_shadow_mode(),
            policy_enforce: default_policy_enforce(),
            tool_filter_enforce: default_tool_filter_enforce(),
            uncertainty_clarify_enforce: default_uncertainty_clarify_enforce(),
            context_refresh_enforce: default_context_refresh_enforce(),
            learning_evidence_gate_enforce: default_learning_evidence_gate_enforce(),
            autotune_shadow: default_autotune_shadow(),
            autotune_enforce: default_autotune_enforce(),
            uncertainty_clarify_threshold: default_uncertainty_threshold(),
            write_consistency: WriteConsistencyConfig::default(),
        }
    }
}

impl AppConfig {
    pub fn load(path: &Path) -> anyhow::Result<Self> {
        let content = std::fs::read_to_string(path)?;
        let expanded = expand_env_vars(&content)?;
        let mut config: AppConfig = toml::from_str(&expanded)?;
        config.provider.apply_model_defaults_recursive();
        config.daemon.queue_policy = config.daemon.queue_policy.normalized();
        config.resolve_secrets()?;
        Ok(config)
    }

    /// Get all Telegram bot configurations (merges legacy single + array).
    /// Legacy `[telegram]` becomes a bot named "default".
    pub fn all_telegram_bots(&self) -> Vec<TelegramBotConfig> {
        let mut bots = self.telegram_bots.clone();

        // Add legacy config as "default" if present and has a token
        if let Some(ref legacy) = self.telegram {
            if !legacy.bot_token.is_empty() {
                bots.push(TelegramBotConfig {
                    bot_token: legacy.bot_token.clone(),
                    allowed_user_ids: legacy.allowed_user_ids.clone(),
                    webhook: legacy.webhook.clone(),
                });
            }
        }

        bots
    }

    /// Get all Discord bot configurations (merges legacy single + array).
    #[cfg(feature = "discord")]
    pub fn all_discord_bots(&self) -> Vec<DiscordBotConfig> {
        let mut bots = self.discord_bots.clone();

        if let Some(ref legacy) = self.discord {
            if !legacy.bot_token.is_empty() {
                bots.push(DiscordBotConfig {
                    bot_token: legacy.bot_token.clone(),
                    allowed_user_ids: legacy.allowed_user_ids.clone(),
                    guild_id: legacy.guild_id,
                });
            }
        }

        bots
    }

    /// Get all Slack bot configurations (merges legacy single + array).
    #[cfg(feature = "slack")]
    pub fn all_slack_bots(&self) -> Vec<SlackBotConfig> {
        let mut bots = self.slack_bots.clone();

        if let Some(ref legacy) = self.slack {
            // Keep legacy `enabled` for backward compatibility in config parsing,
            // but activation is token-driven to match Telegram/Discord behavior.
            if !legacy.bot_token.is_empty() && !legacy.app_token.is_empty() {
                bots.push(SlackBotConfig {
                    app_token: legacy.app_token.clone(),
                    bot_token: legacy.bot_token.clone(),
                    allowed_user_ids: legacy.allowed_user_ids.clone(),
                    use_threads: legacy.use_threads,
                });
            }
        }

        bots
    }

    /// Resolve fields set to `"keychain"` by reading them from the OS credential store.
    fn resolve_secrets(&mut self) -> anyhow::Result<()> {
        self.provider.resolve_secrets_recursive(None)?;

        // Legacy telegram config
        if let Some(ref mut telegram) = self.telegram {
            if telegram.bot_token == "keychain" {
                telegram.bot_token = resolve_from_keychain("bot_token")?;
            }
        }

        // Telegram bots array
        for (i, bot) in self.telegram_bots.iter_mut().enumerate() {
            if bot.bot_token == "keychain" {
                let key = if i == 0 {
                    "bot_token".to_string()
                } else {
                    format!("telegram_bot_token_{}", i)
                };
                bot.bot_token = resolve_from_keychain(&key)?;
            }
        }

        if let Some(ref email) = self.triggers.email {
            if email.password == "keychain" {
                let password = resolve_from_keychain("email_password")?;
                let mut email = email.clone();
                email.password = password;
                self.triggers.email = Some(email);
            }
        }
        if let Some(ref key) = self.state.encryption_key {
            if key == "keychain" {
                self.state.encryption_key = Some(resolve_from_keychain("encryption_key")?);
            }
        }
        if self.search.api_key == "keychain" {
            self.search.api_key = resolve_from_keychain("search_api_key")?;
        }
        if self.terminal.daemon_connect_token.as_deref() == Some("keychain") {
            self.terminal.daemon_connect_token =
                Some(resolve_from_keychain("terminal_daemon_connect_token")?);
        }

        #[cfg(feature = "discord")]
        {
            if let Some(ref mut discord) = self.discord {
                if discord.bot_token == "keychain" {
                    discord.bot_token = resolve_from_keychain("discord_bot_token")?;
                }
            }
            for (i, bot) in self.discord_bots.iter_mut().enumerate() {
                if bot.bot_token == "keychain" {
                    let key = if i == 0 {
                        "discord_bot_token".to_string()
                    } else {
                        format!("discord_bot_token_{}", i)
                    };
                    bot.bot_token = resolve_from_keychain(&key)?;
                }
            }
        }

        #[cfg(feature = "slack")]
        {
            if let Some(ref mut slack) = self.slack {
                if slack.app_token == "keychain" {
                    slack.app_token = resolve_from_keychain("slack_app_token")?;
                }
                if slack.bot_token == "keychain" {
                    slack.bot_token = resolve_from_keychain("slack_bot_token")?;
                }
            }
            for (i, bot) in self.slack_bots.iter_mut().enumerate() {
                if bot.app_token == "keychain" {
                    let key = if i == 0 {
                        "slack_app_token".to_string()
                    } else {
                        format!("slack_app_token_{}", i)
                    };
                    bot.app_token = resolve_from_keychain(&key)?;
                }
                if bot.bot_token == "keychain" {
                    let key = if i == 0 {
                        "slack_bot_token".to_string()
                    } else {
                        format!("slack_bot_token_{}", i)
                    };
                    bot.bot_token = resolve_from_keychain(&key)?;
                }
            }
        }

        // HTTP auth profiles
        for (name, profile) in self.http_auth.iter_mut() {
            let fields: &mut [(&str, &mut Option<String>)] = &mut [
                ("api_key", &mut profile.api_key),
                ("api_secret", &mut profile.api_secret),
                ("access_token", &mut profile.access_token),
                ("access_token_secret", &mut profile.access_token_secret),
                ("token", &mut profile.token),
                ("header_value", &mut profile.header_value),
                ("password", &mut profile.password),
            ];
            for (field, value) in fields.iter_mut() {
                if let Some(ref v) = value {
                    if v == "keychain" {
                        let keychain_key = format!("http_auth_{}_{}", name, field);
                        **value = Some(resolve_from_keychain(&keychain_key)?);
                    }
                }
            }
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use once_cell::sync::Lazy;

    #[test]
    fn tool_result_chars_for_unknown_model_uses_global_default() {
        let config = ContextWindowConfig::default();
        assert_eq!(
            config.tool_result_chars_for("some-model"),
            config.max_tool_result_chars
        );
    }

    #[test]
    fn tool_result_chars_for_explicit_override_wins() {
        let mut config = ContextWindowConfig::default();
        config.model_budgets.insert("gemma-3-4b".to_string(), 8_000);
        config
            .model_tool_result_chars
            .insert("gemma-3-4b".to_string(), 1_500);
        assert_eq!(config.tool_result_chars_for("gemma-3-4b"), 1_500);
    }

    #[test]
    fn tool_result_chars_for_scales_down_with_small_model_budget() {
        // Small local model: budget 8k tokens vs default 48k → tool results
        // shrink proportionally so a single read can't swamp the window.
        let mut config = ContextWindowConfig::default();
        config.model_budgets.insert("gemma-3-4b".to_string(), 8_000);
        let chars = config.tool_result_chars_for("gemma-3-4b");
        assert!(
            chars < config.max_tool_result_chars,
            "small budget should shrink the cap, got {chars}"
        );
        assert!(chars >= 1_000, "cap must not drop below usable floor");
    }

    #[test]
    fn tool_result_chars_for_scales_up_with_large_model_budget() {
        // Big cloud model: budget 192k tokens → tool results may grow so
        // powerful models see more of each file in one read.
        let mut config = ContextWindowConfig::default();
        config
            .model_budgets
            .insert("claude-fable-5".to_string(), 192_000);
        let chars = config.tool_result_chars_for("claude-fable-5");
        assert!(
            chars > config.max_tool_result_chars,
            "large budget should raise the cap, got {chars}"
        );
    }

    static ENV_EXPANSION_LOCK: Lazy<std::sync::Mutex<()>> = Lazy::new(|| std::sync::Mutex::new(()));
    static TERMINAL_WEB_APP_URL_ENV_LOCK: Lazy<std::sync::Mutex<()>> =
        Lazy::new(|| std::sync::Mutex::new(()));
    static TERMINAL_BRIDGE_ENABLED_ENV_LOCK: Lazy<std::sync::Mutex<()>> =
        Lazy::new(|| std::sync::Mutex::new(()));
    static TERMINAL_STATIC_FALLBACK_ENV_LOCK: Lazy<std::sync::Mutex<()>> =
        Lazy::new(|| std::sync::Mutex::new(()));
    static KEYCHAIN_ENV_FILE_LOCK: Lazy<std::sync::Mutex<()>> =
        Lazy::new(|| std::sync::Mutex::new(()));

    fn restore_env_var(name: &str, old_value: Option<String>) {
        if let Some(old) = old_value {
            std::env::set_var(name, old);
        } else {
            std::env::remove_var(name);
        }
    }

    #[test]
    fn cli_agents_config_defaults_to_disabled() {
        let config: AppConfig = toml::from_str(
            r#"
            [provider]
            api_key = "test"
            "#,
        )
        .unwrap();
        assert!(!config.cli_agents.enabled);
    }

    #[test]
    fn health_config_defaults_to_disabled() {
        let config: AppConfig = toml::from_str(
            r#"
            [provider]
            api_key = "test"
            "#,
        )
        .unwrap();
        assert!(!config.health.enabled);
    }

    #[test]
    fn diagnostics_config_defaults_to_disabled() {
        let config: AppConfig = toml::from_str(
            r#"
            [provider]
            api_key = "test"
            "#,
        )
        .unwrap();
        assert!(!config.diagnostics.enabled);
        assert!(config.diagnostics.record_decision_points);
    }

    #[test]
    fn tools_config_defaults_disable_low_value_base_tools() {
        let config = ToolsConfig::default();
        for tool in [
            "git_info",
            "git_commit",
            "policy_metrics",
            "check_environment",
            "service_status",
            "project_inspect",
            "read_channel_history",
            "tool_trace",
        ] {
            assert!(
                !config.is_enabled(tool),
                "expected {tool} to be disabled by default"
            );
        }
        assert!(config.is_enabled("terminal"));
        assert!(config.is_enabled("goal_trace"));
        assert!(!config.is_enabled("tool_trace"));
        assert!(config.is_enabled("search_files"));
    }

    #[test]
    fn tools_disabled_list_parses_from_config() {
        let config: AppConfig = toml::from_str(
            r#"
            [provider]
            api_key = "test"
            [tools]
            disabled = ["policy_metrics", "service_status"]
            "#,
        )
        .unwrap();
        assert_eq!(
            config.tools.disabled,
            vec!["policy_metrics".to_string(), "service_status".to_string()]
        );
        assert!(!config.tools.is_enabled("policy_metrics"));
        assert!(config.tools.is_enabled("terminal"));
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn expand_env_vars_replaces_set_variable() {
        let _guard = ENV_EXPANSION_LOCK.lock().unwrap();
        std::env::set_var("AIDAEMON_TEST_VAR", "hello");
        let result = expand_env_vars("key = \"${AIDAEMON_TEST_VAR}\"").unwrap();
        assert_eq!(result, "key = \"hello\"");
        std::env::remove_var("AIDAEMON_TEST_VAR");
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn expand_env_vars_errors_on_missing() {
        let _guard = ENV_EXPANSION_LOCK.lock().unwrap();
        std::env::remove_var("AIDAEMON_MISSING_VAR");
        let result = expand_env_vars("key = \"${AIDAEMON_MISSING_VAR}\"");
        assert!(result.is_err());
        let msg = result.unwrap_err().to_string();
        assert!(msg.contains("AIDAEMON_MISSING_VAR"));
    }

    #[test]
    fn expand_env_vars_leaves_plain_strings() {
        let input = "key = \"sk-hardcoded-value\"";
        let result = expand_env_vars(input).unwrap();
        assert_eq!(result, input);
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn expand_env_vars_handles_multiple() {
        let _guard = ENV_EXPANSION_LOCK.lock().unwrap();
        std::env::set_var("AIDAEMON_TEST_A", "aaa");
        std::env::set_var("AIDAEMON_TEST_B", "bbb");
        let result =
            expand_env_vars("a = \"${AIDAEMON_TEST_A}\"\nb = \"${AIDAEMON_TEST_B}\"").unwrap();
        assert_eq!(result, "a = \"aaa\"\nb = \"bbb\"");
        std::env::remove_var("AIDAEMON_TEST_A");
        std::env::remove_var("AIDAEMON_TEST_B");
    }

    #[test]
    fn expand_env_vars_ignores_bare_dollar() {
        let input = "path = \"$HOME/something\"";
        let result = expand_env_vars(input).unwrap();
        assert_eq!(result, input);
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn store_in_keychain_writes_env_file_when_keychain_disabled() {
        let _guard = KEYCHAIN_ENV_FILE_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let env_path = tmp.path().join(".env.test");
        std::fs::write(&env_path, "EXISTING=1\n").unwrap();

        let old_no_keychain = std::env::var("AIDAEMON_NO_KEYCHAIN").ok();
        let old_env_file = std::env::var("AIDAEMON_ENV_FILE").ok();
        std::env::set_var("AIDAEMON_NO_KEYCHAIN", "1");
        std::env::set_var("AIDAEMON_ENV_FILE", env_path.to_string_lossy().to_string());

        store_in_keychain("oauth_twitter_access_token", "new token$1")
            .expect("write oauth token to env");

        let content = std::fs::read_to_string(&env_path).expect("read env file");
        assert!(content.contains("EXISTING=1"));
        assert!(content.contains("OAUTH_TWITTER_ACCESS_TOKEN=\"new token\\$1\""));

        restore_env_var("AIDAEMON_NO_KEYCHAIN", old_no_keychain);
        restore_env_var("AIDAEMON_ENV_FILE", old_env_file);
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn delete_from_keychain_removes_env_file_key_when_keychain_disabled() {
        let _guard = KEYCHAIN_ENV_FILE_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let env_path = tmp.path().join(".env.test");
        std::fs::write(
            &env_path,
            "KEEP=1\nOAUTH_TWITTER_ACCESS_TOKEN=oldtoken\nANOTHER=2\n",
        )
        .unwrap();

        let old_no_keychain = std::env::var("AIDAEMON_NO_KEYCHAIN").ok();
        let old_env_file = std::env::var("AIDAEMON_ENV_FILE").ok();
        std::env::set_var("AIDAEMON_NO_KEYCHAIN", "1");
        std::env::set_var("AIDAEMON_ENV_FILE", env_path.to_string_lossy().to_string());

        delete_from_keychain("oauth_twitter_access_token").expect("delete oauth token from env");

        let content = std::fs::read_to_string(&env_path).expect("read env file");
        assert!(content.contains("KEEP=1"));
        assert!(content.contains("ANOTHER=2"));
        assert!(!content.contains("OAUTH_TWITTER_ACCESS_TOKEN"));

        restore_env_var("AIDAEMON_NO_KEYCHAIN", old_no_keychain);
        restore_env_var("AIDAEMON_ENV_FILE", old_env_file);
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn resolve_from_keychain_reads_env_file_when_keychain_disabled() {
        let _guard = KEYCHAIN_ENV_FILE_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let env_path = tmp.path().join(".env.test");
        std::fs::write(&env_path, "OAUTH_TWITTER_CLIENT_ID=client123\n").unwrap();

        let old_no_keychain = std::env::var("AIDAEMON_NO_KEYCHAIN").ok();
        let old_env_file = std::env::var("AIDAEMON_ENV_FILE").ok();
        let old_client_id = std::env::var("OAUTH_TWITTER_CLIENT_ID").ok();
        std::env::set_var("AIDAEMON_NO_KEYCHAIN", "1");
        std::env::set_var("AIDAEMON_ENV_FILE", env_path.to_string_lossy().to_string());
        std::env::remove_var("OAUTH_TWITTER_CLIENT_ID");

        let value = resolve_from_keychain("oauth_twitter_client_id").expect("resolve client id");
        assert_eq!(value, "client123");

        restore_env_var("AIDAEMON_NO_KEYCHAIN", old_no_keychain);
        restore_env_var("AIDAEMON_ENV_FILE", old_env_file);
        restore_env_var("OAUTH_TWITTER_CLIENT_ID", old_client_id);
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn resolve_from_keychain_tolerates_malformed_env_lines() {
        let _guard = KEYCHAIN_ENV_FILE_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let env_path = tmp.path().join(".env.test");
        std::fs::write(
            &env_path,
            "BROKEN=\"unterminated\nOAUTH_TWITTER_CLIENT_ID=client123\n",
        )
        .unwrap();

        let old_no_keychain = std::env::var("AIDAEMON_NO_KEYCHAIN").ok();
        let old_env_file = std::env::var("AIDAEMON_ENV_FILE").ok();
        let old_client_id = std::env::var("OAUTH_TWITTER_CLIENT_ID").ok();
        std::env::set_var("AIDAEMON_NO_KEYCHAIN", "1");
        std::env::set_var("AIDAEMON_ENV_FILE", env_path.to_string_lossy().to_string());
        std::env::remove_var("OAUTH_TWITTER_CLIENT_ID");

        let value = resolve_from_keychain("oauth_twitter_client_id")
            .expect("resolve client id despite malformed env line");
        assert_eq!(value, "client123");

        restore_env_var("AIDAEMON_NO_KEYCHAIN", old_no_keychain);
        restore_env_var("AIDAEMON_ENV_FILE", old_env_file);
        restore_env_var("OAUTH_TWITTER_CLIENT_ID", old_client_id);
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn resolve_env_file_path_prefers_runtime_resolved_path() {
        let _guard = KEYCHAIN_ENV_FILE_LOCK.lock().unwrap();
        let old_runtime_env_file = std::env::var(crate::RUNTIME_ENV_FILE_ENV_KEY).ok();
        let old_env_file = std::env::var("AIDAEMON_ENV_FILE").ok();

        std::env::set_var(crate::RUNTIME_ENV_FILE_ENV_KEY, "/daemon/root/.env");
        std::env::set_var("AIDAEMON_ENV_FILE", "/some/other/.env");

        assert_eq!(resolve_env_file_path(), PathBuf::from("/daemon/root/.env"));

        restore_env_var(crate::RUNTIME_ENV_FILE_ENV_KEY, old_runtime_env_file);
        restore_env_var("AIDAEMON_ENV_FILE", old_env_file);
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn expand_env_vars_reports_all_missing() {
        let _guard = ENV_EXPANSION_LOCK.lock().unwrap();
        std::env::remove_var("AIDAEMON_MISS_X");
        std::env::remove_var("AIDAEMON_MISS_Y");
        let result = expand_env_vars("a = \"${AIDAEMON_MISS_X}\"\nb = \"${AIDAEMON_MISS_Y}\"");
        assert!(result.is_err());
        let msg = result.unwrap_err().to_string();
        assert!(msg.contains("AIDAEMON_MISS_X"));
        assert!(msg.contains("AIDAEMON_MISS_Y"));
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn terminal_web_app_url_defaults_to_hosted_terminal() {
        let _guard = TERMINAL_WEB_APP_URL_ENV_LOCK.lock().unwrap();
        let old = std::env::var("AIDAEMON_TERMINAL_WEB_APP_URL").ok();
        std::env::remove_var("AIDAEMON_TERMINAL_WEB_APP_URL");
        let cfg = TerminalConfig::default();
        assert_eq!(cfg.effective_web_app_url(), "https://terminal.aidaemon.ai");
        if let Some(old) = old {
            std::env::set_var("AIDAEMON_TERMINAL_WEB_APP_URL", old);
        }
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn terminal_web_app_url_env_override_wins() {
        let _guard = TERMINAL_WEB_APP_URL_ENV_LOCK.lock().unwrap();
        let old = std::env::var("AIDAEMON_TERMINAL_WEB_APP_URL").ok();
        std::env::set_var(
            "AIDAEMON_TERMINAL_WEB_APP_URL",
            "https://terminal.dev.aidaemon.ai",
        );
        let cfg = TerminalConfig {
            web_app_url: "https://terminal.aidaemon.ai".to_string(),
            ..TerminalConfig::default()
        };
        assert_eq!(
            cfg.effective_web_app_url(),
            "https://terminal.dev.aidaemon.ai"
        );
        if let Some(old) = old {
            std::env::set_var("AIDAEMON_TERMINAL_WEB_APP_URL", old);
        } else {
            std::env::remove_var("AIDAEMON_TERMINAL_WEB_APP_URL");
        }
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn terminal_bridge_enabled_defaults_to_true() {
        let _guard = TERMINAL_BRIDGE_ENABLED_ENV_LOCK.lock().unwrap();
        let old = std::env::var("AIDAEMON_TERMINAL_BRIDGE_ENABLED").ok();
        std::env::remove_var("AIDAEMON_TERMINAL_BRIDGE_ENABLED");
        let cfg = TerminalConfig::default();
        assert!(cfg.effective_bridge_enabled());
        if let Some(old) = old {
            std::env::set_var("AIDAEMON_TERMINAL_BRIDGE_ENABLED", old);
        }
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn terminal_bridge_enabled_env_override_wins() {
        let _guard = TERMINAL_BRIDGE_ENABLED_ENV_LOCK.lock().unwrap();
        let old = std::env::var("AIDAEMON_TERMINAL_BRIDGE_ENABLED").ok();
        std::env::set_var("AIDAEMON_TERMINAL_BRIDGE_ENABLED", "false");
        let cfg = TerminalConfig {
            bridge_enabled: true,
            ..TerminalConfig::default()
        };
        assert!(!cfg.effective_bridge_enabled());
        if let Some(old) = old {
            std::env::set_var("AIDAEMON_TERMINAL_BRIDGE_ENABLED", old);
        } else {
            std::env::remove_var("AIDAEMON_TERMINAL_BRIDGE_ENABLED");
        }
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn terminal_static_fallback_defaults_to_disabled() {
        let _guard = TERMINAL_STATIC_FALLBACK_ENV_LOCK.lock().unwrap();
        let old = std::env::var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK").ok();
        std::env::remove_var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK");
        let cfg = TerminalConfig::default();
        assert!(!cfg.effective_allow_static_token_fallback());
        if let Some(old) = old {
            std::env::set_var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK", old);
        }
    }

    #[test]
    #[serial_test::serial(env_vars)]
    fn terminal_static_fallback_env_override_wins() {
        let _guard = TERMINAL_STATIC_FALLBACK_ENV_LOCK.lock().unwrap();
        let old = std::env::var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK").ok();
        std::env::set_var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK", "true");
        let cfg = TerminalConfig {
            allow_static_token_fallback: false,
            ..TerminalConfig::default()
        };
        assert!(cfg.effective_allow_static_token_fallback());
        if let Some(old) = old {
            std::env::set_var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK", old);
        } else {
            std::env::remove_var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK");
        }
    }

    #[test]
    fn provider_gateway_token_defaults_to_none() {
        let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "test-key"

[provider.models]
primary = "gpt-4o"
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        assert_eq!(cfg.provider.gateway_token, None);
    }

    #[test]
    fn provider_gateway_token_parses_when_set() {
        let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "test-key"
gateway_token = "cf-gw-token"

[provider.models]
primary = "gpt-4o"
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        assert_eq!(cfg.provider.gateway_token.as_deref(), Some("cf-gw-token"));
    }

    #[test]
    fn provider_extra_headers_parse_when_set() {
        let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "test-key"
extra_headers = { "x-app" = "aidaemon", "x-env" = "dev" }

[provider.models]
primary = "gpt-4o"
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        let headers = cfg.provider.extra_headers.expect("extra headers");
        assert_eq!(headers.get("x-app"), Some(&"aidaemon".to_string()));
        assert_eq!(headers.get("x-env"), Some(&"dev".to_string()));
    }

    #[test]
    fn provider_max_tokens_parses_when_set() {
        let toml = r#"
[provider]
kind = "anthropic"
api_key = "test-key"
max_tokens = 32768

[provider.models]
primary = "claude-sonnet-4-20250514"
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        assert_eq!(cfg.provider.max_tokens, Some(32768));
    }

    #[test]
    fn provider_kind_parses_xai_native() {
        let toml = r#"
[provider]
kind = "xai_native"
api_key = "test-key"

[provider.models]
primary = "grok-4"
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        assert_eq!(cfg.provider.kind, ProviderKind::XaiNative);
    }

    #[test]
    fn provider_models_support_default_and_fallback_keys() {
        let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "test-key"

[provider.models]
default = "kimi-k2.5"
fallback = ["mistral-nemo", "gpt-4o-mini"]
"#;
        let mut cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        cfg.provider.models.apply_defaults(&cfg.provider.kind);
        assert_eq!(cfg.provider.models.default_model, "kimi-k2.5");
        assert_eq!(
            cfg.provider.models.fallback_models,
            vec!["mistral-nemo".to_string(), "gpt-4o-mini".to_string()]
        );
        // Legacy compatibility mirrors default/fallback.
        assert_eq!(cfg.provider.models.primary, "kimi-k2.5");
    }

    #[test]
    fn provider_models_legacy_keys_derive_default_and_fallback() {
        let mut models = ModelsConfig {
            default_model: String::new(),
            fallback_models: Vec::new(),
            primary: "primary-model".to_string(),
            fast: "fast-model".to_string(),
            smart: "smart-model".to_string(),
        };
        models.apply_defaults(&ProviderKind::OpenaiCompatible);
        assert_eq!(models.default_model, "primary-model");
        assert_eq!(
            models.fallback_models,
            vec!["smart-model".to_string(), "fast-model".to_string()]
        );
    }

    #[test]
    fn provider_fallback_configs_parse() {
        let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "primary-key"

[provider.models]
default = "primary-model"
fallback = ["primary-backup"]

[[provider.fallbacks]]
kind = "anthropic"
api_key = "secondary-key"

[provider.fallbacks.models]
default = "claude-sonnet-4-20250514"
fallback = ["claude-3-5-haiku-latest"]
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        assert_eq!(cfg.provider.fallbacks.len(), 1);
        assert_eq!(cfg.provider.fallbacks[0].kind, ProviderKind::Anthropic);
        assert_eq!(cfg.provider.fallbacks[0].api_key, "secondary-key");
        assert_eq!(
            cfg.provider.fallbacks[0].models.default_model,
            "claude-sonnet-4-20250514"
        );
    }

    #[test]
    fn provider_failover_alias_still_parses() {
        let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "primary-key"

[provider.models]
default = "primary-model"

[[provider.failover]]
kind = "xai_native"
api_key = "secondary-key"

[provider.failover.models]
default = "grok-4"
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        assert_eq!(cfg.provider.fallbacks.len(), 1);
        assert_eq!(cfg.provider.fallbacks[0].kind, ProviderKind::XaiNative);
        assert_eq!(cfg.provider.fallbacks[0].models.default_model, "grok-4");
    }

    #[test]
    fn provider_fallback_model_defaults_apply_recursively() {
        let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "primary-key"

[provider.models]
primary = "primary-model"
fast = "primary-fast"

[[provider.fallbacks]]
kind = "anthropic"
api_key = "secondary-key"

[provider.fallbacks.models]
primary = "claude-sonnet-4-20250514"
smart = "claude-3-5-haiku-latest"
"#;
        let mut cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        cfg.provider.apply_model_defaults_recursive();
        assert_eq!(cfg.provider.models.default_model, "primary-model");
        assert_eq!(
            cfg.provider.models.fallback_models,
            vec!["primary-fast".to_string()]
        );
        assert_eq!(
            cfg.provider.fallbacks[0].models.default_model,
            "claude-sonnet-4-20250514"
        );
        assert_eq!(
            cfg.provider.fallbacks[0].models.fallback_models,
            vec!["claude-3-5-haiku-latest".to_string()]
        );
    }

    #[test]
    fn iteration_limit_default_is_unlimited() {
        let config = SubagentsConfig::default();
        assert!(matches!(
            config.iteration_limit,
            IterationLimitConfig::Unlimited
        ));
    }

    #[test]
    fn iteration_limit_effective_returns_unlimited_when_defaults_unchanged() {
        let config = SubagentsConfig::default();
        assert!(matches!(
            config.effective_iteration_limit(),
            IterationLimitConfig::Unlimited
        ));
    }

    #[test]
    fn iteration_limit_effective_migrates_legacy_config() {
        // Simulate old config with custom values
        let config = SubagentsConfig {
            max_iterations: 15,
            max_iterations_cap: 30,
            ..SubagentsConfig::default()
        };

        match config.effective_iteration_limit() {
            IterationLimitConfig::Hard { initial, cap } => {
                assert_eq!(initial, 15);
                assert_eq!(cap, 30);
            }
            _ => panic!("Expected Hard mode migration"),
        }
    }

    #[test]
    fn iteration_limit_explicit_soft_preserved() {
        // Even with legacy values set, explicit config takes precedence
        let config = SubagentsConfig {
            iteration_limit: IterationLimitConfig::Soft {
                threshold: 50,
                warn_at: 40,
            },
            max_iterations: 15,
            max_iterations_cap: 30,
            ..SubagentsConfig::default()
        };

        match config.effective_iteration_limit() {
            IterationLimitConfig::Soft { threshold, warn_at } => {
                assert_eq!(threshold, 50);
                assert_eq!(warn_at, 40);
            }
            _ => panic!("Expected Soft mode to be preserved"),
        }
    }

    #[test]
    fn task_timeout_default_is_30_minutes() {
        let config = SubagentsConfig::default();
        assert_eq!(config.task_timeout_secs, Some(1800));
    }

    #[test]
    fn write_consistency_policy_defaults_are_applied() {
        let policy = PolicyConfig::default();
        assert_eq!(policy.write_consistency.max_abs_global_delta, 3);
        assert_eq!(policy.write_consistency.max_session_mismatch_count, 0);
        assert_eq!(policy.write_consistency.max_stale_task_starts, 0);
        assert_eq!(policy.write_consistency.max_missing_message_id_events, 0);
    }

    #[test]
    fn write_consistency_policy_can_be_overridden_in_toml() {
        let toml = r#"
[provider]
api_key = "test-key"
kind = "openai_compatible"

[provider.models]
primary = "gpt-4o"
fast = "gpt-4o-mini"
smart = "gpt-4o"

[terminal]
allowed_prefixes = ["ls"]

[policy.write_consistency]
max_abs_global_delta = 7
max_session_mismatch_count = 2
max_stale_task_starts = 1
max_missing_message_id_events = 4
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        assert_eq!(cfg.policy.write_consistency.max_abs_global_delta, 7);
        assert_eq!(cfg.policy.write_consistency.max_session_mismatch_count, 2);
        assert_eq!(cfg.policy.write_consistency.max_stale_task_starts, 1);
        assert_eq!(
            cfg.policy.write_consistency.max_missing_message_id_events,
            4
        );
    }

    #[test]
    fn queue_policy_normalizes_invalid_values() {
        let policy = QueuePolicyConfig {
            approval_capacity: 0,
            media_capacity: 0,
            trigger_event_capacity: 0,
            warning_ratio: f32::NAN,
            overload_ratio: f32::INFINITY,
            lanes: QueueLanePoliciesConfig::default(),
            adaptive_shedding: Some(true),
            fair_trigger_sessions: Some(true),
            fair_trigger_session_window_secs: Some(0),
            fair_trigger_max_events_per_session: Some(0),
        };
        let normalized = policy.normalized();
        assert_eq!(normalized.approval_capacity, 1);
        assert_eq!(normalized.media_capacity, 1);
        assert_eq!(normalized.trigger_event_capacity, 1);
        assert_eq!(normalized.warning_ratio, 0.75);
        assert_eq!(normalized.overload_ratio, 0.90);
        assert_eq!(normalized.lanes.approval.fair_session_window_secs, 1);
        assert_eq!(normalized.lanes.media.fair_session_window_secs, 1);
        assert_eq!(normalized.lanes.trigger.fair_session_window_secs, 1);
        assert_eq!(normalized.lanes.approval.fair_max_events_per_session, 1);
        assert_eq!(normalized.lanes.media.fair_max_events_per_session, 1);
        assert_eq!(normalized.lanes.trigger.fair_max_events_per_session, 1);
    }

    #[test]
    fn queue_policy_legacy_shared_knobs_apply_to_all_lanes() {
        let policy = QueuePolicyConfig {
            adaptive_shedding: Some(false),
            fair_trigger_sessions: Some(false),
            fair_trigger_session_window_secs: Some(13),
            fair_trigger_max_events_per_session: Some(2),
            ..QueuePolicyConfig::default()
        };
        let normalized = policy.normalized();
        for lane in [
            &normalized.lanes.approval,
            &normalized.lanes.media,
            &normalized.lanes.trigger,
        ] {
            assert!(!lane.adaptive_shedding);
            assert!(!lane.fair_sessions);
            assert_eq!(lane.fair_session_window_secs, 13);
            assert_eq!(lane.fair_max_events_per_session, 2);
        }
    }

    #[test]
    fn queue_policy_lane_specific_overrides_take_precedence() {
        let mut policy = QueuePolicyConfig {
            adaptive_shedding: Some(false),
            fair_trigger_sessions: Some(false),
            fair_trigger_session_window_secs: Some(13),
            fair_trigger_max_events_per_session: Some(2),
            ..QueuePolicyConfig::default()
        };
        policy.lanes.media = QueueLanePolicyConfig {
            adaptive_shedding: true,
            fair_sessions: true,
            fair_session_window_secs: 99,
            fair_max_events_per_session: 9,
        };
        let normalized = policy.normalized();
        assert!(!normalized.lanes.approval.adaptive_shedding);
        assert!(!normalized.lanes.trigger.adaptive_shedding);
        assert_eq!(normalized.lanes.approval.fair_session_window_secs, 13);
        assert_eq!(normalized.lanes.trigger.fair_session_window_secs, 13);
        assert!(normalized.lanes.media.adaptive_shedding);
        assert!(normalized.lanes.media.fair_sessions);
        assert_eq!(normalized.lanes.media.fair_session_window_secs, 99);
        assert_eq!(normalized.lanes.media.fair_max_events_per_session, 9);
    }

    #[test]
    fn queue_policy_lane_specific_toml_is_supported() {
        let toml = r#"
[provider]
api_key = "test-key"
kind = "openai_compatible"

[provider.models]
primary = "gpt-4o"
fast = "gpt-4o-mini"
smart = "gpt-4o"

[terminal]
allowed_prefixes = ["ls"]

[daemon.queue_policy.lanes.approval]
adaptive_shedding = false
fair_sessions = false
fair_session_window_secs = 45
fair_max_events_per_session = 3

[daemon.queue_policy.lanes.media]
adaptive_shedding = true
fair_sessions = true
fair_session_window_secs = 20
fair_max_events_per_session = 2
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        let queue = cfg.daemon.queue_policy.normalized();
        assert!(!queue.lanes.approval.adaptive_shedding);
        assert_eq!(queue.lanes.approval.fair_session_window_secs, 45);
        assert_eq!(queue.lanes.approval.fair_max_events_per_session, 3);
        assert!(queue.lanes.media.adaptive_shedding);
        assert_eq!(queue.lanes.media.fair_session_window_secs, 20);
        assert_eq!(queue.lanes.media.fair_max_events_per_session, 2);
    }

    #[test]
    fn all_telegram_bots_merges_legacy_and_preserves_webhook_config() {
        let toml = r#"
[provider]
api_key = "test-key"
kind = "openai_compatible"

[provider.models]
primary = "gpt-4o"
fast = "gpt-4o-mini"
smart = "gpt-4o"

[terminal]
allowed_prefixes = ["ls"]

[[telegram_bots]]
bot_token = "array-token"
allowed_user_ids = [111]

[telegram_bots.webhook]
enabled = true
public_url = "https://array.example.com/hook"
listen_addr = "0.0.0.0:8443"
path = "/array"
max_connections = 42
drop_pending_updates = true

[telegram]
bot_token = "legacy-token"
allowed_user_ids = [222]

[telegram.webhook]
enabled = true
public_url = "https://legacy.example.com/hook"
listen_addr = "127.0.0.1:9443"
path = "/legacy"
max_connections = 21
drop_pending_updates = false
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        let bots = cfg.all_telegram_bots();
        assert_eq!(bots.len(), 2);

        assert_eq!(bots[0].bot_token, "array-token");
        assert_eq!(bots[0].allowed_user_ids, vec![111]);
        assert!(bots[0].webhook.enabled);
        assert_eq!(
            bots[0].webhook.public_url.as_deref(),
            Some("https://array.example.com/hook")
        );
        assert_eq!(bots[0].webhook.listen_addr.as_deref(), Some("0.0.0.0:8443"));
        assert_eq!(bots[0].webhook.path.as_deref(), Some("/array"));
        assert_eq!(bots[0].webhook.max_connections, Some(42));
        assert!(bots[0].webhook.drop_pending_updates);

        assert_eq!(bots[1].bot_token, "legacy-token");
        assert_eq!(bots[1].allowed_user_ids, vec![222]);
        assert!(bots[1].webhook.enabled);
        assert_eq!(
            bots[1].webhook.public_url.as_deref(),
            Some("https://legacy.example.com/hook")
        );
        assert_eq!(
            bots[1].webhook.listen_addr.as_deref(),
            Some("127.0.0.1:9443")
        );
        assert_eq!(bots[1].webhook.path.as_deref(), Some("/legacy"));
        assert_eq!(bots[1].webhook.max_connections, Some(21));
        assert!(!bots[1].webhook.drop_pending_updates);
    }

    #[test]
    fn telegram_webhook_defaults_to_disabled_when_omitted() {
        let toml = r#"
[provider]
api_key = "test-key"
kind = "openai_compatible"

[provider.models]
primary = "gpt-4o"
fast = "gpt-4o-mini"
smart = "gpt-4o"

[terminal]
allowed_prefixes = ["ls"]

[telegram]
bot_token = "legacy-token"
allowed_user_ids = [333]
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        let bots = cfg.all_telegram_bots();
        assert_eq!(bots.len(), 1);
        assert_eq!(bots[0].bot_token, "legacy-token");
        assert!(!bots[0].webhook.enabled);
        assert_eq!(bots[0].webhook.public_url, None);
        assert_eq!(bots[0].webhook.listen_addr, None);
        assert_eq!(bots[0].webhook.path, None);
        assert_eq!(bots[0].webhook.max_connections, None);
        assert!(!bots[0].webhook.drop_pending_updates);
    }

    #[cfg(feature = "slack")]
    #[test]
    fn all_slack_bots_merges_legacy_without_enabled_gate() {
        let toml = r#"
[provider]
api_key = "test-key"
kind = "openai_compatible"

[provider.models]
primary = "gpt-4o"
fast = "gpt-4o-mini"
smart = "gpt-4o"

[terminal]
allowed_prefixes = ["ls"]

[slack]
enabled = false
app_token = "xapp-123"
bot_token = "xoxb-456"
allowed_user_ids = ["U123"]
"#;
        let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
        let bots = cfg.all_slack_bots();
        assert_eq!(bots.len(), 1);
        assert_eq!(bots[0].app_token, "xapp-123");
        assert_eq!(bots[0].bot_token, "xoxb-456");
        assert_eq!(bots[0].allowed_user_ids, vec!["U123"]);
    }

    // === Browser session_isolation parsing + resolution ===

    /// A BrowserConfig with no profile and no remote-debugging port (truly
    /// ephemeral), letting us isolate the `session_isolation` field's behavior.
    fn ephemeral_browser_config(isolation: Option<SessionIsolation>) -> BrowserConfig {
        BrowserConfig {
            enabled: true,
            headless: true,
            screenshot_width: 1280,
            screenshot_height: 720,
            remote_debugging_port: None,
            user_data_dir: None,
            profile: None,
            session_isolation: isolation,
            ..BrowserConfig::default()
        }
    }

    #[test]
    fn parse_session_isolation_page() {
        let toml = r#"
enabled = true
user_data_dir = "/tmp/profile"
session_isolation = "page"
"#;
        let cfg: BrowserConfig = toml::from_str(toml).expect("parse browser config");
        assert_eq!(cfg.session_isolation, Some(SessionIsolation::Page));
    }

    #[test]
    fn parse_session_isolation_browser_context() {
        let toml = r#"
enabled = true
session_isolation = "browser_context"
"#;
        let cfg: BrowserConfig = toml::from_str(toml).expect("parse browser config");
        assert_eq!(
            cfg.session_isolation,
            Some(SessionIsolation::BrowserContext)
        );
    }

    #[test]
    fn parse_session_isolation_omitted_is_none() {
        let toml = r#"
enabled = true
"#;
        let cfg: BrowserConfig = toml::from_str(toml).expect("parse browser config");
        assert_eq!(cfg.session_isolation, None);
    }

    #[test]
    fn parse_session_isolation_invalid_value_rejected() {
        let toml = r#"
enabled = true
session_isolation = "incognito"
"#;
        let result: Result<BrowserConfig, _> = toml::from_str(toml);
        assert!(
            result.is_err(),
            "unknown session_isolation variant should fail to parse"
        );
    }

    #[test]
    fn browser_timeout_defaults_are_sane() {
        let cfg = BrowserConfig::default();
        assert_eq!(cfg.nav_timeout(), std::time::Duration::from_secs(30));
        assert_eq!(cfg.element_timeout(), std::time::Duration::from_secs(10));
        assert_eq!(cfg.action_timeout(), std::time::Duration::from_secs(30));
    }

    #[test]
    fn browser_timeouts_clamp_out_of_range_values() {
        // Zero is clamped up to the per-field minimum (1s) so navigation/wait
        // never gives up before it can start.
        let mut cfg = BrowserConfig::default();
        cfg.nav_timeout_secs = 0;
        cfg.element_timeout_secs = 0;
        cfg.action_timeout_secs = 0;
        assert_eq!(cfg.nav_timeout(), std::time::Duration::from_secs(1));
        assert_eq!(cfg.element_timeout(), std::time::Duration::from_secs(1));
        assert_eq!(cfg.action_timeout(), std::time::Duration::from_secs(1));

        // Absurdly large values are clamped down to the per-field maximum so a
        // typo can never hang the tool indefinitely.
        cfg.nav_timeout_secs = 1_000_000;
        cfg.element_timeout_secs = 1_000_000;
        cfg.action_timeout_secs = 1_000_000;
        assert_eq!(cfg.nav_timeout(), std::time::Duration::from_secs(120));
        assert_eq!(cfg.element_timeout(), std::time::Duration::from_secs(120));
        assert_eq!(cfg.action_timeout(), std::time::Duration::from_secs(300));
    }

    #[test]
    fn browser_timeouts_in_range_pass_through() {
        let mut cfg = BrowserConfig::default();
        cfg.nav_timeout_secs = 45;
        cfg.element_timeout_secs = 5;
        cfg.action_timeout_secs = 90;
        assert_eq!(cfg.nav_timeout(), std::time::Duration::from_secs(45));
        assert_eq!(cfg.element_timeout(), std::time::Duration::from_secs(5));
        assert_eq!(cfg.action_timeout(), std::time::Duration::from_secs(90));
    }

    #[test]
    fn resolve_auto_ephemeral_is_browser_context() {
        let cfg = ephemeral_browser_config(None);
        assert_eq!(
            cfg.resolve_session_isolation(),
            Ok(SessionIsolation::BrowserContext)
        );
    }

    #[test]
    fn resolve_auto_with_profile_is_page() {
        let mut cfg = ephemeral_browser_config(None);
        cfg.user_data_dir = Some("/tmp/profile".to_string());
        assert_eq!(cfg.resolve_session_isolation(), Ok(SessionIsolation::Page));
    }

    #[test]
    fn resolve_auto_with_remote_port_is_page() {
        let mut cfg = ephemeral_browser_config(None);
        cfg.remote_debugging_port = Some(9222);
        assert_eq!(cfg.resolve_session_isolation(), Ok(SessionIsolation::Page));
    }

    #[test]
    fn resolve_page_with_profile_is_ok_page() {
        let mut cfg = ephemeral_browser_config(Some(SessionIsolation::Page));
        cfg.user_data_dir = Some("/tmp/profile".to_string());
        assert_eq!(cfg.resolve_session_isolation(), Ok(SessionIsolation::Page));
    }

    #[test]
    fn resolve_browser_context_ephemeral_is_ok() {
        let cfg = ephemeral_browser_config(Some(SessionIsolation::BrowserContext));
        assert_eq!(
            cfg.resolve_session_isolation(),
            Ok(SessionIsolation::BrowserContext)
        );
    }

    #[test]
    fn resolve_browser_context_with_profile_is_err() {
        let mut cfg = ephemeral_browser_config(Some(SessionIsolation::BrowserContext));
        cfg.user_data_dir = Some("/tmp/profile".to_string());
        let err = cfg
            .resolve_session_isolation()
            .expect_err("browser_context + persistent profile must be rejected");
        assert!(
            err.contains("browser_context") && err.contains("user_data_dir"),
            "error should explain the incompatibility: {err}"
        );
    }

    #[test]
    fn resolve_browser_context_with_remote_port_is_err() {
        let mut cfg = ephemeral_browser_config(Some(SessionIsolation::BrowserContext));
        cfg.remote_debugging_port = Some(9222);
        let err = cfg
            .resolve_session_isolation()
            .expect_err("browser_context + remote-debugging Chrome must be rejected");
        assert!(
            err.contains("browser_context") && err.contains("remote_debugging_port"),
            "error should explain the incompatibility: {err}"
        );
    }

    #[test]
    fn shipped_default_config_resolves_without_error() {
        // The shipped default uses a persistent profile (user_data_dir set) and
        // session_isolation = None. It must resolve to Page, not error.
        let cfg = BrowserConfig::default();
        assert_eq!(cfg.session_isolation, None);
        assert_eq!(cfg.resolve_session_isolation(), Ok(SessionIsolation::Page));
    }
}
