use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;

#[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 time: TimeConfig,
    #[serde(default)]
    pub gateway: GatewayConfig,
    #[serde(default)]
    pub scheduler: SchedulerConfig,
    #[serde(default)]
    pub client: ClientConfig,
    #[serde(default)]
    pub channels: HashMap<String, ChannelConfig>,
    #[serde(default)]
    pub skills: SkillsConfig,
    #[serde(default)]
    pub tools: ToolsConfig,
    #[serde(default)]
    pub memory_maintenance: MemoryMaintenanceConfig,
    #[serde(default, rename = "mcpServers")]
    pub mcp_servers: HashMap<String, crate::mcp::McpServerConfig>,
    #[serde(default)]
    pub image_context: ImageContextConfig,
    #[serde(default)]
    pub subagents: SubagentsConfig,
}

/// 图片上下文限制配置
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ImageContextConfig {
    /// topic 上下文历史中最多发送给模型的图片数量 (默认 1)
    #[serde(default = "default_max_images_in_context")]
    pub max_images_in_context: usize,
    /// 图片超过多少消息轮次后就不再提交给模型 (默认 10)
    /// "轮次"定义为：消息在历史中的位置（距离最新消息的消息数）
    /// 包括所有 role 类型（user、assistant、tool、system 等）
    #[serde(default = "default_max_image_age_rounds")]
    pub max_image_age_rounds: usize,
}

fn default_max_images_in_context() -> usize {
    1
}

fn default_max_image_age_rounds() -> usize {
    10
}

impl Default for ImageContextConfig {
    fn default() -> Self {
        Self {
            max_images_in_context: default_max_images_in_context(),
            max_image_age_rounds: default_max_image_age_rounds(),
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TimeConfig {
    #[serde(default = "default_timezone")]
    pub timezone: String,
}

impl TimeConfig {
    pub fn parse_timezone(&self) -> Result<Tz, ConfigError> {
        self.timezone.parse::<Tz>().map_err(|_| {
            ConfigError::InvalidTimezone(format!(
                "unsupported timezone '{}', expected an IANA timezone like 'Asia/Shanghai'",
                self.timezone
            ))
        })
    }
}

impl Default for TimeConfig {
    fn default() -> Self {
        Self {
            timezone: default_timezone(),
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SkillsConfig {
    #[serde(default = "default_skills_enabled")]
    pub enabled: bool,
    #[serde(default = "default_skills_sources")]
    pub sources: Vec<String>,
    #[serde(default = "default_skills_max_index_chars")]
    pub max_index_chars: usize,
    #[serde(default = "default_skills_max_listed")]
    pub max_listed_skills: usize,
}

fn default_skills_enabled() -> bool {
    true
}

fn default_skills_sources() -> Vec<String> {
    vec![
        "user".to_string(),
        "user_agent".to_string(),
        "user_openclaw".to_string(),
        "project".to_string(),
        "project_agent".to_string(),
        "project_openclaw".to_string(),
    ]
}

fn default_skills_max_index_chars() -> usize {
    4_000
}

fn default_skills_max_listed() -> usize {
    32
}

impl Default for SkillsConfig {
    fn default() -> Self {
        Self {
            enabled: default_skills_enabled(),
            sources: default_skills_sources(),
            max_index_chars: default_skills_max_index_chars(),
            max_listed_skills: default_skills_max_listed(),
        }
    }
}

/// 自定义子代理配置
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SubagentsConfig {
    /// 是否启用自定义子代理发现
    #[serde(default = "default_subagents_enabled")]
    pub enabled: bool,
    /// 定义来源优先级
    #[serde(default = "default_subagents_sources")]
    pub sources: Vec<String>,
}

fn default_subagents_enabled() -> bool {
    true
}

fn default_subagents_sources() -> Vec<String> {
    vec!["user".to_string(), "project".to_string()]
}

impl Default for SubagentsConfig {
    fn default() -> Self {
        Self {
            enabled: default_subagents_enabled(),
            sources: default_subagents_sources(),
        }
    }
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ToolsConfig {
    #[serde(default)]
    pub disabled: Vec<String>,
    #[serde(default)]
    pub task: TaskConfig,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MemoryMaintenanceConfig {
    /// 单次最大合并/删除比例 (0.0-1.0)，默认 0.3 (30%)
    #[serde(default = "default_max_merge_ratio")]
    pub max_merge_ratio: f32,
    /// 最小保留记忆数量，默认 5
    #[serde(default = "default_min_memories_to_keep")]
    pub min_memories_to_keep: usize,
    /// 单次合并最大源记忆数，默认 3
    #[serde(default = "default_max_merge_per_group")]
    pub max_merge_per_group: usize,
}

fn default_max_merge_ratio() -> f32 {
    0.3
}

fn default_min_memories_to_keep() -> usize {
    5
}

fn default_max_merge_per_group() -> usize {
    3
}

impl Default for MemoryMaintenanceConfig {
    fn default() -> Self {
        Self {
            max_merge_ratio: default_max_merge_ratio(),
            min_memories_to_keep: default_min_memories_to_keep(),
            max_merge_per_group: default_max_merge_per_group(),
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TaskConfig {
    #[serde(default = "default_task_enabled")]
    pub enabled: bool,
    #[serde(default = "default_task_max_execution_secs")]
    pub max_execution_secs: u64,
    #[serde(default = "default_task_explore_max_execution_secs")]
    pub explore_max_execution_secs: u64,
    #[serde(default = "default_task_ttl_hours")]
    pub ttl_hours: u64,
    #[serde(default = "default_task_allowed_tools")]
    pub allowed_tools: Vec<String>,
}

fn default_task_enabled() -> bool {
    true
}

fn default_task_max_execution_secs() -> u64 {
    3600  // 60分钟
}

fn default_task_explore_max_execution_secs() -> u64 {
    3600  // 60分钟
}

fn default_task_ttl_hours() -> u64 {
    24
}

fn default_task_allowed_tools() -> Vec<String> {
    vec![
        "read".to_string(),
        "edit".to_string(),
        "write".to_string(),
        "bash".to_string(),
        "http_request".to_string(),
        "web_fetch".to_string(),
        "memory_search".to_string(),
        "get_time".to_string(),
        "calculator".to_string(),
        "skill_activate".to_string(),
        "skill_list".to_string(),
        "send_session_message".to_string(),
    ]
}

impl Default for TaskConfig {
    fn default() -> Self {
        Self {
            enabled: default_task_enabled(),
            max_execution_secs: default_task_max_execution_secs(),
            explore_max_execution_secs: default_task_explore_max_execution_secs(),
            ttl_hours: default_task_ttl_hours(),
            allowed_tools: default_task_allowed_tools(),
        }
    }
}

impl ToolsConfig {
    /// Check if a tool is disabled
    pub fn is_disabled(&self, tool_name: &str) -> bool {
        self.disabled.iter().any(|name| name == tool_name)
    }
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ChannelConfig {
    Tagged(TaggedChannelConfig),
    LegacyFeishu(FeishuChannelConfig),
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TaggedChannelConfig {
    Feishu(FeishuChannelConfig),
    Wechat(WechatChannelConfig),
}

impl ChannelConfig {
    pub fn kind(&self) -> &'static str {
        match self {
            Self::Tagged(TaggedChannelConfig::Feishu(_)) | Self::LegacyFeishu(_) => "feishu",
            Self::Tagged(TaggedChannelConfig::Wechat(_)) => "wechat",
        }
    }

    pub fn enabled(&self) -> bool {
        match self {
            Self::Tagged(TaggedChannelConfig::Feishu(config)) | Self::LegacyFeishu(config) => {
                config.enabled
            }
            Self::Tagged(TaggedChannelConfig::Wechat(config)) => config.enabled,
        }
    }

    pub fn as_feishu(&self) -> Option<&FeishuChannelConfig> {
        match self {
            Self::Tagged(TaggedChannelConfig::Feishu(config)) | Self::LegacyFeishu(config) => {
                Some(config)
            }
            Self::Tagged(TaggedChannelConfig::Wechat(_)) => None,
        }
    }

    pub fn as_wechat(&self) -> Option<&WechatChannelConfig> {
        match self {
            Self::Tagged(TaggedChannelConfig::Wechat(config)) => Some(config),
            Self::Tagged(TaggedChannelConfig::Feishu(_)) | Self::LegacyFeishu(_) => None,
        }
    }
}

#[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>,
    #[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,
    #[serde(default = "default_channel_max_message_chars")]
    pub max_message_chars: usize,
    #[serde(default = "default_channel_reply_context_max_chars")]
    pub reply_context_max_chars: usize,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WechatChannelConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_allow_from")]
    pub allow_from: Vec<String>,
    #[serde(default)]
    pub agent: String,
    #[serde(default = "default_wechat_base_url")]
    pub base_url: String,
    #[serde(default = "default_wechat_cred_path")]
    pub cred_path: String,
    #[serde(default)]
    pub force_login: bool,
}

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

fn default_media_dir() -> String {
    let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
    home.join(".picobot/media/feishu")
        .to_string_lossy()
        .to_string()
}

fn default_wechat_base_url() -> String {
    "https://ilinkai.weixin.qq.com".to_string()
}

fn default_wechat_cred_path() -> String {
    let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
    home.join(".picobot/wechat/credentials.json")
        .to_string_lossy()
        .to_string()
}

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

fn default_channel_max_message_chars() -> usize {
    20_000
}

fn default_channel_reply_context_max_chars() -> usize {
    20_000
}

#[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>,
    #[serde(default = "default_llm_timeout_secs")]
    pub llm_timeout_secs: u64,
    #[serde(default = "default_memory_maintenance_timeout_secs")]
    pub memory_maintenance_timeout_secs: u64,
}

#[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>,
    #[serde(default)]
    pub context_window_tokens: Option<u32>,
    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

#[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,
    #[serde(default = "default_tool_result_max_chars")]
    pub tool_result_max_chars: usize,
    #[serde(default = "default_context_tool_result_trim_chars")]
    pub context_tool_result_trim_chars: usize,
}

fn default_max_tool_iterations() -> usize {
    100
}

fn default_tool_result_max_chars() -> usize {
    100_000
}

fn default_context_tool_result_trim_chars() -> usize {
    2_000
}

fn default_llm_timeout_secs() -> u64 {
    120
}

fn default_memory_maintenance_timeout_secs() -> u64 {
    600
}

#[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)]
    pub show_tool_results: bool,
    #[serde(
        default = "default_agent_prompt_reinject_every",
        rename = "agent_prompt_reinject_every"
    )]
    pub agent_prompt_reinject_every: u64,
    #[serde(default = "default_max_concurrent_requests")]
    pub max_concurrent_requests: usize,
    #[serde(default, rename = "session_ttl_hours")]
    pub session_ttl_hours: Option<u64>,
}

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

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SchedulerConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_scheduler_tick_resolution_ms")]
    pub tick_resolution_ms: u64,
    #[serde(default = "default_scheduler_worker_queue_capacity")]
    pub worker_queue_capacity: usize,
    #[serde(default)]
    pub misfire_policy: SchedulerMisfirePolicy,
    #[serde(default)]
    pub jobs: Vec<SchedulerJobConfig>,
}

pub const BUILTIN_MEMORY_MAINTENANCE_JOB_ID: &str = "builtin.memory_maintenance_daily";
pub const BUILTIN_SESSION_CLEANUP_JOB_ID: &str = "builtin.session_cleanup_hourly";

#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SchedulerMisfirePolicy {
    CatchUp,
    #[default]
    Skip,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SchedulerJobConfig {
    pub id: String,
    #[serde(default = "default_scheduler_job_enabled")]
    pub enabled: bool,
    pub kind: SchedulerJobKind,
    #[serde(default)]
    pub schedule: Option<SchedulerSchedule>,
    #[serde(default)]
    pub startup_delay_secs: u64,
    #[serde(default)]
    pub interval_secs: u64,
    #[serde(default)]
    pub target: SchedulerJobTarget,
    #[serde(default)]
    pub payload: serde_json::Value,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SchedulerJobKind {
    InternalEvent,
    OutboundMessage,
    AgentTask,
    SilentAgentTask,
}

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct SchedulerJobTarget {
    #[serde(default)]
    pub channel: Option<String>,
    #[serde(default)]
    pub chat_id: Option<String>,
    #[serde(default)]
    pub session_chat_id: Option<String>,
    #[serde(default)]
    pub reply_to: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SchedulerSchedule {
    Delay {
        seconds: u64,
    },
    Interval {
        seconds: u64,
        #[serde(default)]
        startup_delay_secs: u64,
    },
    At {
        timestamp: String,
    },
    Cron {
        expression: String,
    },
}

impl SchedulerJobConfig {
    pub fn resolved_schedule(&self) -> Result<SchedulerSchedule, ConfigError> {
        if let Some(schedule) = &self.schedule {
            schedule.validate(&self.id)?;
            return Ok(schedule.normalized_for_storage());
        }

        if self.interval_secs > 0 {
            return Ok(SchedulerSchedule::Interval {
                seconds: self.interval_secs,
                startup_delay_secs: self.startup_delay_secs,
            });
        }

        Err(ConfigError::InvalidSchedulerJob(format!(
            "scheduler job '{}' requires schedule or interval_secs",
            self.id
        )))
    }
}

impl SchedulerConfig {
    pub fn builtin_jobs(time: &TimeConfig) -> Vec<SchedulerJobConfig> {
        vec![
            SchedulerJobConfig {
                id: BUILTIN_MEMORY_MAINTENANCE_JOB_ID.to_string(),
                enabled: true,
                kind: SchedulerJobKind::InternalEvent,
                schedule: Some(SchedulerSchedule::Cron {
                    expression: "0 */4 * * *".to_string(),
                }),
                startup_delay_secs: 0,
                interval_secs: 0,
                target: SchedulerJobTarget::default(),
                payload: serde_json::json!({
                    "event": "memory_maintenance",
                    "time_zone": time.timezone,
                    "local_time": "every_4_hours"
                }),
            },
            SchedulerJobConfig {
                id: BUILTIN_SESSION_CLEANUP_JOB_ID.to_string(),
                enabled: true,
                kind: SchedulerJobKind::InternalEvent,
                schedule: Some(SchedulerSchedule::Cron {
                    expression: "0 * * * *".to_string(),
                }),
                startup_delay_secs: 0,
                interval_secs: 0,
                target: SchedulerJobTarget::default(),
                payload: serde_json::json!({
                    "event": "session_cleanup",
                    "time_zone": time.timezone,
                    "local_time": "every_hour"
                }),
            },
        ]
    }

    pub fn effective_jobs(&self, time: &TimeConfig) -> Vec<SchedulerJobConfig> {
        let mut jobs = Self::builtin_jobs(time);

        for configured in &self.jobs {
            if let Some(existing) = jobs.iter_mut().find(|job| job.id == configured.id) {
                *existing = configured.clone();
            } else {
                jobs.push(configured.clone());
            }
        }

        jobs
    }
}

impl SchedulerSchedule {
    pub fn validate(&self, job_id: &str) -> Result<(), ConfigError> {
        match self {
            SchedulerSchedule::Delay { seconds } => {
                if *seconds == 0 {
                    return Err(ConfigError::InvalidSchedulerJob(format!(
                        "scheduler job '{}' delay.seconds must be greater than 0",
                        job_id
                    )));
                }
            }
            SchedulerSchedule::Interval { seconds, .. } => {
                if *seconds == 0 {
                    return Err(ConfigError::InvalidSchedulerJob(format!(
                        "scheduler job '{}' interval.seconds must be greater than 0",
                        job_id
                    )));
                }
            }
            SchedulerSchedule::At { timestamp } => {
                DateTime::parse_from_rfc3339(timestamp).map_err(|err| {
                    ConfigError::InvalidSchedulerJob(format!(
                        "scheduler job '{}' invalid at.timestamp '{}': {}",
                        job_id, timestamp, err
                    ))
                })?;
            }
            SchedulerSchedule::Cron { expression } => {
                parse_scheduler_cron(expression).map_err(|err| {
                    ConfigError::InvalidSchedulerJob(format!(
                        "scheduler job '{}' invalid cron.expression '{}': {}",
                        job_id, expression, err
                    ))
                })?;
            }
        }

        Ok(())
    }

    pub fn is_one_shot(&self) -> bool {
        matches!(
            self,
            SchedulerSchedule::Delay { .. } | SchedulerSchedule::At { .. }
        )
    }

    pub fn normalized_for_storage(&self) -> Self {
        match self {
            SchedulerSchedule::At { timestamp } => {
                let parsed = DateTime::parse_from_rfc3339(timestamp)
                    .map(|value| value.with_timezone(&Utc).to_rfc3339())
                    .unwrap_or_else(|_| timestamp.clone());
                SchedulerSchedule::At { timestamp: parsed }
            }
            other => other.clone(),
        }
    }

    pub fn display(&self) -> String {
        match self {
            SchedulerSchedule::Delay { seconds } => format!("delay:{}s", seconds),
            SchedulerSchedule::Interval {
                seconds,
                startup_delay_secs,
            } => format!("interval:{}s:start_delay:{}s", seconds, startup_delay_secs),
            SchedulerSchedule::At { timestamp } => format!("at:{}", timestamp),
            SchedulerSchedule::Cron { expression } => format!("cron:{}", expression),
        }
    }
}

fn parse_scheduler_cron(expression: &str) -> Result<cron::Schedule, cron::error::Error> {
    let normalized = normalize_cron_expression(expression);
    cron::Schedule::from_str(&normalized)
}

fn normalize_cron_expression(expression: &str) -> String {
    let parts: Vec<&str> = expression.split_whitespace().collect();
    if parts.len() == 5 {
        format!("0 {}", expression.trim())
    } else {
        expression.trim().to_string()
    }
}

fn default_scheduler_tick_resolution_ms() -> u64 {
    1_000
}

fn default_scheduler_worker_queue_capacity() -> usize {
    64
}

fn default_scheduler_job_enabled() -> bool {
    true
}

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

fn default_gateway_port() -> u16 {
    19876
}

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

fn default_timezone() -> String {
    detect_system_timezone().unwrap_or_else(default_beijing_timezone)
}

fn detect_system_timezone() -> Option<String> {
    let detected = iana_time_zone::get_timezone().ok()?;
    if detected.parse::<Tz>().is_ok() {
        Some(detected)
    } else {
        None
    }
}

fn default_beijing_timezone() -> String {
    "Asia/Shanghai".to_string()
}

fn default_agent_prompt_reinject_every() -> u64 {
    100
}

fn default_max_concurrent_requests() -> usize {
    10
}

impl Default for GatewayConfig {
    fn default() -> Self {
        Self {
            host: default_gateway_host(),
            port: default_gateway_port(),
            show_tool_results: false,
            agent_prompt_reinject_every: default_agent_prompt_reinject_every(),
            max_concurrent_requests: default_max_concurrent_requests(),
            session_ttl_hours: Some(24),
        }
    }
}

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

impl Default for SchedulerConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            tick_resolution_ms: default_scheduler_tick_resolution_ms(),
            worker_queue_capacity: default_scheduler_worker_queue_capacity(),
            misfire_policy: SchedulerMisfirePolicy::default(),
            jobs: Vec::new(),
        }
    }
}

#[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 llm_timeout_secs: u64,
    pub memory_maintenance_timeout_secs: u64,
    pub model_id: String,
    pub temperature: Option<f32>,
    pub max_tokens: Option<u32>,
    pub context_window_tokens: Option<u32>,
    pub model_extra: HashMap<String, serde_json::Value>,
    pub max_tool_iterations: usize,
    pub tool_result_max_chars: usize,
    pub context_tool_result_trim_chars: usize,
    /// 图片上下文限制配置
    pub max_images_in_context: usize,
    pub max_image_age_rounds: usize,
}

impl LLMProviderConfig {
    pub fn context_window_tokens(&self) -> usize {
        self.context_window_tokens
            .map(|value| value as usize)
            .unwrap_or(128_000)
    }

    pub fn context_summary_char_budget(&self) -> usize {
        const SUMMARY_RATIO: f64 = 0.1;
        const CHARS_PER_TOKEN: f64 = 2.5;
        const MIN_SUMMARY_CHARS: usize = 1_500;
        const MAX_SUMMARY_CHARS: usize = 50_000;

        ((self.context_window_tokens() as f64 * SUMMARY_RATIO * CHARS_PER_TOKEN) as usize)
            .clamp(MIN_SUMMARY_CHARS, MAX_SUMMARY_CHARS)
    }
}

fn get_default_config_path() -> PathBuf {
    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    home.join(".picobot").join("config.json")
}

impl Config {
    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 = get_default_config_path();
        Self::load_from(&path)
    }

    fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
        load_env_file()?;
        let content = if path.exists() {
            tracing::info!(path = %path.display(), "Config loaded");
            fs::read_to_string(path)?
        } else {
            // Fallback to current directory
            let fallback = Path::new("config.json");
            if fallback.exists() {
                tracing::info!(path = %fallback.display(), "Config loaded from fallback path");
                fs::read_to_string(fallback)?
            } else {
                return Err(Box::new(ConfigError::ConfigNotFound(
                    path.to_string_lossy().to_string(),
                )));
            }
        };
        let content = resolve_env_placeholders(&content);
        let config: Config = serde_json::from_str(&content)?;
        config.time.parse_timezone()?;

        // Log MCP servers count if any
        if !config.mcp_servers.is_empty() {
            tracing::info!(
                mcp_servers = config.mcp_servers.len(),
                "MCP servers loaded from config"
            );
        }

        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(),
            llm_timeout_secs: provider.llm_timeout_secs,
            memory_maintenance_timeout_secs: provider.memory_maintenance_timeout_secs,
            model_id: model.model_id.clone(),
            temperature: model.temperature,
            max_tokens: model.max_tokens,
            context_window_tokens: model.context_window_tokens,
            model_extra: model.extra.clone(),
            max_tool_iterations: agent.max_tool_iterations,
            tool_result_max_chars: agent.tool_result_max_chars,
            context_tool_result_trim_chars: agent.context_tool_result_trim_chars,
            max_images_in_context: self.image_context.max_images_in_context,
            max_image_age_rounds: self.image_context.max_image_age_rounds,
        })
    }
}

#[derive(Debug)]
pub enum ConfigError {
    ConfigNotFound(String),
    AgentNotFound(String),
    ProviderNotFound(String),
    ModelNotFound(String),
    InvalidSchedulerJob(String),
    InvalidTimezone(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),
            ConfigError::InvalidSchedulerJob(message) => {
                write!(f, "Invalid scheduler job: {}", message)
            }
            ConfigError::InvalidTimezone(message) => write!(f, "Invalid timezone: {}", message),
        }
    }
}

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

fn load_env_file() -> Result<(), Box<dyn std::error::Error>> {
    let env_path = Path::new(".env");
    if env_path.exists() {
        let content = fs::read_to_string(env_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 !value.is_empty() {
                    // SAFETY: Setting environment variables for the current process
                    // is safe as we're only modifying our own process state
                    unsafe { env::set_var(key, value) };
                }
            }
        }
    }
    Ok(())
}

fn resolve_env_placeholders(content: &str) -> String {
    // Support both ${ENV_VAR} (Claude Desktop style) and <ENV_VAR> (legacy style)
    let re_braces = Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)\}").expect("invalid regex");
    let re_angle = Regex::new(r"<([A-Z_]+)>").expect("invalid regex");

    let content = re_braces.replace_all(content, |caps: &regex::Captures| {
        let var_name = &caps[1];
        env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
    });

    re_angle.replace_all(&content, |caps: &regex::Captures| {
        let var_name = &caps[1];
        env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
    })
    .to_string()
}

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

    fn write_test_config() -> tempfile::NamedTempFile {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            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": "127.0.0.1",
        "port": 19876,
        "agent_prompt_reinject_every": 120
    }
}"#,
        )
        .unwrap();
        file
    }

    #[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.max_tokens, None);
        assert_eq!(provider_config.llm_timeout_secs, 120);
        assert_eq!(provider_config.tool_result_max_chars, 100_000);
        assert_eq!(provider_config.context_tool_result_trim_chars, 2_000);
        assert_eq!(provider_config.context_summary_char_budget(), 32_000);
    }

    #[test]
    fn test_provider_config_loads_custom_llm_timeout() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {},
            "llm_timeout_secs": 400
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let provider_config = config.get_provider_config("default").unwrap();

        assert_eq!(provider_config.llm_timeout_secs, 400);
    }

    #[test]
    fn test_default_skills_sources_include_agent_directories() {
        let config = SkillsConfig::default();

        assert_eq!(
            config.sources,
            vec![
                "user".to_string(),
                "user_agent".to_string(),
                "user_openclaw".to_string(),
                "project".to_string(),
                "project_agent".to_string(),
                "project_openclaw".to_string(),
            ]
        );
    }

    #[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, "127.0.0.1");
        assert_eq!(config.gateway.port, 19876);
        assert!(!config.gateway.show_tool_results);
        assert_eq!(config.gateway.agent_prompt_reinject_every, 120);
    }

    #[test]
    fn test_gateway_config_defaults_agent_prompt_reinject_every() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        assert!(!config.gateway.show_tool_results);
        assert_eq!(config.gateway.agent_prompt_reinject_every, 100);
    }

    #[test]
    fn test_config_loads_configured_timezone() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "time": {
        "timezone": "Asia/Shanghai"
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        assert_eq!(config.time.timezone, "Asia/Shanghai");
        assert_eq!(
            config.time.parse_timezone().unwrap(),
            chrono_tz::Asia::Shanghai
        );
    }

    #[test]
    fn test_config_rejects_invalid_timezone() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "time": {
        "timezone": "Mars/Base"
    }
}"#,
        )
        .unwrap();

        let error = Config::load(file.path().to_str().unwrap()).unwrap_err();
        assert!(error.to_string().contains("Invalid timezone"));
    }

    #[test]
    fn test_gateway_config_can_enable_tool_results() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "gateway": {
        "show_tool_results": true
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        assert!(config.gateway.show_tool_results);
    }

    #[test]
    fn test_agent_config_defaults_max_tool_iterations_to_100() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        assert_eq!(config.agents["default"].max_tool_iterations, 100);
        assert_eq!(config.agents["default"].tool_result_max_chars, 100_000);
        assert_eq!(
            config.agents["default"].context_tool_result_trim_chars,
            2_000
        );
    }

    #[test]
    fn test_agent_config_loads_custom_truncation_limits() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus",
            "tool_result_max_chars": 1234,
            "context_tool_result_trim_chars": 3456
        }
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let agent = &config.agents["default"];
        let provider_config = config.get_provider_config("default").unwrap();

        assert_eq!(agent.tool_result_max_chars, 1234);
        assert_eq!(agent.context_tool_result_trim_chars, 3456);
        assert_eq!(provider_config.tool_result_max_chars, 1234);
        assert_eq!(provider_config.context_tool_result_trim_chars, 3456);
        assert_eq!(provider_config.context_summary_char_budget(), 32_000);
    }

    #[test]
    fn test_provider_config_summary_budget_scales_with_context_window_tokens() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus",
            "context_window_tokens": 4096
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let provider_config = config.get_provider_config("default").unwrap();

        assert_eq!(provider_config.context_window_tokens(), 4096);
        assert_eq!(provider_config.context_summary_char_budget(), 1_500);
    }

    #[test]
    fn test_provider_config_max_tokens_does_not_change_context_window() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus",
            "max_tokens": 4096
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let provider_config = config.get_provider_config("default").unwrap();

        assert_eq!(provider_config.max_tokens, Some(4096));
        assert_eq!(provider_config.context_window_tokens(), 128_000);
        assert_eq!(provider_config.context_summary_char_budget(), 32_000);
    }

    #[test]
    fn test_feishu_channel_config_defaults_truncation_limits() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "channels": {
        "feishu": {
            "enabled": true,
            "app_id": "app-id",
            "app_secret": "secret"
        }
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let feishu = config.channels["feishu"].as_feishu().unwrap();
        assert_eq!(feishu.max_message_chars, 20_000);
        assert_eq!(feishu.reply_context_max_chars, 20_000);
    }

    #[test]
    fn test_tagged_feishu_channel_config_loads() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "channels": {
        "primary": {
            "type": "feishu",
            "enabled": true,
            "app_id": "app-id",
            "app_secret": "secret"
        }
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let feishu = config.channels["primary"].as_feishu().unwrap();

        assert_eq!(config.channels["primary"].kind(), "feishu");
        assert!(config.channels["primary"].enabled());
        assert_eq!(feishu.app_id, "app-id");
    }

    #[test]
    fn test_tagged_wechat_channel_config_loads() {
        let file = tempfile::NamedTempFile::new().unwrap();
        // 使用临时文件路径确保跨平台兼容
        let temp_dir = tempfile::tempdir().unwrap();
        let cred_path = temp_dir.path().join("wechat-creds.json");
        // JSON 中的路径需要转义反斜杠
        let cred_path_json = cred_path.display().to_string().replace('\\', "\\\\");

        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "channels": {
        "wechat_main": {
            "type": "wechat",
            "enabled": true,
            "base_url": "https://ilinkai.weixin.qq.com",
            "cred_path": "<CRED_PATH>",
            "force_login": true,
            "allow_from": ["wxid_1"]
        }
    }
}"#.replace("<CRED_PATH>", &cred_path_json),
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let wechat = config.channels["wechat_main"].as_wechat().unwrap();

        assert_eq!(config.channels["wechat_main"].kind(), "wechat");
        assert!(config.channels["wechat_main"].enabled());
        assert_eq!(wechat.cred_path, cred_path.display().to_string());
        assert!(wechat.force_login);
        assert_eq!(wechat.allow_from, vec!["wxid_1"]);
    }

    #[test]
    fn test_feishu_channel_config_loads_custom_truncation_limits() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "channels": {
        "feishu": {
            "enabled": true,
            "app_id": "app-id",
            "app_secret": "secret",
            "max_message_chars": 3456,
            "reply_context_max_chars": 4567
        }
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let feishu = config.channels["feishu"].as_feishu().unwrap();
        assert_eq!(feishu.max_message_chars, 3456);
        assert_eq!(feishu.reply_context_max_chars, 4567);
    }

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

        assert!(config.scheduler.enabled);
        assert_eq!(config.scheduler.tick_resolution_ms, 1_000);
        assert_eq!(config.scheduler.worker_queue_capacity, 64);
        assert_eq!(
            config.scheduler.misfire_policy,
            SchedulerMisfirePolicy::Skip
        );
        assert!(config.scheduler.jobs.is_empty());

        let effective_jobs = config.scheduler.effective_jobs(&config.time);
        assert_eq!(effective_jobs.len(), 2);
        assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID);
        assert_eq!(effective_jobs[0].kind, SchedulerJobKind::InternalEvent);
        assert_eq!(
            effective_jobs[0].resolved_schedule().unwrap(),
            SchedulerSchedule::Cron {
                expression: "0 */4 * * *".to_string(),
            }
        );
        // 第二个内置作业是会话清理
        assert_eq!(effective_jobs[1].id, BUILTIN_SESSION_CLEANUP_JOB_ID);
    }

    #[test]
    fn test_scheduler_effective_jobs_allows_builtin_override() {
        let mut scheduler = SchedulerConfig::default();
        scheduler.jobs.push(SchedulerJobConfig {
            id: BUILTIN_MEMORY_MAINTENANCE_JOB_ID.to_string(),
            enabled: false,
            kind: SchedulerJobKind::InternalEvent,
            schedule: Some(SchedulerSchedule::Cron {
                expression: "15 2 * * *".to_string(),
            }),
            startup_delay_secs: 0,
            interval_secs: 0,
            target: SchedulerJobTarget::default(),
            payload: serde_json::json!({
                "event": "memory_maintenance",
                "time_zone": "UTC",
                "local_time": "02:15"
            }),
        });
        scheduler.jobs.push(SchedulerJobConfig {
            id: "custom.reminder".to_string(),
            enabled: true,
            kind: SchedulerJobKind::InternalEvent,
            schedule: Some(SchedulerSchedule::Delay { seconds: 30 }),
            startup_delay_secs: 0,
            interval_secs: 0,
            target: SchedulerJobTarget::default(),
            payload: serde_json::json!({"event": "custom"}),
        });

        let effective_jobs = scheduler.effective_jobs(&TimeConfig {
            timezone: "Asia/Shanghai".to_string(),
        });
        assert_eq!(effective_jobs.len(), 3); // 2个内置 + 1个自定义
        // 第一个作业：内存维护（被覆盖为禁用）
        assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID);
        assert!(!effective_jobs[0].enabled);
        assert_eq!(
            effective_jobs[0].resolved_schedule().unwrap(),
            SchedulerSchedule::Cron {
                expression: "15 2 * * *".to_string(),
            }
        );
        // 第二个作业：会话清理（保持默认）
        assert_eq!(effective_jobs[1].id, BUILTIN_SESSION_CLEANUP_JOB_ID);
        assert!(effective_jobs[1].enabled);
        // 第三个作业：自定义提醒
        assert_eq!(effective_jobs[2].id, "custom.reminder");
    }

    #[test]
    fn test_scheduler_config_loads_interval_compat_jobs() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "scheduler": {
        "enabled": true,
        "tick_resolution_ms": 500,
        "worker_queue_capacity": 8,
        "misfire_policy": "catch_up",
        "jobs": [
            {
                "id": "heartbeat.reminder",
                "kind": "outbound_message",
                "interval_secs": 60,
                "startup_delay_secs": 5,
                "target": {
                    "channel": "feishu",
                    "chat_id": "oc_demo"
                },
                "payload": {
                    "content": "heartbeat"
                }
            }
        ]
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        assert!(config.scheduler.enabled);
        assert_eq!(config.scheduler.tick_resolution_ms, 500);
        assert_eq!(config.scheduler.worker_queue_capacity, 8);
        assert_eq!(
            config.scheduler.misfire_policy,
            SchedulerMisfirePolicy::CatchUp
        );
        assert_eq!(config.scheduler.jobs.len(), 1);

        let job = &config.scheduler.jobs[0];
        assert_eq!(job.id, "heartbeat.reminder");
        assert!(job.enabled);
        assert_eq!(job.kind, SchedulerJobKind::OutboundMessage);
        assert_eq!(job.interval_secs, 60);
        assert_eq!(job.startup_delay_secs, 5);
        assert_eq!(job.target.channel.as_deref(), Some("feishu"));
        assert_eq!(job.target.chat_id.as_deref(), Some("oc_demo"));
        assert_eq!(
            job.payload.get("content").and_then(|value| value.as_str()),
            Some("heartbeat")
        );
        assert_eq!(
            job.resolved_schedule().unwrap(),
            SchedulerSchedule::Interval {
                seconds: 60,
                startup_delay_secs: 5,
            }
        );
    }

    #[test]
    fn test_scheduler_config_loads_schedule_variants() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "scheduler": {
        "enabled": true,
        "jobs": [
            {
                "id": "delay.job",
                "kind": "internal_event",
                "schedule": {
                    "type": "delay",
                    "seconds": 30
                }
            },
            {
                "id": "at.job",
                "kind": "outbound_message",
                "schedule": {
                    "type": "at",
                    "timestamp": "2026-04-23T09:00:00Z"
                },
                "target": {
                    "channel": "feishu",
                    "chat_id": "oc_demo"
                },
                "payload": {
                    "content": "at run"
                }
            },
            {
                "id": "cron.job",
                "kind": "internal_event",
                "schedule": {
                    "type": "cron",
                    "expression": "0 9 * * *"
                }
            }
        ]
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        assert_eq!(config.scheduler.jobs.len(), 3);
        assert_eq!(
            config.scheduler.jobs[0].resolved_schedule().unwrap(),
            SchedulerSchedule::Delay { seconds: 30 }
        );
        assert_eq!(
            config.scheduler.jobs[0].kind,
            SchedulerJobKind::InternalEvent
        );
        assert_eq!(
            config.scheduler.jobs[1].resolved_schedule().unwrap(),
            SchedulerSchedule::At {
                timestamp: "2026-04-23T09:00:00+00:00".to_string(),
            }
        );
        assert_eq!(
            config.scheduler.jobs[1].kind,
            SchedulerJobKind::OutboundMessage
        );
        assert_eq!(
            config.scheduler.jobs[2].resolved_schedule().unwrap(),
            SchedulerSchedule::Cron {
                expression: "0 9 * * *".to_string(),
            }
        );
        assert_eq!(
            config.scheduler.jobs[2].kind,
            SchedulerJobKind::InternalEvent
        );
    }

    #[test]
    fn test_scheduler_config_loads_agent_task_job() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "scheduler": {
        "enabled": true,
        "jobs": [
            {
                "id": "agent.daily_summary",
                "kind": "agent_task",
                "schedule": {
                    "type": "cron",
                    "expression": "0 9 * * *"
                },
                "target": {
                    "channel": "feishu",
                    "chat_id": "oc_demo"
                },
                "payload": {
                    "prompt": "请总结今天待办"
                }
            }
        ]
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let job = &config.scheduler.jobs[0];
        assert_eq!(job.kind, SchedulerJobKind::AgentTask);
        assert_eq!(job.target.channel.as_deref(), Some("feishu"));
        assert_eq!(job.target.chat_id.as_deref(), Some("oc_demo"));
        assert_eq!(
            job.payload.get("prompt").and_then(|value| value.as_str()),
            Some("请总结今天待办")
        );
    }

    #[test]
    fn test_scheduler_config_loads_silent_agent_task_job() {
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "scheduler": {
        "enabled": true,
        "jobs": [
            {
                "id": "agent.daily_summary.background",
                "kind": "silent_agent_task",
                "schedule": {
                    "type": "cron",
                    "expression": "0 9 * * *"
                },
                "target": {
                    "channel": "feishu",
                    "chat_id": "oc_demo",
                    "session_chat_id": "scheduler/agent.daily_summary.background"
                },
                "payload": {
                    "prompt": "请后台总结今天待办"
                }
            }
        ]
    }
}"#,
        )
        .unwrap();

        let config = Config::load(file.path().to_str().unwrap()).unwrap();
        let job = &config.scheduler.jobs[0];
        assert_eq!(job.kind, SchedulerJobKind::SilentAgentTask);
        assert_eq!(job.target.channel.as_deref(), Some("feishu"));
        assert_eq!(job.target.chat_id.as_deref(), Some("oc_demo"));
        assert_eq!(
            job.target.session_chat_id.as_deref(),
            Some("scheduler/agent.daily_summary.background")
        );
        assert_eq!(
            job.payload.get("prompt").and_then(|value| value.as_str()),
            Some("请后台总结今天待办")
        );
    }

    #[test]
    fn test_scheduler_schedule_validation_rejects_invalid_values() {
        assert!(
            SchedulerSchedule::Delay { seconds: 0 }
                .validate("delay.job")
                .is_err()
        );
        assert!(
            SchedulerSchedule::Interval {
                seconds: 0,
                startup_delay_secs: 0,
            }
            .validate("interval.job")
            .is_err()
        );
        assert!(
            SchedulerSchedule::At {
                timestamp: "bad timestamp".to_string(),
            }
            .validate("at.job")
            .is_err()
        );
        assert!(
            SchedulerSchedule::Cron {
                expression: "bad cron".to_string(),
            }
            .validate("cron.job")
            .is_err()
        );
    }

    #[test]
    fn test_resolve_env_placeholders_brace_syntax() {
        // Test ${ENV_VAR} syntax (Claude Desktop style)
        unsafe { env::set_var("TEST_API_KEY", "my-secret-key") };

        let content = r#"{"api_key": "${TEST_API_KEY}", "other": "${MISSING_VAR}"}"#;
        let resolved = resolve_env_placeholders(content);

        assert!(resolved.contains("my-secret-key"));
        assert!(resolved.contains("${MISSING_VAR}")); // Unresolved stays as-is

        // Clean up
        unsafe { env::remove_var("TEST_API_KEY") };
    }

    #[test]
    fn test_resolve_env_placeholders_angle_syntax() {
        // Test <ENV_VAR> syntax (legacy style)
        unsafe { env::set_var("LEGACY_KEY", "legacy-value") };

        let content = r#"{"api_key": "<LEGACY_KEY>", "other": "<MISSING>"}"#;
        let resolved = resolve_env_placeholders(content);

        assert!(resolved.contains("legacy-value"));
        assert!(resolved.contains("<MISSING>")); // Unresolved stays as-is

        // Clean up
        unsafe { env::remove_var("LEGACY_KEY") };
    }

    #[test]
    fn test_resolve_env_placeholders_mixed_syntax() {
        // Test both syntaxes in the same content
        unsafe { env::set_var("BRACE_VAR", "brace-value") };
        unsafe { env::set_var("ANGLE_VAR", "angle-value") };

        let content = r#"{"brace": "${BRACE_VAR}", "angle": "<ANGLE_VAR>"}"#;
        let resolved = resolve_env_placeholders(content);

        assert!(resolved.contains("brace-value"));
        assert!(resolved.contains("angle-value"));

        // Clean up
        unsafe { env::remove_var("BRACE_VAR") };
        unsafe { env::remove_var("ANGLE_VAR") };
    }

    #[test]
    fn test_root_level_mcp_servers_merging() {
        // Test that mcpServers at root level is loaded correctly
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "mcpServers": {
        "WebSearch": {
            "type": "streamableHttp",
            "baseUrl": "https://api.example.com/mcp",
            "isActive": true
        },
        "filesystem": {
            "type": "stdio",
            "command": "npx",
            "isActive": true
        }
    }
}"#,
        )
        .unwrap();

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

        // Should have 2 servers
        assert_eq!(config.mcp_servers.len(), 2);
        assert!(config.mcp_servers.contains_key("WebSearch"));
        assert!(config.mcp_servers.contains_key("filesystem"));
    }

    #[test]
    fn test_root_level_mcp_servers_only() {
        // Test that mcpServers at root level works
        let file = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(
            file.path(),
            r#"{
    "providers": {
        "aliyun": {
            "type": "openai",
            "base_url": "https://example.invalid/v1",
            "api_key": "test-key",
            "extra_headers": {}
        }
    },
    "models": {
        "qwen-plus": {
            "model_id": "qwen-plus"
        }
    },
    "agents": {
        "default": {
            "provider": "aliyun",
            "model": "qwen-plus"
        }
    },
    "mcpServers": {
        "WebSearch": {
            "type": "streamableHttp",
            "baseUrl": "https://api.example.com/mcp",
            "isActive": true
        }
    }
}"#,
        )
        .unwrap();

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

        // Should have 1 server from root level
        assert_eq!(config.mcp_servers.len(), 1);
        assert!(config.mcp_servers.contains_key("WebSearch"));
    }
}
