use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

/// Get the user configuration directory (~/.picobot)
pub fn get_user_config_dir() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".picobot")
}

/// Get the default workspace directory (~/.picobot/workspace)
pub fn get_default_workspace_dir() -> PathBuf {
    get_user_config_dir().join("workspace")
}

/// Get the default session database path (`<config_dir>/data/picobot.db`).
/// Kept under the user config directory rather than the workspace so session
/// data is independent of the workspace directory.
pub fn get_default_db_path() -> PathBuf {
    get_user_config_dir().join("data").join("picobot.db")
}

/// Expand ~ in path to user home directory
pub fn expand_path(path: &str) -> PathBuf {
    if let Some(path) = path.strip_prefix("~/") {
        dirs::home_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join(path)
    } else {
        PathBuf::from(path)
    }
}

/// Ensure workspace directory exists, create if needed
pub fn ensure_workspace_dir(path: &Path) -> Result<PathBuf, std::io::Error> {
    if !path.exists() {
        tracing::info!("Creating workspace directory: {}", path.display());
        fs::create_dir_all(path)?;
    }
    // Return canonical path
    path.canonicalize().or_else(|_| Ok(path.to_path_buf()))
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
    pub providers: HashMap<String, ProviderConfig>,
    pub models: HashMap<String, ModelConfig>,
    pub agents: HashMap<String, AgentConfig>,
    #[serde(default)]
    pub gateway: GatewayConfig,
    #[serde(default)]
    pub client: ClientConfig,
    #[serde(default)]
    pub channels: HashMap<String, FeishuChannelConfig>,
    #[serde(default)]
    pub memory: MemoryConfig,
    #[serde(default = "default_workspace_dir")]
    pub workspace_dir: String,
    #[serde(default)]
    pub mcp: McpConfig,
    #[serde(default)]
    pub browser: BrowserConfig,
    #[serde(default)]
    pub agent_orchestration: AgentOrchestrationConfig,
    #[serde(default)]
    pub context_compaction: ContextCompactionConfig,
    /// Recoverable source problems ignored while constructing this effective
    /// runtime configuration. These are never serialized back into config.json.
    #[serde(skip)]
    pub diagnostics: Vec<ConfigDiagnostic>,
    /// SHA-256 revision of the raw config.json bytes used for this load.
    #[serde(skip)]
    pub source_revision: String,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ConfigDiagnostic {
    /// RFC 6901 pointer into the raw config.json document.
    pub path: String,
    pub kind: ConfigDiagnosticKind,
    pub reason: String,
    pub action: ConfigRecoveryAction,
    /// Exact raw-document path removed by the cleanup API.
    pub cleanup_path: String,
}

#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConfigDiagnosticKind {
    UnknownField,
    InvalidValue,
    InvalidEntry,
}

#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConfigRecoveryAction {
    Ignored,
}

#[derive(Debug, Clone)]
pub(crate) struct ConfigLoadContext {
    pub process_env: HashMap<String, String>,
    pub startup_cwd: PathBuf,
}

fn default_workspace_dir() -> String {
    get_default_workspace_dir().to_string_lossy().to_string()
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FeishuChannelConfig {
    #[serde(default)]
    pub enabled: bool,
    pub app_id: String,
    pub app_secret: String,
    #[serde(default = "default_allow_from")]
    pub allow_from: Vec<String>,
    /// Require an explicit bot @mention before accepting group-chat messages.
    #[serde(default = "default_true")]
    pub require_mention: bool,
    #[serde(default)]
    pub agent: String,
    #[serde(default = "default_media_dir")]
    pub media_dir: String,
    /// Emoji type for message reactions (e.g. "THUMBSUP", "OK", "EYES").
    #[serde(default = "default_reaction_emoji")]
    pub reaction_emoji: String,
    /// Edit one card with latest Turn snapshots instead of sending only the final result.
    #[serde(default)]
    pub live_updates: bool,
    #[serde(default = "default_feishu_live_update_interval_ms")]
    pub live_update_interval_ms: u64,
    #[serde(default = "default_feishu_max_image_bytes")]
    pub max_image_bytes: u64,
    #[serde(default = "default_feishu_max_file_bytes")]
    pub max_file_bytes: u64,
    #[serde(default = "default_feishu_media_dir_max_bytes")]
    pub media_dir_max_bytes: u64,
    #[serde(default = "default_feishu_request_timeout_secs")]
    pub request_timeout_secs: u64,
}

fn default_allow_from() -> Vec<String> {
    vec!["*".to_string()]
}

fn default_media_dir() -> String {
    get_user_config_dir()
        .join("media/feishu")
        .to_string_lossy()
        .to_string()
}

fn default_reaction_emoji() -> String {
    "Typing".to_string()
}

fn default_feishu_live_update_interval_ms() -> u64 {
    500
}

fn default_feishu_max_image_bytes() -> u64 {
    10 * 1024 * 1024
}

fn default_feishu_max_file_bytes() -> u64 {
    25 * 1024 * 1024
}

fn default_feishu_media_dir_max_bytes() -> u64 {
    512 * 1024 * 1024
}

fn default_feishu_request_timeout_secs() -> u64 {
    30
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProviderConfig {
    #[serde(rename = "type")]
    pub provider_type: String,
    pub base_url: String,
    pub api_key: String,
    #[serde(default)]
    pub extra_headers: HashMap<String, String>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ModelConfig {
    pub model_id: String,
    #[serde(default)]
    pub temperature: Option<f32>,
    #[serde(default)]
    pub max_tokens: Option<u32>,
    /// Model context-window capacity. Agent profiles may narrow it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub token_limit: Option<usize>,
    #[serde(default = "default_input_type")]
    pub input_type: Vec<String>,
    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

fn default_input_type() -> Vec<String> {
    vec!["text".to_string()]
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentConfig {
    pub provider: String,
    pub model: String,
    #[serde(default = "default_max_tool_iterations")]
    pub max_tool_iterations: usize,
    /// Optional per-Agent context-window cap. It may narrow but never expand
    /// the selected model's limit; a missing model limit falls back to 128K.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub token_limit: Option<usize>,
}

fn default_max_tool_iterations() -> usize {
    99
}

fn default_token_limit() -> usize {
    128_000
}

/// Resolve the effective context window. Agent values are optional caps: they
/// may narrow a model window but can never expand it. A missing model window
/// is the product's conservative 128K default.
pub(crate) fn resolve_token_limit(
    agent_token_limit: Option<usize>,
    model_token_limit: Option<usize>,
) -> usize {
    let model_token_limit = model_token_limit.unwrap_or_else(default_token_limit);
    agent_token_limit.map_or(model_token_limit, |agent| agent.min(model_token_limit))
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ContextCompactionConfig {
    /// Controls proactive Turn-boundary compaction only. Manual compaction and
    /// overflow recovery remain available when this is false.
    pub enabled: bool,
    /// Input headroom reserved for model output and request-estimation error.
    pub reserve_tokens: usize,
    /// Approximate number of recent history tokens retained verbatim.
    pub keep_recent_tokens: usize,
}

impl Default for ContextCompactionConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            reserve_tokens: 16_384,
            keep_recent_tokens: 20_000,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct AgentOrchestrationConfig {
    pub definitions_dir: String,
    pub max_tree_depth: u16,
    pub max_runs_per_tree: usize,
    pub max_concurrent_runs: usize,
    pub max_concurrent_runs_per_session: usize,
    pub max_concurrent_provider_steps: usize,
    pub max_concurrent_provider_steps_per_session: usize,
    pub max_concurrent_tool_steps: usize,
    pub max_concurrent_tool_steps_per_session: usize,
    pub max_pending_inbox_events_per_session: usize,
    pub inbox_event_ttl_hours: u64,
    pub max_inbox_delivery_attempts: u32,
    pub max_user_turn_burst_before_inbox: usize,
    pub max_inbox_wait_secs: u64,
}

impl Default for AgentOrchestrationConfig {
    fn default() -> Self {
        Self {
            definitions_dir: "agents".to_string(),
            max_tree_depth: 4,
            max_runs_per_tree: 16,
            max_concurrent_runs: 6,
            max_concurrent_runs_per_session: 4,
            max_concurrent_provider_steps: 8,
            max_concurrent_provider_steps_per_session: 4,
            max_concurrent_tool_steps: 16,
            max_concurrent_tool_steps_per_session: 8,
            max_pending_inbox_events_per_session: 128,
            inbox_event_ttl_hours: 168,
            max_inbox_delivery_attempts: 8,
            max_user_turn_burst_before_inbox: 4,
            max_inbox_wait_secs: 30,
        }
    }
}

impl AgentOrchestrationConfig {
    pub fn validate(&self) -> Result<(), String> {
        let positive = [
            ("max_tree_depth", usize::from(self.max_tree_depth)),
            ("max_runs_per_tree", self.max_runs_per_tree),
            ("max_concurrent_runs", self.max_concurrent_runs),
            (
                "max_concurrent_runs_per_session",
                self.max_concurrent_runs_per_session,
            ),
            (
                "max_concurrent_provider_steps",
                self.max_concurrent_provider_steps,
            ),
            (
                "max_concurrent_provider_steps_per_session",
                self.max_concurrent_provider_steps_per_session,
            ),
            ("max_concurrent_tool_steps", self.max_concurrent_tool_steps),
            (
                "max_concurrent_tool_steps_per_session",
                self.max_concurrent_tool_steps_per_session,
            ),
            (
                "max_pending_inbox_events_per_session",
                self.max_pending_inbox_events_per_session,
            ),
            (
                "max_inbox_delivery_attempts",
                self.max_inbox_delivery_attempts as usize,
            ),
            (
                "max_user_turn_burst_before_inbox",
                self.max_user_turn_burst_before_inbox,
            ),
        ];
        if let Some((name, _)) = positive.into_iter().find(|(_, value)| *value == 0) {
            return Err(format!(
                "agent_orchestration.{name} must be greater than zero"
            ));
        }
        if self.definitions_dir.trim().is_empty() {
            return Err("agent_orchestration.definitions_dir must not be empty".to_string());
        }
        if self.max_concurrent_runs_per_session > self.max_concurrent_runs {
            return Err("agent_orchestration.max_concurrent_runs_per_session cannot exceed max_concurrent_runs".to_string());
        }
        if self.max_concurrent_provider_steps_per_session > self.max_concurrent_provider_steps {
            return Err("agent_orchestration.max_concurrent_provider_steps_per_session cannot exceed max_concurrent_provider_steps".to_string());
        }
        if self.max_concurrent_tool_steps_per_session > self.max_concurrent_tool_steps {
            return Err("agent_orchestration.max_concurrent_tool_steps_per_session cannot exceed max_concurrent_tool_steps".to_string());
        }
        if self.max_tree_depth > 32 {
            return Err("agent_orchestration.max_tree_depth cannot exceed 32".to_string());
        }
        if self.max_runs_per_tree > 1024 {
            return Err("agent_orchestration.max_runs_per_tree cannot exceed 1024".to_string());
        }
        if self.inbox_event_ttl_hours == 0 || self.inbox_event_ttl_hours > 24 * 365 {
            return Err(
                "agent_orchestration.inbox_event_ttl_hours must be between 1 and 8760".to_string(),
            );
        }
        if self.max_inbox_wait_secs == 0 || self.max_inbox_wait_secs > 3600 {
            return Err(
                "agent_orchestration.max_inbox_wait_secs must be between 1 and 3600".to_string(),
            );
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GatewayConfig {
    #[serde(default = "default_gateway_host")]
    pub host: String,
    #[serde(default = "default_gateway_port")]
    pub port: u16,
    #[serde(default = "default_require_pairing")]
    pub require_pairing: bool,
    #[serde(default, rename = "session_ttl_hours")]
    pub session_ttl_hours: Option<u64>,
    #[serde(default, rename = "cleanup_interval_minutes")]
    pub cleanup_interval_minutes: Option<u64>,
    #[serde(default, rename = "session_db_path")]
    pub session_db_path: Option<String>,
    #[serde(default)]
    pub scheduler: Option<SchedulerConfig>,
    #[serde(default)]
    pub file_transfer: FileTransferConfig,
}

impl Default for GatewayConfig {
    fn default() -> Self {
        Self {
            host: default_gateway_host(),
            port: default_gateway_port(),
            require_pairing: default_require_pairing(),
            session_ttl_hours: None,
            cleanup_interval_minutes: None,
            session_db_path: None,
            scheduler: None,
            file_transfer: FileTransferConfig::default(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileTransferConfig {
    #[serde(default = "default_file_transfer_enabled")]
    pub enabled: bool,
    #[serde(default = "default_upload_dir")]
    pub upload_dir: String,
    #[serde(default = "default_max_file_bytes")]
    pub max_file_bytes: u64,
    #[serde(default = "default_max_files_per_message")]
    pub max_files_per_message: usize,
    #[serde(default = "default_max_message_bytes")]
    pub max_message_bytes: u64,
    #[serde(default = "default_pending_ttl_seconds")]
    pub pending_ttl_seconds: u64,
}

impl Default for FileTransferConfig {
    fn default() -> Self {
        Self {
            enabled: default_file_transfer_enabled(),
            upload_dir: default_upload_dir(),
            max_file_bytes: default_max_file_bytes(),
            max_files_per_message: default_max_files_per_message(),
            max_message_bytes: default_max_message_bytes(),
            pending_ttl_seconds: default_pending_ttl_seconds(),
        }
    }
}

fn default_file_transfer_enabled() -> bool {
    true
}

fn default_upload_dir() -> String {
    get_user_config_dir()
        .join("media/cli_chat")
        .to_string_lossy()
        .to_string()
}

fn default_max_file_bytes() -> u64 {
    25 * 1024 * 1024
}

fn default_max_files_per_message() -> usize {
    8
}

fn default_max_message_bytes() -> u64 {
    64 * 1024 * 1024
}

fn default_pending_ttl_seconds() -> u64 {
    60 * 60
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchedulerConfig {
    /// Whether the scheduler is enabled
    #[serde(default = "default_scheduler_enabled")]
    pub enabled: bool,
    /// Poll interval in seconds (how often to check for due jobs)
    #[serde(default = "default_poll_interval_secs")]
    pub poll_interval_secs: u64,
    /// Maximum concurrent job executions.
    #[serde(default = "default_max_concurrent")]
    pub max_concurrent: usize,
    /// Hard timeout for one scheduled execution.
    #[serde(default = "default_execution_timeout_secs")]
    pub execution_timeout_secs: u64,
}

fn default_scheduler_enabled() -> bool {
    true
}

fn default_poll_interval_secs() -> u64 {
    60
}

fn default_max_concurrent() -> usize {
    1
}

fn default_execution_timeout_secs() -> u64 {
    900
}

impl Default for SchedulerConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            poll_interval_secs: 60,
            max_concurrent: 1,
            execution_timeout_secs: 900,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ClientConfig {
    #[serde(default = "default_gateway_url")]
    pub gateway_url: String,
}

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

fn default_gateway_port() -> u16 {
    19876
}

fn default_require_pairing() -> bool {
    true
}

fn default_gateway_url() -> String {
    "ws://127.0.0.1:19876/ws".to_string()
}

impl Default for ClientConfig {
    fn default() -> Self {
        Self {
            gateway_url: default_gateway_url(),
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MemoryConfig {
    /// Provider name for consolidation LLM calls (key in `providers`).
    /// If not set, falls back to the main agent's provider.
    #[serde(default)]
    pub consolidation_provider: Option<String>,
    /// Model name for consolidation LLM calls (key in `models`).
    /// If not set, falls back to the main agent's model.
    #[serde(default)]
    pub consolidation_model: Option<String>,
    /// Max knowledge entries injected into system prompt per turn.
    #[serde(default = "default_recall_limit")]
    pub recall_limit: usize,
    /// Idle minutes before triggering consolidation (for async channels).
    #[serde(default = "default_idle_consolidation_minutes")]
    pub idle_consolidation_minutes: u64,
    /// Days before timeline entries are auto-cleaned.
    #[serde(default = "default_timeline_retention_days")]
    pub timeline_retention_days: u64,
    /// Consecutive consolidation failures before degrading to raw archive.
    #[serde(default = "default_max_failures_before_degrade")]
    pub max_failures_before_degrade: usize,
}

impl Default for MemoryConfig {
    fn default() -> Self {
        Self {
            consolidation_provider: None,
            consolidation_model: None,
            recall_limit: 5,
            idle_consolidation_minutes: 10,
            timeline_retention_days: 90,
            max_failures_before_degrade: 3,
        }
    }
}

impl MemoryConfig {
    /// Resolve consolidation provider name, falling back to the main agent's provider.
    pub fn resolve_consolidation_provider(&self, default: &str) -> String {
        self.consolidation_provider
            .clone()
            .unwrap_or_else(|| default.to_string())
    }

    /// Resolve consolidation model name, falling back to the main agent's model.
    pub fn resolve_consolidation_model(&self, default: &str) -> String {
        self.consolidation_model
            .clone()
            .unwrap_or_else(|| default.to_string())
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpConfig {
    #[serde(default)]
    pub servers: Vec<McpServerConfig>,
    #[serde(default = "default_mcp_tool_timeout_secs")]
    pub tool_timeout_secs: u64,
}

impl Default for McpConfig {
    fn default() -> Self {
        Self {
            servers: Vec::new(),
            tool_timeout_secs: default_mcp_tool_timeout_secs(),
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpServerConfig {
    pub name: String,
    #[serde(default = "default_true")]
    pub enabled: bool,
    #[serde(default = "default_mcp_transport")]
    pub transport: McpTransport,
    #[serde(default)]
    pub command: Option<String>,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default)]
    pub env: HashMap<String, String>,
    #[serde(default)]
    pub url: Option<String>,
    #[serde(default)]
    pub headers: HashMap<String, String>,
    #[serde(default)]
    pub tool_timeout_secs: Option<u64>,
    /// Per-tool execution declarations for tools discovered from this server.
    /// Unknown MCP tools remain conservative: side-effecting and sequential.
    #[serde(default)]
    pub tool_settings: HashMap<String, McpToolSettings>,
}

impl McpServerConfig {
    /// Return the effective execution declarations for one discovered tool.
    pub fn tool_settings_for(&self, tool_name: &str) -> McpToolSettings {
        self.tool_settings
            .get(tool_name)
            .copied()
            .unwrap_or_default()
    }
}

/// Locally declared execution properties for an externally provided MCP tool.
///
/// MCP tool discovery does not provide a trustworthy side-effect contract, so
/// PicoBot keeps the declaration in its own trusted configuration. Concurrent
/// execution is intentionally derived rather than stored: a tool is safe to
/// run alongside another tool only when it is read-only and non-exclusive.
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct McpToolSettings {
    #[serde(default)]
    pub read_only: bool,
    #[serde(default)]
    pub exclusive: bool,
}

impl McpToolSettings {
    pub fn concurrency_safe(self) -> bool {
        self.read_only && !self.exclusive
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum McpTransport {
    Stdio,
    Sse,
    #[serde(alias = "streamable-http")]
    StreamableHttp,
}

fn default_mcp_transport() -> McpTransport {
    McpTransport::Stdio
}

fn default_mcp_tool_timeout_secs() -> u64 {
    180
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BrowserConfig {
    #[serde(default = "default_true")]
    pub enabled: bool,
    #[serde(default = "default_agent_browser_command")]
    pub command: String,
    #[serde(default = "default_true")]
    pub headless: bool,
    #[serde(default)]
    pub browser_executable_path: Option<String>,
    #[serde(default = "default_browser_max_sessions")]
    pub max_sessions: usize,
    #[serde(default = "default_browser_idle_timeout_secs")]
    pub idle_timeout_secs: u64,
    #[serde(default = "default_browser_command_timeout_secs")]
    pub command_timeout_secs: u64,
    #[serde(default = "default_browser_max_output_chars")]
    pub max_output_chars: usize,
    #[serde(default = "default_true")]
    pub content_boundaries: bool,
    #[serde(default)]
    pub allowed_domains: Vec<String>,
    #[serde(default)]
    pub allow_private_hosts: bool,
    #[serde(default = "default_browser_artifact_dir")]
    pub artifact_dir: String,
    #[serde(default)]
    pub persistence: BrowserPersistenceConfig,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BrowserPersistenceConfig {
    #[serde(default = "default_browser_profile_dir")]
    pub profile_dir: String,
}

fn default_agent_browser_command() -> String {
    "agent-browser".to_string()
}

fn default_true() -> bool {
    true
}

fn default_browser_max_sessions() -> usize {
    4
}

fn default_browser_idle_timeout_secs() -> u64 {
    60 * 60
}

fn default_browser_command_timeout_secs() -> u64 {
    120
}

fn default_browser_max_output_chars() -> usize {
    50_000
}

fn default_browser_artifact_dir() -> String {
    get_user_config_dir()
        .join("media/browser")
        .to_string_lossy()
        .to_string()
}

fn default_browser_profile_dir() -> String {
    get_user_config_dir()
        .join("browser/profiles")
        .to_string_lossy()
        .to_string()
}

impl Default for BrowserPersistenceConfig {
    fn default() -> Self {
        Self {
            profile_dir: default_browser_profile_dir(),
        }
    }
}

impl Default for BrowserConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            command: default_agent_browser_command(),
            headless: true,
            browser_executable_path: None,
            max_sessions: default_browser_max_sessions(),
            idle_timeout_secs: default_browser_idle_timeout_secs(),
            command_timeout_secs: default_browser_command_timeout_secs(),
            max_output_chars: default_browser_max_output_chars(),
            content_boundaries: true,
            allowed_domains: Vec::new(),
            allow_private_hosts: false,
            artifact_dir: default_browser_artifact_dir(),
            persistence: BrowserPersistenceConfig::default(),
        }
    }
}

fn default_recall_limit() -> usize {
    5
}
fn default_idle_consolidation_minutes() -> u64 {
    10
}
fn default_timeline_retention_days() -> u64 {
    90
}
fn default_max_failures_before_degrade() -> usize {
    3
}

#[derive(Debug, Clone)]
pub struct LLMProviderConfig {
    pub provider_type: String,
    pub name: String,
    pub base_url: String,
    pub api_key: String,
    pub extra_headers: HashMap<String, String>,
    pub model_id: String,
    pub temperature: Option<f32>,
    pub max_tokens: Option<u32>,
    pub model_extra: HashMap<String, serde_json::Value>,
    pub max_tool_iterations: usize,
    pub token_limit: usize,
    pub workspace_dir: PathBuf,
    pub input_types: Vec<String>,
    pub price_input_per_million: Option<f64>,
    pub price_output_per_million: Option<f64>,
}

impl LLMProviderConfig {
    pub fn cost_of(&self, prompt_tokens: u32, completion_tokens: u32) -> Option<f64> {
        match (self.price_input_per_million, self.price_output_per_million) {
            (Some(pi), Some(po)) => {
                Some(prompt_tokens as f64 / 1e6 * pi + completion_tokens as f64 / 1e6 * po)
            }
            _ => None,
        }
    }
}

pub fn get_default_config_path() -> PathBuf {
    get_user_config_dir().join("config.json")
}

/// Resolve the config file that `load_default` will read. This must be called
/// before Gateway changes its working directory so the fallback remains stable.
pub fn resolve_default_config_path() -> PathBuf {
    let primary = get_default_config_path();
    if primary.exists() {
        primary
    } else {
        env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join("config.json")
    }
}

const MAX_CONFIG_RECOVERIES: usize = 256;

#[derive(Debug, Clone)]
enum ConfigPathSegment {
    Key(String),
    Index(usize),
}

#[derive(Debug, Clone)]
struct OriginNode {
    pointer: String,
    children: OriginChildren,
}

#[derive(Debug, Clone)]
enum OriginChildren {
    Scalar,
    Object(HashMap<String, OriginNode>),
    Array(Vec<OriginNode>),
}

impl OriginNode {
    fn from_value(value: &Value, pointer: String) -> Self {
        let children = match value {
            Value::Object(object) => OriginChildren::Object(
                object
                    .iter()
                    .map(|(key, value)| {
                        let child_pointer = join_json_pointer(&pointer, key);
                        (key.clone(), Self::from_value(value, child_pointer))
                    })
                    .collect(),
            ),
            Value::Array(array) => OriginChildren::Array(
                array
                    .iter()
                    .enumerate()
                    .map(|(index, value)| {
                        Self::from_value(value, join_json_pointer(&pointer, &index.to_string()))
                    })
                    .collect(),
            ),
            _ => OriginChildren::Scalar,
        };
        Self { pointer, children }
    }

    fn at<'a>(&'a self, path: &[ConfigPathSegment]) -> Option<&'a Self> {
        let mut current = self;
        for segment in path {
            current = match (&current.children, segment) {
                (OriginChildren::Object(children), ConfigPathSegment::Key(key)) => {
                    children.get(key)?
                }
                (OriginChildren::Array(children), ConfigPathSegment::Index(index)) => {
                    children.get(*index)?
                }
                _ => return None,
            };
        }
        Some(current)
    }
}

fn escape_json_pointer_segment(segment: &str) -> String {
    segment.replace('~', "~0").replace('/', "~1")
}

fn join_json_pointer(parent: &str, segment: &str) -> String {
    format!("{parent}/{}", escape_json_pointer_segment(segment))
}

fn error_path_segments(path: &serde_path_to_error::Path) -> Option<Vec<ConfigPathSegment>> {
    path.iter()
        .map(|segment| match segment {
            serde_path_to_error::Segment::Map { key } => Some(ConfigPathSegment::Key(key.clone())),
            serde_path_to_error::Segment::Seq { index } => Some(ConfigPathSegment::Index(*index)),
            serde_path_to_error::Segment::Enum { variant } => {
                Some(ConfigPathSegment::Key(variant.clone()))
            }
            serde_path_to_error::Segment::Unknown => None,
        })
        .collect()
}

fn remove_config_path(
    value: &mut Value,
    origin: &mut OriginNode,
    path: &[ConfigPathSegment],
) -> bool {
    let Some((head, tail)) = path.split_first() else {
        return false;
    };
    if tail.is_empty() {
        return match (value, &mut origin.children, head) {
            (
                Value::Object(object),
                OriginChildren::Object(origins),
                ConfigPathSegment::Key(key),
            ) => {
                let removed = object.remove(key).is_some();
                origins.remove(key);
                removed
            }
            (
                Value::Array(array),
                OriginChildren::Array(origins),
                ConfigPathSegment::Index(index),
            ) if *index < array.len() && *index < origins.len() => {
                array.remove(*index);
                origins.remove(*index);
                true
            }
            _ => false,
        };
    }
    match (value, &mut origin.children, head) {
        (Value::Object(object), OriginChildren::Object(origins), ConfigPathSegment::Key(key)) => {
            let Some(child) = object.get_mut(key) else {
                return false;
            };
            let Some(child_origin) = origins.get_mut(key) else {
                return false;
            };
            remove_config_path(child, child_origin, tail)
        }
        (Value::Array(array), OriginChildren::Array(origins), ConfigPathSegment::Index(index))
            if *index < array.len() && *index < origins.len() =>
        {
            remove_config_path(&mut array[*index], &mut origins[*index], tail)
        }
        _ => false,
    }
}

fn diagnostic_reason(error: &serde_json::Error) -> (&'static str, ConfigDiagnosticKind) {
    let message = error.to_string();
    if message.starts_with("missing field") {
        (
            "配置条目缺少必填字段，整个条目已忽略",
            ConfigDiagnosticKind::InvalidEntry,
        )
    } else if message.starts_with("unknown variant") {
        (
            "配置值不是受支持的枚举选项，已忽略",
            ConfigDiagnosticKind::InvalidValue,
        )
    } else if message.starts_with("invalid type") {
        (
            "配置值类型不匹配，已忽略",
            ConfigDiagnosticKind::InvalidValue,
        )
    } else {
        ("配置值无法解析，已忽略", ConfigDiagnosticKind::InvalidValue)
    }
}

fn collect_unknown_fields(
    source: &Value,
    origin: &OriginNode,
    effective: &Value,
    diagnostics: &mut Vec<ConfigDiagnostic>,
) {
    match (source, &origin.children, effective) {
        (Value::Object(source), OriginChildren::Object(origins), Value::Object(effective)) => {
            for (key, source_value) in source {
                let Some(child_origin) = origins.get(key) else {
                    continue;
                };
                if let Some(effective_value) = effective.get(key) {
                    collect_unknown_fields(
                        source_value,
                        child_origin,
                        effective_value,
                        diagnostics,
                    );
                } else {
                    diagnostics.push(ConfigDiagnostic {
                        path: child_origin.pointer.clone(),
                        kind: ConfigDiagnosticKind::UnknownField,
                        reason: "当前版本不识别此配置项，已忽略".to_string(),
                        action: ConfigRecoveryAction::Ignored,
                        cleanup_path: child_origin.pointer.clone(),
                    });
                }
            }
        }
        (Value::Array(source), OriginChildren::Array(origins), Value::Array(effective)) => {
            for ((source_value, child_origin), effective_value) in
                source.iter().zip(origins).zip(effective)
            {
                collect_unknown_fields(source_value, child_origin, effective_value, diagnostics);
            }
        }
        _ => {}
    }
}

pub(crate) fn source_revision(raw: &str) -> String {
    let digest: String = Sha256::digest(raw.as_bytes())
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect();
    format!("sha256:{digest}")
}

fn decode_json_pointer(pointer: &str) -> Option<Vec<String>> {
    if pointer.is_empty() {
        return Some(Vec::new());
    }
    pointer.strip_prefix('/').map(|rest| {
        rest.split('/')
            .map(|segment| segment.replace("~1", "/").replace("~0", "~"))
            .collect()
    })
}

fn is_pointer_ancestor(ancestor: &str, descendant: &str) -> bool {
    descendant
        .strip_prefix(ancestor)
        .is_some_and(|suffix| suffix.starts_with('/'))
}

fn remove_raw_pointer(value: &mut Value, segments: &[String]) -> bool {
    let Some((head, tail)) = segments.split_first() else {
        return false;
    };
    if tail.is_empty() {
        return match value {
            Value::Object(object) => object.remove(head).is_some(),
            Value::Array(array) => head
                .parse::<usize>()
                .ok()
                .filter(|index| *index < array.len())
                .map(|index| {
                    array.remove(index);
                })
                .is_some(),
            _ => false,
        };
    }
    match value {
        Value::Object(object) => object
            .get_mut(head)
            .is_some_and(|child| remove_raw_pointer(child, tail)),
        Value::Array(array) => head
            .parse::<usize>()
            .ok()
            .and_then(|index| array.get_mut(index))
            .is_some_and(|child| remove_raw_pointer(child, tail)),
        _ => false,
    }
}

/// Remove only paths previously diagnosed against this exact raw revision.
/// Ancestors subsume descendants, and sibling array indexes are deleted from
/// highest to lowest so paths remain stable during the operation.
pub(crate) fn cleanup_invalid_config(value: &mut Value, diagnostics: &[ConfigDiagnostic]) -> usize {
    let mut paths = diagnostics
        .iter()
        .map(|diagnostic| diagnostic.cleanup_path.clone())
        .collect::<Vec<_>>();
    paths.sort_by_key(|path| decode_json_pointer(path).map_or(usize::MAX, |parts| parts.len()));
    let mut coalesced: Vec<String> = Vec::new();
    for path in paths {
        if !coalesced
            .iter()
            .any(|ancestor| ancestor == &path || is_pointer_ancestor(ancestor, &path))
        {
            coalesced.push(path);
        }
    }
    coalesced.sort_by(|left, right| {
        let left_parts = decode_json_pointer(left).unwrap_or_default();
        let right_parts = decode_json_pointer(right).unwrap_or_default();
        let left_parent = &left_parts[..left_parts.len().saturating_sub(1)];
        let right_parent = &right_parts[..right_parts.len().saturating_sub(1)];
        if left_parent == right_parent {
            let left_index = left_parts
                .last()
                .and_then(|part| part.parse::<usize>().ok());
            let right_index = right_parts
                .last()
                .and_then(|part| part.parse::<usize>().ok());
            if let (Some(left_index), Some(right_index)) = (left_index, right_index) {
                return right_index.cmp(&left_index);
            }
        }
        right_parts
            .len()
            .cmp(&left_parts.len())
            .then_with(|| right.cmp(left))
    });
    coalesced
        .iter()
        .filter_map(|path| decode_json_pointer(path))
        .filter(|segments| remove_raw_pointer(value, segments))
        .count()
}

fn parse_config_tolerant(content: &str) -> Result<Config, Box<dyn std::error::Error>> {
    let mut candidate: Value = serde_json::from_str(content)?;
    let mut origins = OriginNode::from_value(&candidate, String::new());
    let mut diagnostics = Vec::new();

    let mut config = None;
    for _ in 0..=MAX_CONFIG_RECOVERIES {
        let serialized = serde_json::to_string(&candidate)?;
        let mut deserializer = serde_json::Deserializer::from_str(&serialized);
        match serde_path_to_error::deserialize::<_, Config>(&mut deserializer) {
            Ok(parsed) => {
                config = Some(parsed);
                break;
            }
            Err(error) => {
                let Some(path) = error_path_segments(error.path()) else {
                    return Err(error.into());
                };
                if path.is_empty() {
                    return Err(error.into());
                }
                let Some(original) = origins.at(&path).map(|node| node.pointer.clone()) else {
                    return Err(error.into());
                };
                let (reason, kind) = diagnostic_reason(error.inner());
                if !remove_config_path(&mut candidate, &mut origins, &path) {
                    return Err(error.into());
                }
                diagnostics.push(ConfigDiagnostic {
                    path: original.clone(),
                    kind,
                    reason: reason.to_string(),
                    action: ConfigRecoveryAction::Ignored,
                    cleanup_path: original,
                });
            }
        }
    }

    let Some(mut config) = config else {
        return Err(format!(
            "configuration contains more than {MAX_CONFIG_RECOVERIES} recoverable errors"
        )
        .into());
    };
    let effective = serde_json::to_value(&config)?;
    collect_unknown_fields(&candidate, &origins, &effective, &mut diagnostics);

    let unsupported_channels = config
        .channels
        .keys()
        .filter(|name| name.as_str() != "feishu")
        .cloned()
        .collect::<Vec<_>>();
    for name in unsupported_channels {
        config.channels.remove(&name);
        let pointer = join_json_pointer("/channels", &name);
        diagnostics.push(ConfigDiagnostic {
            path: pointer.clone(),
            kind: ConfigDiagnosticKind::InvalidEntry,
            reason: "当前版本不支持此渠道配置，整个条目已忽略".to_string(),
            action: ConfigRecoveryAction::Ignored,
            cleanup_path: pointer,
        });
    }

    let invalid_agents = config
        .agents
        .iter()
        .filter(|(name, agent)| {
            name.as_str() != "default"
                && (!config.providers.contains_key(&agent.provider)
                    || !config.models.contains_key(&agent.model))
        })
        .map(|(name, _)| name.clone())
        .collect::<Vec<_>>();
    for name in invalid_agents {
        config.agents.remove(&name);
        let pointer = join_json_pointer("/agents", &name);
        diagnostics.push(ConfigDiagnostic {
            path: pointer.clone(),
            kind: ConfigDiagnosticKind::InvalidEntry,
            reason: "Agent 引用了不存在的 Provider 或 Model，整个条目已忽略".to_string(),
            action: ConfigRecoveryAction::Ignored,
            cleanup_path: pointer,
        });
    }

    for channel in config.channels.values_mut() {
        if !channel.agent.is_empty() && !config.agents.contains_key(&channel.agent) {
            channel.agent.clear();
            let pointer = "/channels/feishu/agent".to_string();
            diagnostics.push(ConfigDiagnostic {
                path: pointer.clone(),
                kind: ConfigDiagnosticKind::InvalidValue,
                reason: "渠道引用了不存在的 Agent，已回退到 default".to_string(),
                action: ConfigRecoveryAction::Ignored,
                cleanup_path: pointer,
            });
        }
    }

    if config
        .memory
        .consolidation_provider
        .as_ref()
        .is_some_and(|provider| !config.providers.contains_key(provider))
    {
        config.memory.consolidation_provider = None;
        let pointer = "/memory/consolidation_provider".to_string();
        diagnostics.push(ConfigDiagnostic {
            path: pointer.clone(),
            kind: ConfigDiagnosticKind::InvalidValue,
            reason: "记忆配置引用了不存在的 Provider，已回退到 default".to_string(),
            action: ConfigRecoveryAction::Ignored,
            cleanup_path: pointer,
        });
    }
    if config
        .memory
        .consolidation_model
        .as_ref()
        .is_some_and(|model| !config.models.contains_key(model))
    {
        config.memory.consolidation_model = None;
        let pointer = "/memory/consolidation_model".to_string();
        diagnostics.push(ConfigDiagnostic {
            path: pointer.clone(),
            kind: ConfigDiagnosticKind::InvalidValue,
            reason: "记忆配置引用了不存在的 Model，已回退到 default".to_string(),
            action: ConfigRecoveryAction::Ignored,
            cleanup_path: pointer,
        });
    }

    diagnostics.sort_by(|left, right| {
        let left_depth = decode_json_pointer(&left.path).map_or(usize::MAX, |parts| parts.len());
        let right_depth = decode_json_pointer(&right.path).map_or(usize::MAX, |parts| parts.len());
        left_depth
            .cmp(&right_depth)
            .then_with(|| left.path.cmp(&right.path))
    });
    let mut coalesced: Vec<ConfigDiagnostic> = Vec::new();
    for diagnostic in diagnostics {
        if !coalesced.iter().any(|existing| {
            existing.path == diagnostic.path
                || is_pointer_ancestor(&existing.path, &diagnostic.path)
        }) {
            coalesced.push(diagnostic);
        }
    }
    coalesced.sort_by(|left, right| left.path.cmp(&right.path));
    config.diagnostics = coalesced;
    Ok(config)
}

impl Config {
    pub(crate) fn load_context() -> ConfigLoadContext {
        ConfigLoadContext {
            process_env: collect_process_env(),
            startup_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
        }
    }

    pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
        Self::load_from(Path::new(path))
    }

    pub fn load_default() -> Result<Self, Box<dyn std::error::Error>> {
        let path = resolve_default_config_path();
        Self::load_from(&path)
    }

    pub(crate) fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
        let context = Self::load_context();
        Self::load_for_startup(path, &context)
    }

    pub(crate) fn load_for_startup(
        path: &Path,
        context: &ConfigLoadContext,
    ) -> Result<Self, Box<dyn std::error::Error>> {
        Self::load_from_with_process_env(
            path,
            &context.process_env,
            true,
            Some(&context.startup_cwd),
        )
    }

    /// Reload configuration without mutating the process environment. The
    /// supplied environment must be the process environment captured before
    /// startup `.env` layers were installed, preserving the documented
    /// precedence while keeping runtime reload thread-safe.
    pub(crate) fn load_for_reload(
        path: &Path,
        startup_process_env: &HashMap<String, String>,
        startup_cwd: &Path,
    ) -> Result<Self, Box<dyn std::error::Error>> {
        Self::load_from_with_process_env(path, startup_process_env, false, Some(startup_cwd))
    }

    /// Strict management-API validation. Runtime loading is deliberately
    /// tolerant of recoverable historical fields, while WebUI writes must not
    /// introduce new ignored configuration.
    pub(crate) fn from_value_strict(value: Value) -> Result<Self, String> {
        let content = serde_json::to_string(&value).map_err(|error| error.to_string())?;
        let config = parse_config_tolerant(&content).map_err(|error| error.to_string())?;
        if let Some(diagnostic) = config.diagnostics.first() {
            return Err(format!("{}: {}", diagnostic.path, diagnostic.reason));
        }
        Ok(config)
    }

    fn load_from_with_process_env(
        path: &Path,
        process_env: &HashMap<String, String>,
        apply_to_process: bool,
        workspace_base: Option<&Path>,
    ) -> Result<Self, Box<dyn std::error::Error>> {
        let config_path = if path.exists() {
            path.to_path_buf()
        } else {
            let fallback = env::current_dir()
                .unwrap_or_else(|_| PathBuf::from("."))
                .join("config.json");
            if fallback.exists() {
                fallback
            } else {
                return Err(Box::new(ConfigError::ConfigNotFound(
                    path.to_string_lossy().to_string(),
                )));
            }
        };

        let content = fs::read_to_string(&config_path)?;
        let config_env_path = config_path
            .parent()
            .unwrap_or_else(|| Path::new("."))
            .join(".env");
        let config_env = read_env_file(&config_env_path)?;

        // The config-directory layer selects the workspace. Loading the workspace
        // layer first would be circular because its location comes from config.json.
        let initial_env = merge_env_layers(&config_env, &HashMap::new(), process_env);
        let initial_content = resolve_env_placeholders(&content, &initial_env);
        let initial_config = parse_config_tolerant(&initial_content)?;
        let mut workspace_path = expand_path(&initial_config.workspace_dir);
        if workspace_path.is_relative()
            && let Some(base) = workspace_base
        {
            workspace_path = base.join(workspace_path);
        }
        let workspace_env_path = workspace_path.join(".env");
        let workspace_env = read_env_file(&workspace_env_path)?;

        let effective_env = merge_env_layers(&config_env, &workspace_env, process_env);
        let resolved_content = resolve_env_placeholders(&content, &effective_env);
        let mut config = parse_config_tolerant(&resolved_content)?;
        if config.workspace_dir != initial_config.workspace_dir {
            return Err(format!(
                "workspace .env cannot change workspace_dir (selected {}, resolved {})",
                initial_config.workspace_dir, config.workspace_dir
            )
            .into());
        }

        if apply_to_process {
            apply_env_layers(&config_env, &workspace_env, process_env);
        }
        config.source_revision = source_revision(&content);
        if !config.diagnostics.is_empty() {
            let paths = config
                .diagnostics
                .iter()
                .take(8)
                .map(|diagnostic| diagnostic.path.as_str())
                .collect::<Vec<_>>()
                .join(", ");
            tracing::warn!(
                count = config.diagnostics.len(),
                paths,
                "Recoverable configuration entries were ignored"
            );
        }
        tracing::info!(
            path = %config_path.display(),
            config_env = %config_env_path.display(),
            workspace_env = %workspace_env_path.display(),
            "Config and layered environment loaded"
        );
        Ok(config)
    }

    pub fn get_provider_config(&self, agent_name: &str) -> Result<LLMProviderConfig, ConfigError> {
        let agent = self
            .agents
            .get(agent_name)
            .ok_or(ConfigError::AgentNotFound(agent_name.to_string()))?;

        let provider = self
            .providers
            .get(&agent.provider)
            .ok_or(ConfigError::ProviderNotFound(agent.provider.clone()))?;

        let model = self
            .models
            .get(&agent.model)
            .ok_or(ConfigError::ModelNotFound(agent.model.clone()))?;

        Ok(LLMProviderConfig {
            provider_type: provider.provider_type.clone(),
            name: agent.provider.clone(),
            base_url: provider.base_url.clone(),
            api_key: provider.api_key.clone(),
            extra_headers: provider.extra_headers.clone(),
            model_id: model.model_id.clone(),
            temperature: model.temperature,
            max_tokens: model.max_tokens,
            model_extra: model.extra.clone(),
            max_tool_iterations: agent.max_tool_iterations,
            token_limit: resolve_token_limit(agent.token_limit, model.token_limit),
            workspace_dir: expand_path(&self.workspace_dir),
            input_types: model.input_type.clone(),
            price_input_per_million: None,
            price_output_per_million: None,
        })
    }
}

#[derive(Debug)]
pub enum ConfigError {
    ConfigNotFound(String),
    AgentNotFound(String),
    ProviderNotFound(String),
    ModelNotFound(String),
}

impl std::fmt::Display for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ConfigError::ConfigNotFound(path) => write!(
                f,
                "Config file not found: {}. Use CONFIG_PATH env var or place config in ~/.picobot/config.json",
                path
            ),
            ConfigError::AgentNotFound(name) => write!(f, "Agent not found: {}", name),
            ConfigError::ProviderNotFound(name) => write!(f, "Provider not found: {}", name),
            ConfigError::ModelNotFound(name) => write!(f, "Model not found: {}", name),
        }
    }
}

impl std::error::Error for ConfigError {}

fn read_env_file(path: &Path) -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
    let mut values = HashMap::new();
    if !path.exists() {
        return Ok(values);
    }

    let content = fs::read_to_string(path)?;
    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some((key, value)) = line.split_once('=') {
            let key = key.trim();
            let value = value.trim().trim_matches('"').trim_matches('\'');
            if !key.is_empty() && !value.is_empty() {
                values.insert(key.to_string(), value.to_string());
            }
        }
    }
    Ok(values)
}

fn collect_process_env() -> HashMap<String, String> {
    env::vars_os()
        .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
        .collect()
}

fn merge_env_layers(
    config_env: &HashMap<String, String>,
    workspace_env: &HashMap<String, String>,
    process_env: &HashMap<String, String>,
) -> HashMap<String, String> {
    let mut merged = config_env.clone();
    merged.extend(workspace_env.clone());
    merged.extend(process_env.clone());
    merged
}

fn apply_env_layers(
    config_env: &HashMap<String, String>,
    workspace_env: &HashMap<String, String>,
    process_env: &HashMap<String, String>,
) {
    for (key, value) in config_env.iter().chain(workspace_env) {
        if !process_env.contains_key(key) {
            // SAFETY: Config loading happens during single-threaded startup before
            // Gateway background tasks are spawned. Existing process values are
            // never modified, and the workspace layer intentionally overwrites the
            // lower-priority config-directory layer.
            unsafe { env::set_var(key, value) };
        }
    }
}

fn resolve_env_placeholders(content: &str, values: &HashMap<String, String>) -> String {
    let re = Regex::new(r"<([A-Z_]+)>").expect("invalid regex");
    re.replace_all(content, |caps: &regex::Captures| {
        let var_name = &caps[1];
        values
            .get(var_name)
            .cloned()
            .unwrap_or_else(|| caps[0].to_string())
    })
    .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::ffi::OsString;
    use std::sync::Mutex;

    struct TestConfig {
        _dir: tempfile::TempDir,
        path: PathBuf,
    }

    impl TestConfig {
        fn path(&self) -> &Path {
            &self.path
        }
    }

    fn write_test_config() -> TestConfig {
        let dir = tempfile::TempDir::new().unwrap();
        let path = dir.path().join("config.json");
        let workspace = dir.path().join("workspace");
        let mut content: serde_json::Value = serde_json::from_str(
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        },
        "volcengine": {
            "type": "openai",
            "base_url": "https://example.invalid/volc",
            "api_key": "test-key-2",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus",
            "temperature": 0.0
        },
        "doubao-seed-2-0-lite-260215": {
            "model_id": "doubao-seed-2-0-lite-260215"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "gateway": {
        "host": "0.0.0.0",
        "port": 19876
    }
}"#,
        )
        .unwrap();
        content["workspace_dir"] = serde_json::json!(workspace);
        std::fs::write(&path, serde_json::to_vec_pretty(&content).unwrap()).unwrap();
        TestConfig { _dir: dir, path }
    }

    static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());

    struct EnvRestore(Vec<(&'static str, Option<OsString>)>);

    impl Drop for EnvRestore {
        fn drop(&mut self) {
            for (key, value) in self.0.drain(..) {
                if let Some(value) = value {
                    // SAFETY: The test serializes mutations of these unique keys.
                    unsafe { env::set_var(key, value) };
                } else {
                    // SAFETY: The test serializes mutations of these unique keys.
                    unsafe { env::remove_var(key) };
                }
            }
        }
    }

    #[test]
    fn layered_env_uses_config_then_workspace_then_process_precedence() {
        const VALUE: &str = "PICOBOT_ENV_LAYERING_VALUE";
        const SYSTEM: &str = "PICOBOT_ENV_LAYERING_SYSTEM";
        const CONFIG_ONLY: &str = "PICOBOT_ENV_LAYERING_CONFIG_ONLY";

        let _lock = ENV_TEST_LOCK.lock().unwrap();
        let _restore = EnvRestore(
            [VALUE, SYSTEM, CONFIG_ONLY]
                .into_iter()
                .map(|key| (key, env::var_os(key)))
                .collect(),
        );
        // SAFETY: Config loading is the code under test and these unique keys are
        // protected by ENV_TEST_LOCK for the duration of the test.
        unsafe {
            env::remove_var(VALUE);
            env::set_var(SYSTEM, "process");
            env::remove_var(CONFIG_ONLY);
        }

        let dir = tempfile::TempDir::new().unwrap();
        let config_dir = dir.path().join("config");
        let workspace_dir = dir.path().join("workspace");
        fs::create_dir_all(&config_dir).unwrap();
        fs::create_dir_all(&workspace_dir).unwrap();
        fs::write(
            config_dir.join(".env"),
            format!("{VALUE}=config\n{SYSTEM}=config\n{CONFIG_ONLY}=config-only\n"),
        )
        .unwrap();
        fs::write(
            workspace_dir.join(".env"),
            format!("{VALUE}=workspace\n{SYSTEM}=workspace\n"),
        )
        .unwrap();

        let config_path = config_dir.join("config.json");
        let config_json = serde_json::json!({
            "providers": {
                "default": {
                    "type": "openai",
                    "base_url": "https://example.invalid/v1",
                    "api_key": format!("<{VALUE}>|<{SYSTEM}>|<{CONFIG_ONLY}>")
                }
            },
            "models": { "default": { "model_id": "test" } },
            "agents": { "default": { "provider": "default", "model": "default" } },
            "workspace_dir": workspace_dir
        });
        fs::write(
            &config_path,
            serde_json::to_vec_pretty(&config_json).unwrap(),
        )
        .unwrap();

        let config = Config::load(config_path.to_str().unwrap()).unwrap();

        assert_eq!(
            config.providers["default"].api_key,
            "workspace|process|config-only"
        );
        assert_eq!(env::var(VALUE).unwrap(), "workspace");
        assert_eq!(env::var(SYSTEM).unwrap(), "process");
        assert_eq!(env::var(CONFIG_ONLY).unwrap(), "config-only");
    }

    #[test]
    fn test_config_load() {
        let file = write_test_config();
        let config = Config::load(file.path().to_str().unwrap()).unwrap();

        // Check providers
        assert!(config.providers.contains_key("volcengine"));
        assert!(config.providers.contains_key("aliyun"));

        // Check models
        assert!(config.models.contains_key("doubao-seed-2-0-lite-260215"));
        assert!(config.models.contains_key("qwen-plus"));

        // Check agents
        assert!(config.agents.contains_key("default"));
    }

    #[test]
    fn test_get_provider_config() {
        let file = write_test_config();
        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let provider_config = config.get_provider_config("default").unwrap();

        assert_eq!(provider_config.provider_type, "openai");
        assert_eq!(provider_config.name, "aliyun");
        assert_eq!(provider_config.model_id, "qwen-plus");
        assert_eq!(provider_config.temperature, Some(0.0));
        assert_eq!(provider_config.token_limit, 128_000);
    }

    #[test]
    fn context_window_uses_agent_model_minimum_then_128k_default() {
        let file = write_test_config();
        let mut config = Config::load(file.path().to_str().unwrap()).unwrap();

        config.models.get_mut("qwen-plus").unwrap().token_limit = Some(64_000);
        assert_eq!(
            config.get_provider_config("default").unwrap().token_limit,
            64_000
        );

        config.agents.get_mut("default").unwrap().token_limit = Some(32_000);
        assert_eq!(
            config.get_provider_config("default").unwrap().token_limit,
            32_000
        );

        config.agents.get_mut("default").unwrap().token_limit = Some(128_000);
        assert_eq!(
            config.get_provider_config("default").unwrap().token_limit,
            64_000
        );

        config.agents.get_mut("default").unwrap().token_limit = None;
        config.models.get_mut("qwen-plus").unwrap().token_limit = None;
        assert_eq!(
            config.get_provider_config("default").unwrap().token_limit,
            128_000
        );
    }

    #[test]
    fn test_default_gateway_config() {
        let file = write_test_config();
        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        assert_eq!(config.gateway.host, "0.0.0.0");
        assert_eq!(config.gateway.port, 19876);
        assert!(config.gateway.require_pairing);
        assert!(config.gateway.file_transfer.enabled);
        assert_eq!(
            config.gateway.file_transfer.max_file_bytes,
            25 * 1024 * 1024
        );
        assert!(config.browser.enabled);
        let browser: BrowserConfig = serde_json::from_str("{}").unwrap();
        assert!(browser.enabled);
        assert_eq!(browser.idle_timeout_secs, 60 * 60);
        assert!(
            browser
                .persistence
                .profile_dir
                .ends_with("browser/profiles")
        );
        assert!(config.context_compaction.enabled);
        assert_eq!(config.context_compaction.reserve_tokens, 16_384);
        assert_eq!(config.context_compaction.keep_recent_tokens, 20_000);
    }

    #[test]
    fn context_compaction_defaults_are_stable() {
        let config: ContextCompactionConfig = serde_json::from_str("{}").unwrap();
        assert!(config.enabled);
        assert_eq!(config.reserve_tokens, 16_384);
        assert_eq!(config.keep_recent_tokens, 20_000);
        assert!(serde_json::from_str::<ContextCompactionConfig>(r#"{"unknown":1}"#).is_ok());
    }

    #[test]
    fn orchestration_config_enforces_hierarchical_limits() {
        let valid = AgentOrchestrationConfig {
            ..Default::default()
        };
        assert!(valid.validate().is_ok());

        let invalid = AgentOrchestrationConfig {
            max_concurrent_runs: 1,
            max_concurrent_runs_per_session: 2,
            ..Default::default()
        };
        assert!(invalid.validate().is_err());
    }

    #[test]
    fn browser_persistence_config_is_explicit() {
        let browser: BrowserConfig = serde_json::from_str(
            r#"{
                "persistence": {
                    "profile_dir": "/tmp/picobot-browser-profiles"
                }
            }"#,
        )
        .unwrap();
        assert_eq!(
            browser.persistence.profile_dir,
            "/tmp/picobot-browser-profiles"
        );
        assert!(
            serde_json::from_str::<BrowserConfig>(
                r#"{"persistence":{"profile_dir":"/tmp/profiles","unknown":1}}"#
            )
            .is_ok()
        );
    }

    #[test]
    fn tolerant_load_reports_unknown_and_invalid_optional_fields() {
        let file = write_test_config();
        let mut value: Value =
            serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
        value["browser"] = serde_json::json!({
            "idle_timeout_secs": "old-value",
            "legacy_auto_close": true
        });
        fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();

        assert_eq!(config.browser.idle_timeout_secs, 60 * 60);
        assert_eq!(
            config
                .diagnostics
                .iter()
                .map(|diagnostic| diagnostic.path.as_str())
                .collect::<Vec<_>>(),
            vec!["/browser/idle_timeout_secs", "/browser/legacy_auto_close"]
        );
        assert!(Config::from_value_strict(value).is_err());
    }

    #[test]
    fn tolerant_load_drops_broken_unused_named_entry_but_not_default_chain() {
        let file = write_test_config();
        let mut value: Value =
            serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
        value["models"]["broken"] = serde_json::json!({ "model_id": 42 });
        fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        assert!(!config.models.contains_key("broken"));
        assert!(config.get_provider_config("default").is_ok());
        assert!(
            config
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.path == "/models/broken")
        );

        value["models"]["qwen-plus"]["model_id"] = serde_json::json!(42);
        fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        assert!(config.get_provider_config("default").is_err());
    }

    #[test]
    fn cleanup_uses_original_array_indexes_and_preserves_valid_entries() {
        let file = write_test_config();
        let mut value: Value =
            serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
        value["mcp"] = serde_json::json!({
            "servers": [
                { "name": 42 },
                { "name": "kept-one", "legacy": true },
                { "name": "kept-two", "transport": "retired-transport" },
                { "name": false }
            ]
        });
        fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        assert_eq!(
            config
                .mcp
                .servers
                .iter()
                .map(|server| server.name.as_str())
                .collect::<Vec<_>>(),
            vec!["kept-one", "kept-two"]
        );
        assert!(
            config
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.path == "/mcp/servers/1/legacy")
        );
        assert!(
            config
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.path == "/mcp/servers/2/transport")
        );

        assert_eq!(cleanup_invalid_config(&mut value, &config.diagnostics), 4);
        let servers = value["mcp"]["servers"].as_array().unwrap();
        assert_eq!(servers.len(), 2);
        assert_eq!(servers[0]["name"], "kept-one");
        assert!(servers[0].get("legacy").is_none());
        assert_eq!(servers[1]["name"], "kept-two");
        assert!(servers[1].get("transport").is_none());
    }

    #[test]
    fn diagnostic_and_cleanup_paths_escape_dynamic_map_keys() {
        let file = write_test_config();
        let mut value: Value =
            serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
        value["agents"]["retired/name~old"] = serde_json::json!({
            "provider": "missing",
            "model": "qwen-plus"
        });
        fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let pointer = "/agents/retired~1name~0old";
        assert!(
            config
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.path == pointer)
        );
        assert_eq!(cleanup_invalid_config(&mut value, &config.diagnostics), 1);
        assert!(value["agents"].get("retired/name~old").is_none());
    }

    #[test]
    fn mcp_tool_settings_default_to_conservative_and_derive_concurrency() {
        let server: McpServerConfig = serde_json::from_str(
            r#"{
                "name": "filesystem",
                "tool_settings": {
                    "read_file": { "read_only": true },
                    "locked_lookup": { "read_only": true, "exclusive": true }
                }
            }"#,
        )
        .unwrap();

        let unknown = server.tool_settings_for("unknown");
        assert!(!unknown.read_only);
        assert!(!unknown.exclusive);
        assert!(!unknown.concurrency_safe());

        assert!(server.tool_settings_for("read_file").concurrency_safe());
        assert!(!server.tool_settings_for("locked_lookup").concurrency_safe());
    }

    #[test]
    fn default_config_path_is_stable_across_working_directory_changes() {
        assert!(resolve_default_config_path().is_absolute());
    }
}
