//! Configuration: providers, model presets, and layered config loading.
//!
//! Precedence: CLI flags > environment variables > ~/.lucid-train/config.toml > defaults.

use serde::Deserialize;
use std::path::PathBuf;

pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
pub const OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
// Production Lucid Cloud (Railway). Override with LUCID_CLOUD_URL for local dev
// (e.g. http://localhost:8787/v1). The `/v1` suffix is the OpenAI-compatible
// proxy; `cloud_root` strips it to reach /auth and /billing.
pub const LUCID_CLOUD_DEFAULT_URL: &str = "https://lucid-cloud-api-production.up.railway.app/v1";
/// Codex subscription endpoint root (the Responses API lives at `/responses`).
pub const CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";

pub const DEFAULT_MAX_ITERATIONS: u32 = 150;
pub const DEFAULT_SHELL_TIMEOUT_MS: u64 = 300_000;

/// Effective context window for local Ollama models. The local server is
/// started with `OLLAMA_CONTEXT_LENGTH` pinned to this value to cap KV-cache
/// memory, so the real usable window is this — NOT the model's theoretical max.
/// Compaction must track this number, so the TUI and `spawn_ollama_serve` both
/// read it here to stay in sync.
pub const OLLAMA_RUNTIME_CONTEXT_TOKENS: u32 = 16_384;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provider {
    OpenRouter,
    OpenAi,
    /// Local Ollama server (small open-source models run on-device).
    Ollama,
    /// Any self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, SGLang, TGI...).
    Custom,
    /// Lucid Cloud: logged-in proxy with credits (big/proprietary models
    /// without bringing your own key).
    LucidCloud,
    /// ChatGPT/Codex subscription via "Sign in with ChatGPT" (no API key — uses
    /// the OAuth token from `codex_auth`, talks the Responses API).
    Codex,
}

impl Provider {
    pub fn label(&self) -> &'static str {
        match self {
            Provider::OpenRouter => "openrouter",
            Provider::OpenAi => "openai",
            Provider::Ollama => "ollama (local)",
            Provider::Custom => "self-hosted",
            Provider::LucidCloud => "lucid cloud",
            Provider::Codex => "codex (chatgpt)",
        }
    }

    pub fn default_base_url(&self) -> String {
        match self {
            Provider::OpenRouter => OPENROUTER_BASE_URL.to_string(),
            Provider::OpenAi => OPENAI_BASE_URL.to_string(),
            Provider::Ollama => OLLAMA_BASE_URL.to_string(),
            Provider::Custom => OPENROUTER_BASE_URL.to_string(),
            Provider::LucidCloud => std::env::var("LUCID_CLOUD_URL")
                .ok()
                .filter(|s| !s.is_empty())
                .unwrap_or_else(|| LUCID_CLOUD_DEFAULT_URL.to_string()),
            Provider::Codex => CODEX_BASE_URL.to_string(),
        }
    }

    pub fn parse(s: &str) -> Option<Provider> {
        match s.to_ascii_lowercase().as_str() {
            "openrouter" => Some(Provider::OpenRouter),
            "openai" => Some(Provider::OpenAi),
            "ollama" | "local" => Some(Provider::Ollama),
            "custom" | "self-hosted" | "selfhosted" | "vllm" | "llamacpp" => Some(Provider::Custom),
            "lucid" | "lucid-cloud" | "lucidcloud" | "cloud" => Some(Provider::LucidCloud),
            "codex" | "chatgpt" | "codex (chatgpt)" => Some(Provider::Codex),
            _ => None,
        }
    }
}

/// A known model preset. `alias` is what users type; `id` is what the API receives.
pub struct ModelPreset {
    pub alias: &'static str,
    pub id: &'static str,
    pub provider: Provider,
    pub context_tokens: u32,
    pub note: &'static str,
}

/// One mebi-token: the de-facto "1M context" window most frontier OpenRouter
/// models report (2^20, matching what `context_length` returns over the API).
pub const CTX_1M: u32 = 1_048_576;
/// 256K window (Kimi K2 family, Qwen3-Coder, Devstral...).
pub const CTX_256K: u32 = 262_144;
/// Conservative window for an unrecognised model: smaller than most real
/// windows, so compaction fires early rather than letting a turn overflow.
pub const DEFAULT_CONTEXT_TOKENS: u32 = 131_072;

/// Curated catalog: the full open-weight families served over OpenRouter
/// (Kimi/Moonshot, DeepSeek, Qwen, GLM/Zhipu, MiniMax, Nemotron, Devstral,
/// Llama, Mistral...), the ChatGPT/Codex subscription models, and the small
/// models that run fully locally through Ollama. `context_tokens` is each
/// model's real window — it drives the context bar and compaction threshold.
///
/// Any OpenRouter id NOT listed here still gets a correct window at runtime via
/// `resolve_context_tokens` (live `/models` cache → id heuristic → default).
pub const MODEL_PRESETS: &[ModelPreset] = &[
    // --- Tencent Hy3 (FREE while in preview) — the current default ---
    // TODO: when tencent/hy3:free stops being free, move ★ back to
    // kimi-k2.7-code and revert Config::default (model/alias/context).
    ModelPreset { alias: "hy3", id: "tencent/hy3:free", provider: Provider::OpenRouter, context_tokens: CTX_256K, note: "★ Lucid's choice — Tencent Hy3, FREE while in preview, 256K context, agentic tool-calling" },
    // --- Kimi / Moonshot (256K window across the K2 family) ---
    ModelPreset { alias: "kimi-k2.7-code", id: "moonshotai/kimi-k2.7-code", provider: Provider::OpenRouter, context_tokens: CTX_256K, note: "Kimi K2.7 Code — coding specialist, 256K context ($0.75/$3.50)" },
    ModelPreset { alias: "kimi-k2.7", id: "moonshotai/kimi-k2.7", provider: Provider::OpenRouter, context_tokens: CTX_256K, note: "Moonshot Kimi K2.7 — agentic generalist, 256K" },
    ModelPreset { alias: "kimi-k2.6", id: "moonshotai/kimi-k2.6", provider: Provider::OpenRouter, context_tokens: CTX_256K, note: "Moonshot Kimi K2.6 — 256K" },
    ModelPreset { alias: "kimi-k2.5", id: "moonshotai/kimi-k2.5", provider: Provider::OpenRouter, context_tokens: CTX_256K, note: "Moonshot Kimi K2.5 — 256K" },
    // --- DeepSeek ---
    ModelPreset { alias: "deepseek-v4-pro", id: "deepseek/deepseek-v4-pro", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "DeepSeek V4 Pro — cheapest near-frontier, 1M ctx ($0.44/$0.87, 93.5 LiveCodeBench)" },
    ModelPreset { alias: "deepseek-v4-flash", id: "deepseek/deepseek-v4-flash", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "DeepSeek V4 Flash — cost-efficient MoE, 1M ctx" },
    ModelPreset { alias: "deepseek", id: "deepseek/deepseek-chat-v3.1", provider: Provider::OpenRouter, context_tokens: 131_072, note: "DeepSeek V3.1 — 128K" },
    // --- Qwen (Alibaba) ---
    ModelPreset { alias: "qwen3.7-max", id: "qwen/qwen3.7-max", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "Qwen3.7 Max — 1M ctx flagship agentic" },
    ModelPreset { alias: "qwen3.7-plus", id: "qwen/qwen3.7-plus", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "Qwen3.7 Plus — 1M ctx, balanced" },
    ModelPreset { alias: "qwen3-coder", id: "qwen/qwen3-coder", provider: Provider::OpenRouter, context_tokens: CTX_256K, note: "Qwen3 Coder 480B-A35B — 256K (free-tier capable)" },
    ModelPreset { alias: "qwen3-coder-next", id: "qwen/qwen3-coder-next", provider: Provider::OpenRouter, context_tokens: CTX_256K, note: "Qwen3 Coder Next — efficiency-focused MoE, 256K" },
    ModelPreset { alias: "qwen3.6-27b", id: "qwen/qwen3.6-27b", provider: Provider::OpenRouter, context_tokens: CTX_256K, note: "Qwen3.6 27B dense — 256K, local-friendly" },
    // --- GLM / Zhipu (Z.ai) ---
    ModelPreset { alias: "glm", id: "z-ai/glm-5.2", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "Zhipu GLM 5.2 — highest open-weight SWE-Bench Pro, 1M" },
    ModelPreset { alias: "glm-5.2", id: "z-ai/glm-5.2", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "Zhipu GLM 5.2 — SWE-Bench Pro leader, 1M" },
    ModelPreset { alias: "glm-5.1", id: "z-ai/glm-5.1", provider: Provider::OpenRouter, context_tokens: 200_000, note: "Zhipu GLM 5.1 — 200K" },
    ModelPreset { alias: "glm-5", id: "z-ai/glm-5", provider: Provider::OpenRouter, context_tokens: 200_000, note: "Zhipu GLM 5 — frontier tier, 200K" },
    ModelPreset { alias: "glm-4.7-flash", id: "z-ai/glm-4.7-flash", provider: Provider::OpenRouter, context_tokens: 200_000, note: "Zhipu GLM 4.7 Flash — fast/cheap, 200K" },
    // --- MiniMax ---
    ModelPreset { alias: "minimax", id: "minimax/minimax-m3", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "MiniMax M3 — 1M ctx, native multimodal" },
    ModelPreset { alias: "minimax-m3", id: "minimax/minimax-m3", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "MiniMax M3 — 1M ctx, native multimodal" },
    ModelPreset { alias: "minimax-m2.5", id: "minimax/minimax-m2.5", provider: Provider::OpenRouter, context_tokens: 204_800, note: "MiniMax M2.5 — agentic, 200K" },
    ModelPreset { alias: "minimax-m2.1", id: "minimax/minimax-m2.1", provider: Provider::OpenRouter, context_tokens: 200_000, note: "MiniMax M2.1 — agentic, 200K" },
    // --- Other open-weight ---
    ModelPreset { alias: "nemotron-3-super", id: "nvidia/nemotron-3-super", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "NVIDIA Nemotron 3 Super — 1M ctx, 91.8 RULER@1M" },
    ModelPreset { alias: "devstral-2", id: "mistralai/devstral-2", provider: Provider::OpenRouter, context_tokens: CTX_256K, note: "Mistral Devstral 2 — coding agent, 256K" },
    ModelPreset { alias: "trinity-large", id: "arcee-ai/trinity-large", provider: Provider::OpenRouter, context_tokens: 131_072, note: "Arcee Trinity Large — reasoning MoE, 128K" },
    ModelPreset { alias: "llama-4-maverick", id: "meta-llama/llama-4-maverick", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "Meta Llama 4 Maverick — 1M ctx" },
    // --- Proprietary via OpenRouter (optional) ---
    ModelPreset { alias: "gpt-5.1-codex", id: "openai/gpt-5.1-codex", provider: Provider::OpenRouter, context_tokens: 400_000, note: "OpenAI GPT-5.1 Codex — 400K" },
    ModelPreset { alias: "claude-sonnet", id: "anthropic/claude-sonnet-4.5", provider: Provider::OpenRouter, context_tokens: 200_000, note: "Claude Sonnet 4.5 — 200K" },
    ModelPreset { alias: "gemini", id: "google/gemini-2.5-pro", provider: Provider::OpenRouter, context_tokens: CTX_1M, note: "Gemini 2.5 Pro — 1M ctx" },
    // --- ChatGPT/Codex subscription (Sign in with ChatGPT — no API key) ---
    ModelPreset { alias: "gpt-5.5", id: "gpt-5.5", provider: Provider::Codex, context_tokens: 400_000, note: "★ newest frontier coding model — default on your ChatGPT subscription" },
    ModelPreset { alias: "gpt-5.4", id: "gpt-5.4", provider: Provider::Codex, context_tokens: 400_000, note: "GPT-5.4 — flagship for coding, reasoning, agentic work" },
    ModelPreset { alias: "gpt-5.4-mini", id: "gpt-5.4-mini", provider: Provider::Codex, context_tokens: 400_000, note: "GPT-5.4 mini — fast, efficient, good for subagents" },
    ModelPreset { alias: "gpt-5.1-codex-max", id: "gpt-5.1-codex-max", provider: Provider::Codex, context_tokens: 400_000, note: "GPT-5.1 Codex Max — long-horizon agentic coding" },
    ModelPreset { alias: "gpt-5.3-codex-spark", id: "gpt-5.3-codex-spark", provider: Provider::Codex, context_tokens: 400_000, note: "near-instant coding iteration (ChatGPT Pro only)" },
    // --- Small open-source models, fully local via Ollama ---
    ModelPreset { alias: "local-qwen-coder", id: "qwen2.5-coder:7b", provider: Provider::Ollama, context_tokens: 32_768, note: "Qwen2.5 Coder 7B — runs locally" },
    ModelPreset { alias: "local-qwen3", id: "qwen3:8b", provider: Provider::Ollama, context_tokens: 40_960, note: "Qwen3 8B — runs locally" },
    ModelPreset { alias: "local-deepseek-coder", id: "deepseek-coder-v2:16b", provider: Provider::Ollama, context_tokens: 32_768, note: "DeepSeek Coder V2 Lite — local" },
    ModelPreset { alias: "local-llama", id: "llama3.1:8b", provider: Provider::Ollama, context_tokens: 32_768, note: "Llama 3.1 8B — runs locally" },
];

/// Human-friendly context-window label, e.g. `1M`, `256K`, `200K`, `16K`.
///
/// Power-of-two windows use a binary-K label (262144 → "256K") while round
/// decimal windows use a decimal-K label (200000 → "200K"), so each model's
/// label matches how its window is actually advertised.
pub fn fmt_context_window(tokens: u32) -> String {
    if tokens >= 1_048_576 && tokens % 1_048_576 == 0 {
        format!("{}M", tokens / 1_048_576)
    } else if tokens >= 1_000_000 {
        format!("{:.1}M", tokens as f64 / 1_000_000.0)
    } else if tokens >= 1000 {
        if tokens % 1024 == 0 {
            format!("{}K", tokens / 1024)
        } else {
            format!("{}K", (tokens as f64 / 1000.0).round() as u32)
        }
    } else {
        tokens.to_string()
    }
}

pub fn find_preset(name: &str) -> Option<&'static ModelPreset> {
    let lower = name.to_ascii_lowercase();
    MODEL_PRESETS
        .iter()
        .find(|p| p.alias.eq_ignore_ascii_case(&lower) || p.id.eq_ignore_ascii_case(&lower))
}

/// Session mode, mirroring the Lucid Train IDE assistant:
/// agent = full tools; plan = read/propose only (no tools);
/// research = web tools only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Agent,
    Plan,
    Research,
}

impl Mode {
    pub fn label(&self) -> &'static str {
        match self {
            Mode::Agent => "agent",
            Mode::Plan => "plan",
            Mode::Research => "research",
        }
    }
}

/// Which tool surface an [`Agent`](crate::agent::Agent) exposes to the model.
/// `Full` is the normal interactive harness; `ReadOnly` is the restricted
/// surface given to explore subagents: `read_file` + `run_shell_command`
/// only, with every approval-requiring command auto-denied (never prompts).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToolProfile {
    #[default]
    Full,
    ReadOnly,
}

/// Model roles resolvable via `[roles]` in config.toml. Defaults:
/// visual → multimodal image analysis, implement → strong coding model used
/// only when explicitly requested, explore → cheap fan-out subagents.
pub const ROLE_VISUAL_DEFAULT: &str = "deepseek/deepseek-v4-pro";
pub const ROLE_IMPLEMENT_DEFAULT: &str = "z-ai/glm-5.2";
pub const ROLE_EXPLORE_DEFAULT: &str = "deepseek/deepseek-v4-flash";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalPolicy {
    /// Read & propose only; every command needs explicit approval.
    Plan,
    /// Safe commands auto-approved; risky commands prompt. (default)
    Auto,
    /// Everything auto-approved except hard-blocked destructive commands.
    FullAuto,
}

impl ApprovalPolicy {
    pub fn label(&self) -> &'static str {
        match self {
            ApprovalPolicy::Plan => "plan",
            ApprovalPolicy::Auto => "auto",
            ApprovalPolicy::FullAuto => "full-auto",
        }
    }

    pub fn parse(s: &str) -> Option<ApprovalPolicy> {
        match s.to_ascii_lowercase().as_str() {
            "plan" | "read-only" | "readonly" => Some(ApprovalPolicy::Plan),
            "auto" | "auto-edit" | "autoedit" => Some(ApprovalPolicy::Auto),
            "full-auto" | "fullauto" | "full" | "yolo" => Some(ApprovalPolicy::FullAuto),
            _ => None,
        }
    }
}

#[derive(Debug, Clone)]
pub struct Config {
    pub model: String,
    pub model_alias: String,
    pub provider: Provider,
    pub base_url: String,
    pub api_key: String,
    /// Lucid Cloud login token (set by /login).
    pub lucid_token: String,
    /// Lucid Cloud account email.
    pub lucid_email: String,
    pub fallback_models: Vec<String>,
    pub max_context_tokens: u32,
    pub max_iterations: u32,
    pub shell_timeout_ms: u64,
    pub approval: ApprovalPolicy,
    pub cwd: PathBuf,
    pub temperature: Option<f64>,
    pub mcp_servers: Vec<crate::mcp::McpServerConfig>,
    pub mode: Mode,
    /// Check for a newer published version on startup and prompt to update.
    pub update_check: bool,
    /// A version the user chose to skip (don't prompt again until newer).
    pub skipped_version: String,
    /// Reasoning effort for Codex/GPT-5.x models: low | medium | high | xhigh.
    /// (GPT-5.x rejects "minimal", so it's never sent.)
    pub reasoning_effort: String,
    /// Set once the first-run onboarding wizard has been seen/dismissed, so it
    /// isn't shown again on every launch.
    pub onboarded: bool,
    /// `[roles]` table from config.toml: role name → model id/alias
    /// (keys: visual, implement, explore). Missing keys use built-in defaults.
    pub roles: std::collections::HashMap<String, String>,
    /// Tool surface exposed to the model (Full for interactive sessions,
    /// ReadOnly for explore subagents). Never persisted.
    pub tool_profile: ToolProfile,
}

impl Config {
    /// Model id for a named role: the `[roles]` entry when configured,
    /// otherwise the built-in default for that role. Unknown roles resolve to
    /// the primary model.
    pub fn role_model(&self, role: &str) -> String {
        if let Some(m) = self.roles.get(role).filter(|m| !m.trim().is_empty()) {
            return m.clone();
        }
        match role {
            "visual" => ROLE_VISUAL_DEFAULT.into(),
            "implement" => ROLE_IMPLEMENT_DEFAULT.into(),
            "explore" => ROLE_EXPLORE_DEFAULT.into(),
            _ => self.model.clone(),
        }
    }

    /// Full model config for a role, resolved through the MODEL_PRESETS /
    /// [`apply_model`] machinery WITHOUT mutating the primary config: clones
    /// self, applies the role model, returns the clone.
    ///
    /// LucidCloud/Custom endpoints proxy arbitrary model ids, so for those the
    /// current provider/base_url are kept (pinned) and only the model +
    /// context window change; other providers let the preset resolve its own
    /// provider/base_url.
    pub fn role_config(&self, role: &str) -> Config {
        let mut clone = self.clone();
        let model = self.role_model(role);
        let pin = matches!(self.provider, Provider::LucidCloud | Provider::Custom);
        apply_model(&mut clone, &model, pin, pin);
        clone
    }
}

impl Default for Config {
    fn default() -> Self {
        Config {
            // Default to Lucid Cloud (hosted, logged-in proxy) with Tencent Hy3 —
            // "Lucid's choice", FREE while in preview. BYO OpenRouter/Ollama/Codex
            // stay available via /provider and /model.
            // TODO: when tencent/hy3:free stops being free, revert this default to
            // deepseek/deepseek-v4-pro (alias deepseek-v4-pro, max_context CTX_1M).
            model: "tencent/hy3:free".into(),
            model_alias: "hy3".into(),
            provider: Provider::LucidCloud,
            base_url: Provider::LucidCloud.default_base_url(),
            api_key: String::new(),
            lucid_token: String::new(),
            lucid_email: String::new(),
            fallback_models: Vec::new(),
            max_context_tokens: CTX_256K,
            max_iterations: DEFAULT_MAX_ITERATIONS,
            shell_timeout_ms: DEFAULT_SHELL_TIMEOUT_MS,
            approval: ApprovalPolicy::Auto,
            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
            temperature: None,
            mcp_servers: Vec::new(),
            mode: Mode::Agent,
            update_check: true,
            skipped_version: String::new(),
            reasoning_effort: "medium".into(),
            onboarded: false,
            roles: std::collections::HashMap::new(),
            tool_profile: ToolProfile::Full,
        }
    }
}

#[derive(Debug, Default)]
pub struct CliOverrides {
    pub model: Option<String>,
    pub provider: Option<String>,
    pub base_url: Option<String>,
    pub api_key: Option<String>,
    pub approvals: Option<String>,
    pub max_iterations: Option<u32>,
    pub cwd: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
struct FileConfig {
    model: Option<String>,
    provider: Option<String>,
    base_url: Option<String>,
    api_key: Option<String>,
    lucid_token: Option<String>,
    lucid_email: Option<String>,
    fallback_models: Option<Vec<String>>,
    max_context_tokens: Option<u32>,
    max_iterations: Option<u32>,
    shell_timeout_ms: Option<u64>,
    approvals: Option<String>,
    temperature: Option<f64>,
    mcp_servers: Option<toml::Table>,
    update_check: Option<bool>,
    skipped_version: Option<String>,
    reasoning_effort: Option<String>,
    onboarded: Option<bool>,
    roles: Option<toml::Table>,
}

fn parse_mcp_servers(table: &toml::Table) -> Vec<crate::mcp::McpServerConfig> {
    let mut servers = Vec::new();
    for (name, value) in table {
        let Some(t) = value.as_table() else { continue };
        let Some(command) = t.get("command").and_then(|v| v.as_str()) else { continue };
        let args = t
            .get("args")
            .and_then(|v| v.as_array())
            .map(|arr| arr.iter().filter_map(|a| a.as_str().map(String::from)).collect())
            .unwrap_or_default();
        // Optional `[mcp_servers.<name>.env]` table of string values (e.g.
        // GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN for the Grafana server).
        let env = t
            .get("env")
            .and_then(|v| v.as_table())
            .map(|env| {
                env.iter()
                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
                    .collect()
            })
            .unwrap_or_default();
        servers.push(crate::mcp::McpServerConfig {
            name: name.clone(),
            command: command.to_string(),
            args,
            env,
        });
    }
    servers
}

pub fn config_dir() -> PathBuf {
    if let Ok(home) = std::env::var("LUCID_TRAIN_HOME") {
        return PathBuf::from(home);
    }
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".lucid-train")
}

/// Load config leniently: parse into a generic table and pull each field
/// individually, so one unknown/mistyped/extra key can never wipe the rest
/// (that bug silently dropped the saved api_key on every restart).
fn load_file_config() -> FileConfig {
    let path = config_dir().join("config.toml");
    match std::fs::read_to_string(&path) {
        Ok(content) => parse_file_config(&content),
        Err(_) => FileConfig::default(),
    }
}

/// Lenient parse of a config.toml body. A single unknown/mistyped/extra key
/// never wipes the rest — every field is pulled individually.
fn parse_file_config(content: &str) -> FileConfig {
    let Ok(table) = content.parse::<toml::Table>() else {
        return FileConfig::default();
    };
    let s = |k: &str| table.get(k).and_then(|v| v.as_str()).map(String::from);
    FileConfig {
        model: s("model"),
        provider: s("provider"),
        base_url: s("base_url"),
        api_key: s("api_key"),
        lucid_token: s("lucid_token"),
        lucid_email: s("lucid_email"),
        fallback_models: table.get("fallback_models").and_then(|v| v.as_array()).map(|a| {
            a.iter().filter_map(|x| x.as_str().map(String::from)).collect()
        }),
        max_context_tokens: table.get("max_context_tokens").and_then(|v| v.as_integer()).map(|n| n as u32),
        max_iterations: table.get("max_iterations").and_then(|v| v.as_integer()).map(|n| n as u32),
        shell_timeout_ms: table.get("shell_timeout_ms").and_then(|v| v.as_integer()).map(|n| n as u64),
        approvals: s("approvals"),
        temperature: table.get("temperature").and_then(|v| v.as_float()),
        mcp_servers: table.get("mcp_servers").and_then(|v| v.as_table()).cloned(),
        update_check: table.get("update_check").and_then(|v| v.as_bool()),
        skipped_version: s("skipped_version"),
        reasoning_effort: s("reasoning_effort"),
        onboarded: table.get("onboarded").and_then(|v| v.as_bool()),
        roles: table.get("roles").and_then(|v| v.as_table()).cloned(),
    }
}

/// Convert a `[roles]` TOML table into the role → model map (string values
/// only; anything else is ignored, never fatal).
fn parse_roles(table: &toml::Table) -> std::collections::HashMap<String, String> {
    table
        .iter()
        .filter_map(|(k, v)| v.as_str().map(|m| (k.clone(), m.to_string())))
        .collect()
}

/// Resolve the model name: presets switch provider/base-url/context unless the
/// user pinned those explicitly. For an arbitrary (non-preset) id the context
/// window is still resolved correctly via [`resolve_context_tokens`], so
/// compaction always tracks the real limit instead of a stale default.
pub fn apply_model(cfg: &mut Config, name: &str, provider_pinned: bool, base_url_pinned: bool) {
    if let Some(preset) = find_preset(name) {
        cfg.model = preset.id.to_string();
        cfg.model_alias = preset.alias.to_string();
        cfg.max_context_tokens = preset.context_tokens;
        if !provider_pinned {
            cfg.provider = preset.provider;
            if !base_url_pinned {
                cfg.base_url = preset.provider.default_base_url();
            }
        }
    } else {
        cfg.model = name.to_string();
        cfg.model_alias = name.to_string();
        cfg.max_context_tokens = resolve_context_tokens(name);
    }
}

/// Best-effort context window for an arbitrary model id that isn't a curated
/// preset. Resolution order:
///   1. cached OpenRouter `context_length` (refreshed in the background)
///   2. id-substring heuristic by model family
///   3. conservative [`DEFAULT_CONTEXT_TOKENS`] fallback
///
/// Purely local/offline-safe: the cache is just a file, and a miss never
/// blocks. The background fetch in the TUI keeps the cache fresh.
pub fn resolve_context_tokens(id: &str) -> u32 {
    if let Some(ctx) = load_context_cache().get(id).copied() {
        if ctx > 0 {
            return ctx;
        }
    }
    infer_context_from_id(id).unwrap_or(DEFAULT_CONTEXT_TOKENS)
}

/// Guess a context window from a model id by family. Patterns are ordered
/// most-specific first. Returns `None` when nothing matches (caller falls back
/// to the conservative default).
pub fn infer_context_from_id(id: &str) -> Option<u32> {
    let s = id.to_ascii_lowercase();
    // Ollama-style local tag ("qwen2.5-coder:7b") — colon, no vendor slash.
    if s.contains(':') && !s.contains('/') {
        return Some(32_768);
    }
    // 1M-context frontier families.
    const MILLION: &[&str] = &[
        "deepseek-v4", "qwen3.7", "qwen-max", "minimax-m3", "nemotron",
        "gemini", "llama-4-maverick", "llama-4-scout", "grok-4",
    ];
    if MILLION.iter().any(|m| s.contains(m)) {
        return Some(CTX_1M);
    }
    // 256K families (Kimi K2, Qwen3-Coder, Devstral).
    const K256: &[&str] = &["kimi", "/k2", "-k2", "qwen3-coder", "devstral"];
    if K256.iter().any(|m| s.contains(m)) {
        return Some(CTX_256K);
    }
    // GLM / Zhipu — 200K.
    if s.contains("glm") {
        return Some(200_000);
    }
    // OpenAI GPT-5.x / Codex — 400K.
    if s.contains("gpt-5") || s.contains("codex") {
        return Some(400_000);
    }
    // Anthropic Claude — 200K (1M variants are opt-in and rare on OpenRouter).
    if s.contains("claude") {
        return Some(200_000);
    }
    None
}

/// Path to the on-disk OpenRouter model→context cache, refreshed in the
/// background by the TUI from `GET /api/v1/models`.
pub fn openrouter_cache_path() -> PathBuf {
    config_dir().join("openrouter_models.json")
}

/// Load the cached `id → context_length` map. Accepts either a flat
/// `{"vendor/model": 262144}` object or the raw OpenRouter
/// `{"data":[{"id":..,"context_length":..}]}` payload. Any read/parse error
/// yields an empty map — a missing or corrupt cache is never fatal.
pub fn load_context_cache() -> std::collections::HashMap<String, u32> {
    match std::fs::read_to_string(openrouter_cache_path()) {
        Ok(content) => parse_context_cache(&content),
        Err(_) => std::collections::HashMap::new(),
    }
}

fn parse_context_cache(content: &str) -> std::collections::HashMap<String, u32> {
    let mut map = std::collections::HashMap::new();
    let Ok(val) = serde_json::from_str::<serde_json::Value>(content) else {
        return map;
    };
    if let Some(data) = val.get("data").and_then(|d| d.as_array()) {
        for m in data {
            if let (Some(id), Some(ctx)) = (
                m.get("id").and_then(|v| v.as_str()),
                m.get("context_length").and_then(|v| v.as_u64()),
            ) {
                if ctx > 0 {
                    map.insert(id.to_string(), ctx as u32);
                }
            }
        }
    } else if let Some(obj) = val.as_object() {
        for (k, v) in obj {
            if let Some(ctx) = v.as_u64() {
                if ctx > 0 {
                    map.insert(k.clone(), ctx as u32);
                }
            }
        }
    }
    map
}

pub fn load_config(over: &CliOverrides) -> Config {
    let mut cfg = Config::default();
    let file = load_file_config();

    // ---- file layer ----
    // max_context_tokens is derived from the model preset so the limit always
    // matches the selected model. It is only overridden by an explicit user
    // value, not silently by an old saved default.
    let explicit_max_context = file.max_context_tokens;
    if let Some(v) = file.max_iterations {
        cfg.max_iterations = v;
    }
    if let Some(v) = file.shell_timeout_ms {
        cfg.shell_timeout_ms = v;
    }
    if let Some(v) = file.temperature {
        cfg.temperature = Some(v);
    }
    if let Some(v) = &file.fallback_models {
        cfg.fallback_models = v.clone();
    }
    if let Some(v) = file.approvals.as_deref().and_then(ApprovalPolicy::parse) {
        cfg.approval = v;
    }

    let env = |k: &str| std::env::var(k).ok().filter(|s| !s.is_empty());

    // ---- provider / base_url resolution ----
    let provider_str = over.provider.clone().or_else(|| env("LUCID_PROVIDER")).or(file.provider);
    let provider = provider_str.as_deref().and_then(Provider::parse);
    let provider_pinned = provider.is_some();
    if let Some(p) = provider {
        cfg.provider = p;
        cfg.base_url = p.default_base_url();
    }

    // CLI --base-url / LLM_BASE_URL are explicit overrides for this run.
    let cli_base = over.base_url.clone().or_else(|| env("LLM_BASE_URL"));
    // A *saved* base_url only applies to self-hosted/Custom (or when no provider
    // is pinned at all). Known providers — OpenRouter, OpenAI, Ollama, Codex,
    // LucidCloud — always derive their endpoint from the provider, so a stale
    // saved URL (e.g. an old OpenRouter base left over from /apikey after the
    // user switched to Codex) can't mismatch the provider and break inference.
    let file_base = file
        .base_url
        .filter(|_| cfg.provider == Provider::Custom || !provider_pinned);
    let base_url = cli_base.or(file_base);
    let base_url_pinned = base_url.is_some();
    if let Some(b) = &base_url {
        cfg.base_url = b.trim_end_matches('/').to_string();
        if !provider_pinned {
            cfg.provider = Provider::Custom;
        }
    }

    // ---- model ----
    let model = over.model.clone().or_else(|| env("LLM_MODEL")).or(file.model);
    if let Some(m) = &model {
        apply_model(&mut cfg, m, provider_pinned, base_url_pinned);
    } else if cfg.provider == Provider::Ollama {
        apply_model(&mut cfg, "local-qwen-coder", true, base_url_pinned);
    }
    // Apply only an *explicit* user-provided context limit. The preset value
    // already matches the selected model, so a stale saved default can't
    // shrink a 1M-context model back to 262k.
    if let Some(v) = explicit_max_context {
        cfg.max_context_tokens = v;
    }

    // ---- api key ----
    // Precedence:
    //   1. CLI --api-key
    //   2. LLM_API_KEY environment variable
    //   3. provider-specific environment variable for the resolved provider
    //   4. api_key saved in ~/.lucid-train/config.toml
    //   5. default "ollama" if (and only if) provider is Ollama and nothing else was supplied
    cfg.api_key = over
        .api_key
        .clone()
        .or_else(|| env("LLM_API_KEY"))
        .or_else(|| match cfg.provider {
            Provider::OpenRouter => env("OPENROUTER_API_KEY"),
            Provider::OpenAi => env("OPENAI_API_KEY"),
            Provider::Ollama => None,
            Provider::Custom => env("OPENROUTER_API_KEY").or_else(|| env("OPENAI_API_KEY")),
            Provider::LucidCloud => env("LUCID_TOKEN"),
            // Codex uses the OAuth token from codex_auth, never an API key.
            Provider::Codex => None,
        })
        .or(file.api_key)
        .or_else(|| env("OPENROUTER_API_KEY"))
        .unwrap_or_default();
    if cfg.api_key.is_empty() && cfg.provider == Provider::Ollama {
        cfg.api_key = "ollama".to_string();
    }
    // Codex carries no API key — placeholder so empty-key guards don't trip; the
    // real bearer token is loaded from codex_auth when the client is built.
    if cfg.provider == Provider::Codex {
        cfg.api_key = "codex".to_string();
    }

    // ---- lucid cloud login (from config file / env) ----
    cfg.lucid_token = env("LUCID_TOKEN").or(file.lucid_token).unwrap_or_default();
    cfg.lucid_email = file.lucid_email.unwrap_or_default();
    if cfg.provider == Provider::LucidCloud && !cfg.lucid_token.is_empty() {
        cfg.api_key = cfg.lucid_token.clone();
    }

    if let Some(v) = env("LLM_FALLBACK_MODELS") {
        cfg.fallback_models = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
    }
    if let Some(v) = over.approvals.as_deref().and_then(ApprovalPolicy::parse) {
        cfg.approval = v;
    }
    if let Some(v) = over.max_iterations {
        cfg.max_iterations = v;
    }
    if let Some(dir) = &over.cwd {
        cfg.cwd = PathBuf::from(dir);
    }
    if let Some(table) = &file.mcp_servers {
        cfg.mcp_servers = parse_mcp_servers(table);
    }
    if let Some(v) = file.update_check {
        cfg.update_check = v;
    }
    if std::env::var("LUCID_NO_UPDATE_CHECK").is_ok() {
        cfg.update_check = false;
    }
    if let Some(v) = file.skipped_version {
        cfg.skipped_version = v;
    }
    if let Some(v) = file.reasoning_effort.filter(|s| !s.is_empty()) {
        cfg.reasoning_effort = v;
    }
    if let Some(v) = file.onboarded {
        cfg.onboarded = v;
    }
    if let Some(table) = &file.roles {
        cfg.roles = parse_roles(table);
    }

    cfg
}

/// Set a model for a role in the `[roles]` table of config.toml (mirrors how
/// `[mcp_servers]` entries are persisted).
pub fn save_role(role: &str, model: &str) -> std::io::Result<()> {
    let dir = config_dir();
    std::fs::create_dir_all(&dir)?;
    let path = dir.join("config.toml");
    let mut table: toml::Table = std::fs::read_to_string(&path)
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or_default();
    let roles = table
        .entry("roles".to_string())
        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
    if let Some(roles) = roles.as_table_mut() {
        roles.insert(role.to_string(), toml::Value::String(model.to_string()));
    }
    std::fs::write(&path, toml::to_string_pretty(&table).unwrap_or_default())
}

/// Add or update an MCP server entry in config.toml.
pub fn save_mcp_server(
    name: &str,
    command: &str,
    args: &[String],
    env: &[(String, String)],
) -> std::io::Result<()> {
    let dir = config_dir();
    std::fs::create_dir_all(&dir)?;
    let path = dir.join("config.toml");
    let mut table: toml::Table = std::fs::read_to_string(&path)
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or_default();
    let servers = table
        .entry("mcp_servers".to_string())
        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
    if let Some(servers) = servers.as_table_mut() {
        let mut entry = toml::Table::new();
        entry.insert("command".into(), toml::Value::String(command.to_string()));
        entry.insert(
            "args".into(),
            toml::Value::Array(args.iter().map(|a| toml::Value::String(a.clone())).collect()),
        );
        if !env.is_empty() {
            let mut env_table = toml::Table::new();
            for (k, v) in env {
                env_table.insert(k.clone(), toml::Value::String(v.clone()));
            }
            entry.insert("env".into(), toml::Value::Table(env_table));
        }
        servers.insert(name.to_string(), toml::Value::Table(entry));
    }
    std::fs::write(&path, toml::to_string_pretty(&table).unwrap_or_default())
}

/// Remove an MCP server entry. Returns true if it existed.
pub fn remove_mcp_server(name: &str) -> std::io::Result<bool> {
    let path = config_dir().join("config.toml");
    let mut table: toml::Table = std::fs::read_to_string(&path)
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or_default();
    let mut removed = false;
    if let Some(servers) = table.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) {
        removed = servers.remove(name).is_some();
    }
    std::fs::write(&path, toml::to_string_pretty(&table).unwrap_or_default())?;
    Ok(removed)
}

/// Rough token estimate (~4 chars/token) used when the API doesn't report usage.
pub fn estimate_tokens(text: &str) -> u32 {
    (text.len() as u32 / 4) + 4
}

/// Merge key/value pairs into ~/.lucid-train/config.toml (creating it if
/// needed) so /apikey and /login survive restarts.
pub fn save_config_values(values: &[(&str, &str)]) -> std::io::Result<()> {
    let dir = config_dir();
    std::fs::create_dir_all(&dir)?;
    let path = dir.join("config.toml");
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    // Refuse to clobber a non-empty file we can't parse — back it up instead of
    // silently overwriting it with just the new keys (which would lose the rest).
    let mut table: toml::Table = match existing.parse::<toml::Table>() {
        Ok(t) => t,
        Err(_) if !existing.trim().is_empty() => {
            let _ = std::fs::write(path.with_extension("toml.bak"), &existing);
            toml::Table::new()
        }
        Err(_) => toml::Table::new(),
    };
    for (k, v) in values {
        // Infer a well-typed TOML value so bool/int fields round-trip.
        let value = match *v {
            "true" => toml::Value::Boolean(true),
            "false" => toml::Value::Boolean(false),
            s => match s.parse::<i64>() {
                Ok(n) if k != &"api_key" && k != &"skipped_version" && k != &"model" => {
                    toml::Value::Integer(n)
                }
                _ => toml::Value::String(s.to_string()),
            },
        };
        table.insert(k.to_string(), value);
    }
    std::fs::write(&path, toml::to_string_pretty(&table).unwrap_or_default())
}

/// Serialise tests across the crate that mutate process-wide env vars
/// (`LUCID_TRAIN_HOME`, API-key fallbacks) so they don't race. Crate-visible so
/// `serve`'s tests (which also set `LUCID_TRAIN_HOME`) can share it.
#[cfg(test)]
pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

#[cfg(test)]
mod tests {
    use super::*;
    use super::TEST_ENV_LOCK as ENV_LOCK;

    fn isolated_config_dir() -> (std::path::PathBuf, std::option::Option<String>) {
        let tmp = std::env::temp_dir().join(format!(
            "lucid-train-test-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).unwrap();
        let prev = std::env::var("LUCID_TRAIN_HOME").ok();
        std::env::set_var("LUCID_TRAIN_HOME", &tmp);
        for k in ["LLM_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", "LUCID_TOKEN"] {
            std::env::remove_var(k);
        }
        (tmp, prev)
    }

    fn restore_config_dir(
        tmp: std::path::PathBuf,
        prev: std::option::Option<String>,
    ) {
        if let Some(p) = prev {
            std::env::set_var("LUCID_TRAIN_HOME", p);
        } else {
            std::env::remove_var("LUCID_TRAIN_HOME");
        }
        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[test]
    fn unknown_key_does_not_wipe_api_key() {
        // A config with an extra/unknown key and a type the old all-or-nothing
        // parser would choke on — the api_key must still load.
        let content = r#"
api_key = "sk-or-v1-secret"
model = "moonshotai/kimi-k2.7-code"
some_future_unknown_key = "whatever"
max_iterations = "not-a-number"
"#;
        let fc = parse_file_config(content);
        assert_eq!(fc.api_key.as_deref(), Some("sk-or-v1-secret"));
        assert_eq!(fc.model.as_deref(), Some("moonshotai/kimi-k2.7-code"));
        // The bad-typed field is simply ignored, not fatal.
        assert_eq!(fc.max_iterations, None);
    }

    #[test]
    fn well_typed_fields_round_trip() {
        let content = "update_check = false\nmax_iterations = 42\ntemperature = 0.5\n";
        let fc = parse_file_config(content);
        assert_eq!(fc.update_check, Some(false));
        assert_eq!(fc.max_iterations, Some(42));
        assert_eq!(fc.temperature, Some(0.5));
    }

    #[test]
    fn garbage_file_is_not_fatal() {
        let fc = parse_file_config("this is not [ valid toml @@@");
        assert!(fc.api_key.is_none());
    }

    #[test]
    fn save_config_values_round_trips_api_key_and_provider() {
        let _guard = ENV_LOCK.lock().unwrap();
        let (tmp, prev) = isolated_config_dir();
        save_config_values(&[
            ("api_key", "sk-or-v1-secret"),
            ("provider", "openrouter"),
            ("base_url", "https://openrouter.ai/api/v1"),
        ])
        .unwrap();
        let fc = load_file_config();
        restore_config_dir(tmp, prev);

        assert_eq!(fc.api_key.as_deref(), Some("sk-or-v1-secret"));
        assert_eq!(fc.provider.as_deref(), Some("openrouter"));
        assert_eq!(fc.base_url.as_deref(), Some("https://openrouter.ai/api/v1"));
    }

    #[test]
    fn preset_context_windows_are_correct() {
        // Locks in the windows we researched + the kimi 1M→256K bug fix.
        let expect = [
            ("kimi-k2.7-code", 262_144),
            ("kimi-k2.7", 262_144),
            ("deepseek-v4-pro", 1_048_576),
            ("deepseek-v4-flash", 1_048_576),
            ("deepseek", 131_072),
            ("qwen3.7-max", 1_048_576),
            ("qwen3-coder", 262_144),
            ("glm-5.1", 200_000),
            ("glm", 1_048_576),
            ("minimax-m3", 1_048_576),
            ("nemotron-3-super", 1_048_576),
            ("devstral-2", 262_144),
        ];
        for (alias, want) in expect {
            let p = find_preset(alias).unwrap_or_else(|| panic!("missing preset {alias}"));
            assert_eq!(p.context_tokens, want, "wrong context for {alias}");
        }
    }

    #[test]
    fn preset_aliases_are_unique() {
        let mut seen = std::collections::HashSet::new();
        for p in MODEL_PRESETS {
            assert!(seen.insert(p.alias), "duplicate alias: {}", p.alias);
        }
    }

    #[test]
    fn find_preset_matches_alias_and_id() {
        assert_eq!(find_preset("kimi-k2.7-code").unwrap().id, "moonshotai/kimi-k2.7-code");
        assert_eq!(find_preset("moonshotai/kimi-k2.7-code").unwrap().alias, "kimi-k2.7-code");
        // case-insensitive
        assert!(find_preset("DEEPSEEK-V4-PRO").is_some());
    }

    #[test]
    fn infer_context_by_family() {
        assert_eq!(infer_context_from_id("vendor/deepseek-v4-experimental"), Some(1_048_576));
        assert_eq!(infer_context_from_id("qwen/qwen3.7-turbo"), Some(1_048_576));
        assert_eq!(infer_context_from_id("moonshotai/kimi-k3"), Some(262_144));
        assert_eq!(infer_context_from_id("qwen/qwen3-coder-plus"), Some(262_144));
        assert_eq!(infer_context_from_id("z-ai/glm-7"), Some(200_000));
        assert_eq!(infer_context_from_id("openai/gpt-5.9-codex"), Some(400_000));
        // Ollama-style local tag (colon, no slash).
        assert_eq!(infer_context_from_id("qwen2.5-coder:7b"), Some(32_768));
        // Nothing recognisable.
        assert_eq!(infer_context_from_id("acme/totally-novel-model"), None);
    }

    #[test]
    fn apply_model_unknown_id_uses_default_then_heuristic() {
        let _guard = ENV_LOCK.lock().unwrap();
        let (tmp, prev) = isolated_config_dir();

        // Unrecognised id with no cache → conservative default.
        let mut cfg = Config::default();
        apply_model(&mut cfg, "acme/totally-novel-model", false, false);
        assert_eq!(cfg.max_context_tokens, DEFAULT_CONTEXT_TOKENS);
        assert_eq!(cfg.model, "acme/totally-novel-model");

        // Unrecognised id matching a family heuristic → that window.
        let mut cfg2 = Config::default();
        apply_model(&mut cfg2, "vendor/deepseek-v4-xl", false, false);
        assert_eq!(cfg2.max_context_tokens, 1_048_576);

        restore_config_dir(tmp, prev);
    }

    #[test]
    fn apply_model_prefers_cache_over_heuristic() {
        let _guard = ENV_LOCK.lock().unwrap();
        let (tmp, prev) = isolated_config_dir();
        // Cache says a "glm"-ish id is actually 524288; heuristic would say 200000.
        std::fs::write(
            openrouter_cache_path(),
            r#"{"weird/glm-fork": 524288}"#,
        )
        .unwrap();
        let mut cfg = Config::default();
        apply_model(&mut cfg, "weird/glm-fork", false, false);
        restore_config_dir(tmp, prev);
        assert_eq!(cfg.max_context_tokens, 524_288);
    }

    #[test]
    fn context_cache_accepts_openrouter_payload_and_survives_garbage() {
        let _guard = ENV_LOCK.lock().unwrap();
        let (tmp, prev) = isolated_config_dir();
        // Raw OpenRouter /models shape.
        std::fs::write(
            openrouter_cache_path(),
            r#"{"data":[{"id":"a/b","context_length":12345},{"id":"c/d","context_length":0}]}"#,
        )
        .unwrap();
        let map = load_context_cache();
        assert_eq!(map.get("a/b").copied(), Some(12345));
        assert!(!map.contains_key("c/d"), "zero context_length must be dropped");

        // Corrupt cache → empty map, never panics.
        std::fs::write(openrouter_cache_path(), "not json @@@").unwrap();
        assert!(load_context_cache().is_empty());
        restore_config_dir(tmp, prev);
    }

    #[test]
    fn switching_presets_updates_context_limit() {
        // Regression guard for the original bug: max_context_tokens must track
        // each selected model, not stick at a stale value.
        let mut cfg = Config::default();
        apply_model(&mut cfg, "deepseek-v4-pro", false, false);
        assert_eq!(cfg.max_context_tokens, 1_048_576);
        apply_model(&mut cfg, "glm-5.1", false, false);
        assert_eq!(cfg.max_context_tokens, 200_000);
        apply_model(&mut cfg, "kimi-k2.7-code", false, false);
        assert_eq!(cfg.max_context_tokens, 262_144);
    }

    #[test]
    fn fmt_context_window_labels() {
        assert_eq!(fmt_context_window(1_048_576), "1M");
        assert_eq!(fmt_context_window(262_144), "256K");
        assert_eq!(fmt_context_window(200_000), "200K");
        assert_eq!(fmt_context_window(204_800), "200K");
        assert_eq!(fmt_context_window(400_000), "400K");
        assert_eq!(fmt_context_window(16_384), "16K");
    }

    #[test]
    fn codex_provider_ignores_stale_saved_openrouter_base_url() {
        // Repro: /apikey saved base_url=openrouter, then the user switched to
        // Codex (which saves provider+model but not base_url). On restart the
        // Codex client must still point at the Codex endpoint, not OpenRouter.
        let _guard = ENV_LOCK.lock().unwrap();
        let (tmp, prev) = isolated_config_dir();
        std::fs::write(
            tmp.join("config.toml"),
            r#"provider = "codex (chatgpt)"
model = "gpt-5.5"
base_url = "https://openrouter.ai/api/v1"
"#,
        )
        .unwrap();
        let cfg = load_config(&CliOverrides::default());
        restore_config_dir(tmp, prev);

        assert_eq!(cfg.provider, Provider::Codex);
        assert_eq!(cfg.base_url, CODEX_BASE_URL);
        assert_eq!(cfg.model, "gpt-5.5");
    }

    #[test]
    fn self_hosted_keeps_saved_base_url() {
        // Custom/self-hosted must still honour a persisted base_url.
        let _guard = ENV_LOCK.lock().unwrap();
        let (tmp, prev) = isolated_config_dir();
        std::fs::write(
            tmp.join("config.toml"),
            r#"provider = "custom"
model = "served-model"
base_url = "http://localhost:8000/v1"
"#,
        )
        .unwrap();
        let cfg = load_config(&CliOverrides::default());
        restore_config_dir(tmp, prev);

        assert_eq!(cfg.provider, Provider::Custom);
        assert_eq!(cfg.base_url, "http://localhost:8000/v1");
    }

    #[test]
    fn role_model_defaults_when_unset() {
        let cfg = Config::default();
        assert_eq!(cfg.role_model("visual"), ROLE_VISUAL_DEFAULT);
        assert_eq!(cfg.role_model("implement"), ROLE_IMPLEMENT_DEFAULT);
        assert_eq!(cfg.role_model("explore"), ROLE_EXPLORE_DEFAULT);
        // Unknown roles fall back to the primary model.
        assert_eq!(cfg.role_model("nonsense"), cfg.model);
    }

    #[test]
    fn roles_table_overrides_via_config_toml() {
        let _guard = ENV_LOCK.lock().unwrap();
        let (tmp, prev) = isolated_config_dir();
        std::fs::write(
            tmp.join("config.toml"),
            r#"model = "deepseek/deepseek-v4-pro"

[roles]
explore = "qwen/qwen3-coder"
visual = "google/gemini-2.5-pro"
"#,
        )
        .unwrap();
        let cfg = load_config(&CliOverrides::default());
        restore_config_dir(tmp, prev);

        assert_eq!(cfg.role_model("explore"), "qwen/qwen3-coder");
        assert_eq!(cfg.role_model("visual"), "google/gemini-2.5-pro");
        // Unset role keeps its built-in default.
        assert_eq!(cfg.role_model("implement"), ROLE_IMPLEMENT_DEFAULT);
    }

    #[test]
    fn save_role_round_trips() {
        let _guard = ENV_LOCK.lock().unwrap();
        let (tmp, prev) = isolated_config_dir();
        save_role("explore", "z-ai/glm-4.7-flash").unwrap();
        let cfg = load_config(&CliOverrides::default());
        restore_config_dir(tmp, prev);
        assert_eq!(cfg.role_model("explore"), "z-ai/glm-4.7-flash");
    }

    #[test]
    fn role_config_resolves_without_mutating_primary() {
        let _guard = ENV_LOCK.lock().unwrap();
        let (tmp, prev) = isolated_config_dir();
        let cfg = Config::default(); // LucidCloud + hy3 (free default)
        let ec = cfg.role_config("explore");
        restore_config_dir(tmp, prev);

        assert_eq!(ec.model, ROLE_EXPLORE_DEFAULT);
        assert_eq!(ec.max_context_tokens, CTX_1M);
        // LucidCloud proxies arbitrary ids — provider/base_url stay pinned.
        assert_eq!(ec.provider, Provider::LucidCloud);
        assert_eq!(ec.base_url, cfg.base_url);
        // Primary config untouched.
        assert_eq!(cfg.model, "tencent/hy3:free");
    }

    #[test]
    fn ollama_provider_does_not_wipe_saved_api_key() {
        // Simulate the user running /download (provider=ollama) then /apikey.
        // On the next launch load_config must keep the saved key.
        let _guard = ENV_LOCK.lock().unwrap();
        let (tmp, prev) = isolated_config_dir();
        std::fs::write(
            tmp.join("config.toml"),
            r#"provider = "ollama"
api_key = "sk-or-v1-secret"
"#,
        )
        .unwrap();
        let cfg = load_config(&CliOverrides::default());
        restore_config_dir(tmp, prev);

        assert_eq!(cfg.api_key, "sk-or-v1-secret");
        assert_eq!(cfg.provider, Provider::Ollama);
    }
}
