use crate::config::Config;
use anyhow::Result;
use chrono::{DateTime, Utc};
use std::io::Write;
use std::path::Path;

const DAEMON_STALE_SECONDS: i64 = 30;
const SCHEDULER_STALE_SECONDS: i64 = 120;
const CHANNEL_STALE_SECONDS: i64 = 300;
const COMMAND_VERSION_PREVIEW_CHARS: usize = 60;

// ── Diagnostic item ──────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Severity {
    Ok,
    Warn,
    Error,
}

struct DiagItem {
    severity: Severity,
    category: &'static str,
    message: String,
}

impl DiagItem {
    fn ok(category: &'static str, msg: impl Into<String>) -> Self {
        Self {
            severity: Severity::Ok,
            category,
            message: msg.into(),
        }
    }
    fn warn(category: &'static str, msg: impl Into<String>) -> Self {
        Self {
            severity: Severity::Warn,
            category,
            message: msg.into(),
        }
    }
    fn error(category: &'static str, msg: impl Into<String>) -> Self {
        Self {
            severity: Severity::Error,
            category,
            message: msg.into(),
        }
    }

    fn icon(&self) -> &'static str {
        match self.severity {
            Severity::Ok => "✅",
            Severity::Warn => "⚠️ ",
            Severity::Error => "❌",
        }
    }
}

// ── Public entry point ───────────────────────────────────────────

pub fn run(config: &Config) -> Result<()> {
    let mut items: Vec<DiagItem> = Vec::new();

    check_config_semantics(config, &mut items);
    check_workspace(config, &mut items);
    check_daemon_state(config, &mut items);
    check_environment(&mut items);
    check_audio_health(config, &mut items);

    // Print report
    println!("🩺 Corvus Doctor (enhanced)");
    println!();

    let mut current_cat = "";
    for item in &items {
        if item.category != current_cat {
            current_cat = item.category;
            println!("  [{current_cat}]");
        }
        println!("    {} {}", item.icon(), item.message);
    }

    let errors = items
        .iter()
        .filter(|i| i.severity == Severity::Error)
        .count();
    let warns = items
        .iter()
        .filter(|i| i.severity == Severity::Warn)
        .count();
    let oks = items.iter().filter(|i| i.severity == Severity::Ok).count();

    println!();
    println!("  Summary: {oks} ok, {warns} warnings, {errors} errors");

    if errors > 0 {
        println!("  💡 Fix the errors above, then run `corvus doctor` again.");
    }

    Ok(())
}

// ── Config semantic validation ───────────────────────────────────

fn check_config_semantics(config: &Config, items: &mut Vec<DiagItem>) {
    let cat = "config";

    check_config_file(config, cat, items);
    check_provider_config(config, cat, items);
    check_api_key_config(config, cat, items);
    check_model_config(config, cat, items);
    check_temperature_config(config, cat, items);
    check_gateway_port(config, cat, items);
    check_fallback_providers(config, cat, items);
    check_model_routes(config, cat, items);
    check_classification_integrity(config, cat, items);
    check_channels_configured(config, cat, items);
    check_delegate_agents(config, cat, items);
}

fn provider_validation_error(name: &str) -> Option<String> {
    match crate::providers::create_provider(name, None) {
        Ok(_) => None,
        Err(err) => Some(
            err.to_string()
                .lines()
                .next()
                .unwrap_or("invalid provider")
                .into(),
        ),
    }
}

fn check_config_file(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    if config.config_path.exists() {
        items.push(DiagItem::ok(
            cat,
            format!("config file: {}", config.config_path.display()),
        ));
    } else {
        items.push(DiagItem::error(
            cat,
            format!("config file not found: {}", config.config_path.display()),
        ));
    }
}

fn check_provider_config(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    if let Some(ref provider) = config.default_provider {
        if let Some(reason) = provider_validation_error(provider) {
            items.push(DiagItem::error(
                cat,
                format!("default provider \"{provider}\" is invalid: {reason}"),
            ));
        } else {
            items.push(DiagItem::ok(
                cat,
                format!("provider \"{provider}\" is valid"),
            ));
        }
    } else {
        items.push(DiagItem::error(cat, "no default_provider configured"));
    }
}

fn check_api_key_config(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    if config.default_provider.as_deref() != Some("ollama") {
        if config.api_key.is_some() {
            items.push(DiagItem::ok(cat, "API key configured"));
        } else {
            items.push(DiagItem::warn(
                cat,
                "no api_key set (may rely on env vars or provider defaults)",
            ));
        }
    }
}

fn check_model_config(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    if config.default_model.is_some() {
        items.push(DiagItem::ok(
            cat,
            format!(
                "default model: {}",
                config.default_model.as_deref().unwrap_or("?")
            ),
        ));
    } else {
        items.push(DiagItem::warn(cat, "no default_model configured"));
    }
}

fn check_temperature_config(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    if config.default_temperature >= 0.0 && config.default_temperature <= 2.0 {
        items.push(DiagItem::ok(
            cat,
            format!(
                "temperature {:.1} (valid range 0.0–2.0)",
                config.default_temperature
            ),
        ));
    } else {
        items.push(DiagItem::error(
            cat,
            format!(
                "temperature {:.1} is out of range (expected 0.0–2.0)",
                config.default_temperature
            ),
        ));
    }
}

fn check_gateway_port(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    let port = config.gateway.port;
    if port > 0 {
        items.push(DiagItem::ok(cat, format!("gateway port: {port}")));
    } else {
        items.push(DiagItem::error(cat, "gateway port is 0 (invalid)"));
    }
}

fn check_fallback_providers(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    for fb in &config.reliability.fallback_providers {
        if let Some(reason) = provider_validation_error(fb) {
            items.push(DiagItem::warn(
                cat,
                format!("fallback provider \"{fb}\" is invalid: {reason}"),
            ));
        }
    }
}

fn check_model_routes(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    for route in &config.model_routes {
        if route.hint.is_empty() {
            items.push(DiagItem::warn(cat, "model route with empty hint"));
        }
        if let Some(reason) = provider_validation_error(&route.provider) {
            items.push(DiagItem::warn(
                cat,
                format!(
                    "model route \"{}\" uses invalid provider \"{}\": {}",
                    route.hint, route.provider, reason
                ),
            ));
        }
        if route.model.is_empty() {
            items.push(DiagItem::warn(
                cat,
                format!("model route \"{}\" has empty model", route.hint),
            ));
        }
    }
}

fn check_classification_integrity(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    let classification = &config.query_classification;
    if !classification.enabled {
        return;
    }

    if classification.rules.is_empty() {
        items.push(DiagItem::warn(
            cat,
            "query_classification.enabled = true but no rules configured; add rules or disable classification",
        ));
    }

    if config.model_routes.is_empty() {
        items.push(DiagItem::warn(
            cat,
            "query_classification.enabled = true but no model_routes configured; classification hints cannot resolve",
        ));
    }

    let mut route_hints: Vec<&str> = config
        .model_routes
        .iter()
        .map(|route| route.hint.as_str())
        .collect();
    route_hints.sort_unstable();
    let available_hints = if route_hints.is_empty() {
        "none".to_string()
    } else {
        route_hints.join(", ")
    };

    for rule in &classification.rules {
        if rule.hint.is_empty() {
            items.push(DiagItem::warn(
                cat,
                format!(
                    "classification rule has an empty hint — it will never route to a named model_routes entry (available: {available_hints})"
                ),
            ));
        } else if !route_hints.contains(&rule.hint.as_str()) {
            items.push(DiagItem::warn(
                cat,
                format!(
                    "classification rule hint \"{}\" does not match any model_routes entry (available: {})",
                    rule.hint, available_hints
                ),
            ));
        }

        if rule.keywords.is_empty() && rule.patterns.is_empty() {
            items.push(DiagItem::warn(
                cat,
                format!(
                    "classification rule for hint \"{}\" has no keywords or patterns and will never match",
                    rule.hint
                ),
            ));
        }
    }
}

fn check_channels_configured(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    let cc = &config.channels_config;
    let has_channel = cc.telegram.is_some()
        || cc.discord.is_some()
        || cc.slack.is_some()
        || cc.imessage.is_some()
        || cc.matrix.is_some()
        || cc.whatsapp.is_some()
        || cc.email.is_some()
        || cc.irc.is_some()
        || cc.lark.is_some()
        || cc.webhook.is_some();

    if has_channel {
        items.push(DiagItem::ok(cat, "at least one channel configured"));
    } else {
        items.push(DiagItem::warn(
            cat,
            "no channels configured — run `corvus onboard` to set one up",
        ));
    }
}

fn check_delegate_agents(config: &Config, cat: &'static str, items: &mut Vec<DiagItem>) {
    for (name, agent) in &config.agents {
        if let Some(reason) = provider_validation_error(&agent.provider) {
            items.push(DiagItem::warn(
                cat,
                format!(
                    "agent \"{name}\" uses invalid provider \"{}\": {}",
                    agent.provider, reason
                ),
            ));
        }
    }
}

// ── Workspace integrity ──────────────────────────────────────────

fn check_workspace(config: &Config, items: &mut Vec<DiagItem>) {
    let cat = "workspace";
    let ws = &config.workspace_dir;

    if ws.exists() {
        items.push(DiagItem::ok(
            cat,
            format!("directory exists: {}", ws.display()),
        ));
    } else {
        items.push(DiagItem::error(
            cat,
            format!("directory missing: {}", ws.display()),
        ));
        return;
    }

    // Writable check
    let probe = workspace_probe_path(ws);
    match std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&probe)
    {
        Ok(mut probe_file) => {
            let write_result = probe_file.write_all(b"probe");
            drop(probe_file);
            let _ = std::fs::remove_file(&probe);
            match write_result {
                Ok(()) => items.push(DiagItem::ok(cat, "directory is writable")),
                Err(e) => items.push(DiagItem::error(
                    cat,
                    format!("directory write probe failed: {e}"),
                )),
            }
        }
        Err(e) => {
            items.push(DiagItem::error(
                cat,
                format!("directory is not writable: {e}"),
            ));
        }
    }

    // Disk space (best-effort via `df`)
    if let Some(avail_mb) = disk_available_mb(ws) {
        if avail_mb >= 100 {
            items.push(DiagItem::ok(
                cat,
                format!("disk space: {avail_mb} MB available"),
            ));
        } else {
            items.push(DiagItem::warn(
                cat,
                format!("low disk space: only {avail_mb} MB available"),
            ));
        }
    }

    // Key workspace files
    check_file_exists(ws, "SOUL.md", false, cat, items);
    check_file_exists(ws, "AGENTS.md", false, cat, items);
}

fn check_file_exists(
    base: &Path,
    name: &str,
    required: bool,
    cat: &'static str,
    items: &mut Vec<DiagItem>,
) {
    let path = base.join(name);
    if path.is_file() {
        items.push(DiagItem::ok(cat, format!("{name} present")));
    } else if required {
        items.push(DiagItem::error(cat, format!("{name} missing")));
    } else {
        items.push(DiagItem::warn(cat, format!("{name} not found (optional)")));
    }
}

fn disk_available_mb(path: &Path) -> Option<u64> {
    let output = std::process::Command::new("df")
        .arg("-m")
        .arg(path)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    parse_df_available_mb(&stdout)
}

fn parse_df_available_mb(stdout: &str) -> Option<u64> {
    let line = stdout.lines().rev().find(|line| !line.trim().is_empty())?;
    let avail = line.split_whitespace().nth(3)?;
    avail.parse::<u64>().ok()
}

fn workspace_probe_path(workspace_dir: &Path) -> std::path::PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |duration| duration.as_nanos());
    workspace_dir.join(format!(
        ".corvus_doctor_probe_{}_{}",
        std::process::id(),
        nanos
    ))
}

// ── Daemon state (original logic, preserved) ─────────────────────

fn check_daemon_state(config: &Config, items: &mut Vec<DiagItem>) {
    let cat = "daemon";
    let state_file = crate::daemon::state_file_path(config);

    let snapshot = match load_daemon_state(&state_file, cat, items) {
        Some(s) => s,
        None => return,
    };

    check_daemon_heartbeat(&snapshot, cat, items);
    check_daemon_components(&snapshot, cat, items);
}

fn load_daemon_state(
    state_file: &std::path::Path,
    cat: &'static str,
    items: &mut Vec<DiagItem>,
) -> Option<serde_json::Value> {
    if !state_file.exists() {
        items.push(DiagItem::error(
            cat,
            format!(
                "state file not found: {} — is the daemon running?",
                state_file.display()
            ),
        ));
        return None;
    }

    let raw = match std::fs::read_to_string(state_file) {
        Ok(r) => r,
        Err(e) => {
            items.push(DiagItem::error(cat, format!("cannot read state file: {e}")));
            return None;
        }
    };

    match serde_json::from_str::<serde_json::Value>(&raw) {
        Ok(v) => Some(v),
        Err(e) => {
            items.push(DiagItem::error(cat, format!("invalid state JSON: {e}")));
            None
        }
    }
}

fn check_daemon_heartbeat(
    snapshot: &serde_json::Value,
    cat: &'static str,
    items: &mut Vec<DiagItem>,
) {
    let updated_at = snapshot
        .get("updated_at")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("");

    if let Ok(ts) = DateTime::parse_from_rfc3339(updated_at) {
        let age = Utc::now()
            .signed_duration_since(ts.with_timezone(&Utc))
            .num_seconds();
        if age <= DAEMON_STALE_SECONDS {
            items.push(DiagItem::ok(cat, format!("heartbeat fresh ({age}s ago)")));
        } else {
            items.push(DiagItem::error(
                cat,
                format!("heartbeat stale ({age}s ago)"),
            ));
        }
    } else {
        items.push(DiagItem::error(
            cat,
            format!("invalid daemon timestamp: {updated_at}"),
        ));
    }
}

fn check_daemon_components(
    snapshot: &serde_json::Value,
    cat: &'static str,
    items: &mut Vec<DiagItem>,
) {
    let Some(components) = snapshot
        .get("components")
        .and_then(serde_json::Value::as_object)
    else {
        return;
    };

    check_scheduler_component(components, cat, items);
    check_channel_components(components, cat, items);
}

fn check_scheduler_component(
    components: &serde_json::Map<String, serde_json::Value>,
    cat: &'static str,
    items: &mut Vec<DiagItem>,
) {
    if let Some(scheduler) = components.get("scheduler") {
        let scheduler_ok = scheduler
            .get("status")
            .and_then(serde_json::Value::as_str)
            .is_some_and(|s| s == "ok");
        let scheduler_age = scheduler
            .get("last_ok")
            .and_then(serde_json::Value::as_str)
            .and_then(parse_rfc3339)
            .map_or(i64::MAX, |dt| {
                Utc::now().signed_duration_since(dt).num_seconds()
            });

        if scheduler_ok && scheduler_age <= SCHEDULER_STALE_SECONDS {
            items.push(DiagItem::ok(
                cat,
                format!("scheduler healthy (last ok {scheduler_age}s ago)"),
            ));
        } else {
            items.push(DiagItem::error(
                cat,
                format!("scheduler unhealthy (ok={scheduler_ok}, age={scheduler_age}s)"),
            ));
        }
    } else {
        items.push(DiagItem::warn(cat, "scheduler component not tracked yet"));
    }
}

fn check_channel_components(
    components: &serde_json::Map<String, serde_json::Value>,
    cat: &'static str,
    items: &mut Vec<DiagItem>,
) {
    let mut channel_count = 0u32;
    let mut stale = 0u32;
    for (name, component) in components {
        if !name.starts_with("channel:") {
            continue;
        }
        channel_count += 1;
        let status_ok = component
            .get("status")
            .and_then(serde_json::Value::as_str)
            .is_some_and(|s| s == "ok");
        let age = component
            .get("last_ok")
            .and_then(serde_json::Value::as_str)
            .and_then(parse_rfc3339)
            .map_or(i64::MAX, |dt| {
                Utc::now().signed_duration_since(dt).num_seconds()
            });

        if status_ok && age <= CHANNEL_STALE_SECONDS {
            items.push(DiagItem::ok(cat, format!("{name} fresh ({age}s ago)")));
        } else {
            stale += 1;
            items.push(DiagItem::error(
                cat,
                format!("{name} stale (ok={status_ok}, age={age}s)"),
            ));
        }
    }

    if channel_count == 0 {
        items.push(DiagItem::warn(cat, "no channel components tracked yet"));
    } else if stale > 0 {
        items.push(DiagItem::warn(
            cat,
            format!("{channel_count} channels, {stale} stale"),
        ));
    }
}

// ── Environment checks ───────────────────────────────────────────

fn check_environment(items: &mut Vec<DiagItem>) {
    let cat = "environment";

    // git
    check_command_available("git", &["--version"], cat, items);

    // Shell
    let shell = std::env::var("SHELL").unwrap_or_default();
    if shell.is_empty() {
        items.push(DiagItem::warn(cat, "$SHELL not set"));
    } else {
        items.push(DiagItem::ok(cat, format!("shell: {shell}")));
    }

    // HOME
    if std::env::var("HOME").is_ok() || std::env::var("USERPROFILE").is_ok() {
        items.push(DiagItem::ok(cat, "home directory env set"));
    } else {
        items.push(DiagItem::error(
            cat,
            "neither $HOME nor $USERPROFILE is set",
        ));
    }

    // Optional tools
    check_command_available("curl", &["--version"], cat, items);
}

fn check_command_available(cmd: &str, args: &[&str], cat: &'static str, items: &mut Vec<DiagItem>) {
    match std::process::Command::new(cmd)
        .args(args)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .output()
    {
        Ok(output) if output.status.success() => {
            let ver = String::from_utf8_lossy(&output.stdout);
            let first_line = ver.lines().next().unwrap_or("").trim();
            let display = truncate_for_display(first_line, COMMAND_VERSION_PREVIEW_CHARS);
            items.push(DiagItem::ok(cat, format!("{cmd}: {display}")));
        }
        Ok(_) => {
            items.push(DiagItem::warn(
                cat,
                format!("{cmd} found but returned non-zero"),
            ));
        }
        Err(_) => {
            items.push(DiagItem::warn(cat, format!("{cmd} not found in PATH")));
        }
    }
}

fn truncate_for_display(input: &str, max_chars: usize) -> String {
    let mut chars = input.chars();
    let preview: String = chars.by_ref().take(max_chars).collect();
    if chars.next().is_some() {
        format!("{preview}…")
    } else {
        preview
    }
}

// ── Audio health checks (REQ-18) ─────────────────────────────────

fn check_audio_health(config: &Config, items: &mut Vec<DiagItem>) {
    let cat = "audio";
    let ac = &config.audio;

    if !ac.enabled {
        items.push(DiagItem::ok(cat, "audio disabled — skipping checks"));
        return;
    }

    // Check whisper binary
    check_whisper_binary(&ac.whisper_binary, cat, items);

    // Check model file
    let model_path = crate::transcription::whisper_cli::resolve_model_path(&ac.transcription_model);
    if model_path.is_file() {
        items.push(DiagItem::ok(
            cat,
            format!(
                "whisper model '{}' found at {}",
                ac.transcription_model,
                model_path.display()
            ),
        ));
    } else {
        items.push(DiagItem::error(
            cat,
            format!(
                "whisper model '{}' not found at {}",
                ac.transcription_model,
                model_path.display()
            ),
        ));
    }
}

fn check_whisper_binary(binary_path: &str, cat: &'static str, items: &mut Vec<DiagItem>) {
    match std::process::Command::new(binary_path)
        .arg("--help")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
    {
        Ok(_) => {
            items.push(DiagItem::ok(
                cat,
                format!("whisper binary '{binary_path}' found"),
            ));
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            items.push(DiagItem::error(
                cat,
                format!("whisper binary not found at '{binary_path}'"),
            ));
        }
        Err(e) => {
            items.push(DiagItem::error(
                cat,
                format!("whisper binary '{binary_path}' failed: {e}"),
            ));
        }
    }
}

// ── Helpers ──────────────────────────────────────────────────────

fn parse_rfc3339(raw: &str) -> Option<DateTime<Utc>> {
    DateTime::parse_from_rfc3339(raw)
        .ok()
        .map(|dt| dt.with_timezone(&Utc))
}

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

    #[test]
    fn provider_validation_checks_custom_url_shape() {
        assert!(provider_validation_error("openrouter").is_none());
        assert!(provider_validation_error("custom:https://example.com").is_none());
        assert!(provider_validation_error("anthropic-custom:https://example.com").is_none());

        let invalid_custom = provider_validation_error("custom:").unwrap_or_default();
        assert!(invalid_custom.contains("requires a URL"));

        let invalid_unknown = provider_validation_error("totally-fake").unwrap_or_default();
        assert!(invalid_unknown.contains("Unknown provider"));
    }

    #[test]
    fn diag_item_icons() {
        assert_eq!(DiagItem::ok("t", "m").icon(), "✅");
        assert_eq!(DiagItem::warn("t", "m").icon(), "⚠️ ");
        assert_eq!(DiagItem::error("t", "m").icon(), "❌");
    }

    #[test]
    fn config_validation_catches_bad_temperature() {
        let mut config = Config::default();
        config.default_temperature = 5.0;
        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);
        let temp_item = items.iter().find(|i| i.message.contains("temperature"));
        assert!(temp_item.is_some());
        assert_eq!(temp_item.unwrap().severity, Severity::Error);
    }

    #[test]
    fn config_validation_accepts_valid_temperature() {
        let mut config = Config::default();
        config.default_temperature = 0.7;
        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);
        let temp_item = items.iter().find(|i| i.message.contains("temperature"));
        assert!(temp_item.is_some());
        assert_eq!(temp_item.unwrap().severity, Severity::Ok);
    }

    #[test]
    fn config_validation_warns_no_channels() {
        let config = Config::default();
        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);
        let ch_item = items.iter().find(|i| i.message.contains("channel"));
        assert!(ch_item.is_some());
        assert_eq!(ch_item.unwrap().severity, Severity::Warn);
    }

    #[test]
    fn config_validation_catches_unknown_provider() {
        let mut config = Config::default();
        config.default_provider = Some("totally-fake".into());
        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);
        let prov_item = items
            .iter()
            .find(|i| i.message.contains("default provider"));
        assert!(prov_item.is_some());
        assert_eq!(prov_item.unwrap().severity, Severity::Error);
    }

    #[test]
    fn config_validation_catches_malformed_custom_provider() {
        let mut config = Config::default();
        config.default_provider = Some("custom:".into());
        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);

        let prov_item = items.iter().find(|item| {
            item.message
                .contains("default provider \"custom:\" is invalid")
        });
        assert!(prov_item.is_some());
        assert_eq!(prov_item.unwrap().severity, Severity::Error);
    }

    #[test]
    fn config_validation_accepts_custom_provider() {
        let mut config = Config::default();
        config.default_provider = Some("custom:https://my-api.com".into());
        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);
        let prov_item = items.iter().find(|i| i.message.contains("is valid"));
        assert!(prov_item.is_some());
        assert_eq!(prov_item.unwrap().severity, Severity::Ok);
    }

    #[test]
    fn config_validation_warns_bad_fallback() {
        let mut config = Config::default();
        config.reliability.fallback_providers = vec!["fake-provider".into()];
        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);
        let fb_item = items
            .iter()
            .find(|i| i.message.contains("fallback provider"));
        assert!(fb_item.is_some());
        assert_eq!(fb_item.unwrap().severity, Severity::Warn);
    }

    #[test]
    fn config_validation_warns_bad_custom_fallback() {
        let mut config = Config::default();
        config.reliability.fallback_providers = vec!["custom:".into()];
        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);

        let fb_item = items.iter().find(|item| {
            item.message
                .contains("fallback provider \"custom:\" is invalid")
        });
        assert!(fb_item.is_some());
        assert_eq!(fb_item.unwrap().severity, Severity::Warn);
    }

    #[test]
    fn config_validation_warns_empty_model_route() {
        let mut config = Config::default();
        config.model_routes = vec![crate::config::ModelRouteConfig {
            hint: "fast".into(),
            provider: "groq".into(),
            model: String::new(),
            api_key: None,
            allow_image_input: false,
        }];
        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);
        let route_item = items.iter().find(|i| i.message.contains("empty model"));
        assert!(route_item.is_some());
        assert_eq!(route_item.unwrap().severity, Severity::Warn);
    }

    #[test]
    fn classification_integrity_warns_on_orphaned_hint() {
        let mut config = Config::default();
        config.model_routes = vec![
            crate::config::ModelRouteConfig {
                hint: "fast".into(),
                provider: "groq".into(),
                model: "llama-3.3-70b-versatile".into(),
                api_key: None,
                allow_image_input: false,
            },
            crate::config::ModelRouteConfig {
                hint: "reasoning".into(),
                provider: "openai".into(),
                model: "o1-preview".into(),
                api_key: None,
                allow_image_input: false,
            },
        ];
        config.query_classification.enabled = true;
        config.query_classification.rules = vec![crate::config::ClassificationRule {
            hint: "code".into(),
            keywords: vec!["debug".into()],
            patterns: vec![],
            min_length: None,
            max_length: None,
            priority: 10,
        }];

        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);

        let item = items
            .iter()
            .find(|i| i.message.contains("classification rule hint \"code\""));
        assert!(item.is_some());
        assert_eq!(item.unwrap().severity, Severity::Warn);
    }

    #[test]
    fn classification_integrity_warns_when_enabled_without_rules() {
        let mut config = Config::default();
        config.query_classification.enabled = true;

        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);

        let item = items
            .iter()
            .find(|i| i.message.contains("no rules configured"));
        assert!(item.is_some());
        assert_eq!(item.unwrap().severity, Severity::Warn);
    }

    #[test]
    fn classification_integrity_warns_when_enabled_without_routes() {
        let mut config = Config::default();
        config.query_classification.enabled = true;
        config.query_classification.rules = vec![crate::config::ClassificationRule {
            hint: "reasoning".into(),
            keywords: vec!["compare".into()],
            patterns: vec![],
            min_length: None,
            max_length: None,
            priority: 20,
        }];

        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);

        let item = items
            .iter()
            .find(|i| i.message.contains("no model_routes configured"));
        assert!(item.is_some());
        assert_eq!(item.unwrap().severity, Severity::Warn);
    }

    #[test]
    fn classification_integrity_warns_on_never_matching_rule() {
        let mut config = Config::default();
        config.model_routes = vec![crate::config::ModelRouteConfig {
            hint: "code".into(),
            provider: "groq".into(),
            model: "qwen-qwq-32b".into(),
            api_key: None,
            allow_image_input: false,
        }];
        config.query_classification.enabled = true;
        config.query_classification.rules = vec![crate::config::ClassificationRule {
            hint: "code".into(),
            keywords: vec![],
            patterns: vec![],
            min_length: None,
            max_length: None,
            priority: 10,
        }];

        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);

        let item = items
            .iter()
            .find(|i| i.message.contains("will never match"));
        assert!(item.is_some());
        assert_eq!(item.unwrap().severity, Severity::Warn);
    }

    #[test]
    fn classification_integrity_stays_quiet_for_valid_config() {
        let mut config = Config::default();
        config.model_routes = vec![
            crate::config::ModelRouteConfig {
                hint: "fast".into(),
                provider: "groq".into(),
                model: "llama-3.3-70b-versatile".into(),
                api_key: None,
                allow_image_input: false,
            },
            crate::config::ModelRouteConfig {
                hint: "reasoning".into(),
                provider: "openai".into(),
                model: "o1-preview".into(),
                api_key: None,
                allow_image_input: false,
            },
        ];
        config.query_classification.enabled = true;
        config.query_classification.rules = vec![
            crate::config::ClassificationRule {
                hint: "fast".into(),
                keywords: vec!["quick".into()],
                patterns: vec![],
                min_length: None,
                max_length: None,
                priority: 10,
            },
            crate::config::ClassificationRule {
                hint: "reasoning".into(),
                keywords: vec!["compare".into()],
                patterns: vec![],
                min_length: Some(20),
                max_length: None,
                priority: 20,
            },
        ];

        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);

        let classification_warnings: Vec<&DiagItem> = items
            .iter()
            .filter(|item| {
                item.severity == Severity::Warn
                    && (item.message.contains("classification")
                        || item.message.contains("will never match")
                        || item.message.contains("model_routes configured"))
            })
            .collect();
        assert!(classification_warnings.is_empty());
    }

    #[test]
    fn classification_integrity_skips_hint_check_when_disabled() {
        let mut config = Config::default();
        config.model_routes = vec![crate::config::ModelRouteConfig {
            hint: "fast".into(),
            provider: "groq".into(),
            model: "llama-3.3-70b-versatile".into(),
            api_key: None,
            allow_image_input: false,
        }];
        config.query_classification.enabled = false;
        config.query_classification.rules = vec![crate::config::ClassificationRule {
            hint: "missing".into(),
            keywords: vec!["debug".into()],
            patterns: vec![],
            min_length: None,
            max_length: None,
            priority: 1,
        }];

        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);

        assert!(items.iter().all(|item| {
            !item
                .message
                .contains("classification rule hint \"missing\"")
        }));
    }

    #[test]
    fn classification_integrity_allows_patterns_without_keywords() {
        let mut config = Config::default();
        config.model_routes = vec![crate::config::ModelRouteConfig {
            hint: "code".into(),
            provider: "groq".into(),
            model: "qwen-qwq-32b".into(),
            api_key: None,
            allow_image_input: false,
        }];
        config.query_classification.enabled = true;
        config.query_classification.rules = vec![crate::config::ClassificationRule {
            hint: "code".into(),
            keywords: vec![],
            patterns: vec!["fn ".into()],
            min_length: None,
            max_length: None,
            priority: 5,
        }];

        let mut items = Vec::new();
        check_config_semantics(&config, &mut items);

        assert!(items
            .iter()
            .all(|item| !item.message.contains("will never match")));
    }

    #[test]
    fn environment_check_finds_git() {
        let mut items = Vec::new();
        check_environment(&mut items);
        let git_item = items.iter().find(|i| i.message.starts_with("git:"));
        // git should be available in any CI/dev environment
        assert!(git_item.is_some());
        assert_eq!(git_item.unwrap().severity, Severity::Ok);
    }

    #[test]
    fn parse_df_available_mb_uses_last_data_line() {
        let stdout =
            "Filesystem 1M-blocks Used Available Use% Mounted on\n/dev/sda1 1000 500 500 50% /\n";
        assert_eq!(parse_df_available_mb(stdout), Some(500));
    }

    #[test]
    fn truncate_for_display_preserves_utf8_boundaries() {
        let preview = truncate_for_display("版本号-alpha-build", 3);
        assert_eq!(preview, "版本号…");
    }

    #[test]
    fn workspace_probe_path_is_hidden_and_unique() {
        let tmp = TempDir::new().unwrap();
        let first = workspace_probe_path(tmp.path());
        let second = workspace_probe_path(tmp.path());

        assert_ne!(first, second);
        assert!(first
            .file_name()
            .and_then(|name| name.to_str())
            .is_some_and(|name| name.starts_with(".corvus_doctor_probe_")));
    }

    // ── Audio health checks (Task 4.1) ──────────────────────

    #[test]
    fn audio_health_skip_when_disabled() {
        let config = Config::default(); // audio.enabled = false
        let mut items = Vec::new();
        check_audio_health(&config, &mut items);

        assert_eq!(items.len(), 1);
        assert_eq!(items[0].severity, Severity::Ok);
        assert!(items[0].message.contains("disabled"));
    }

    #[test]
    fn audio_health_error_whisper_binary_not_found() {
        let mut config = Config::default();
        config.audio.enabled = true;
        config.audio.allowed_channels = vec!["telegram".into()];
        config.audio.whisper_binary = "/nonexistent/whisper-cli-fake".into();

        let mut items = Vec::new();
        check_audio_health(&config, &mut items);

        let binary_item = items.iter().find(|i| i.message.contains("whisper binary"));
        assert!(binary_item.is_some());
        assert_eq!(binary_item.unwrap().severity, Severity::Error);
        assert!(binary_item.unwrap().message.contains("not found"));
    }

    #[test]
    fn audio_health_error_model_not_found() {
        let mut config = Config::default();
        config.audio.enabled = true;
        config.audio.allowed_channels = vec!["telegram".into()];
        config.audio.transcription_model = "nonexistent-model-xyz".into();
        // Binary check will also fail, but we care about the model check
        config.audio.whisper_binary = "/nonexistent/whisper-cli-fake".into();

        let mut items = Vec::new();
        check_audio_health(&config, &mut items);

        let model_item = items.iter().find(|i| i.message.contains("whisper model"));
        assert!(model_item.is_some());
        assert_eq!(model_item.unwrap().severity, Severity::Error);
        assert!(model_item.unwrap().message.contains("not found"));
        assert!(model_item
            .unwrap()
            .message
            .contains("nonexistent-model-xyz"));
    }

    #[test]
    fn audio_health_pass_model_exists() {
        let tmp = TempDir::new().unwrap();
        let model_dir = tmp.path().join(".corvus/models/whisper");
        std::fs::create_dir_all(&model_dir).unwrap();
        let model_file = model_dir.join("ggml-base.bin");
        std::fs::write(&model_file, b"fake model").unwrap();

        // We can't easily override home dir for resolve_model_path,
        // so test the model check logic directly
        let cat = "audio";
        let mut items = Vec::new();
        if model_file.exists() {
            items.push(DiagItem::ok(
                cat,
                format!("whisper model 'base' found at {}", model_file.display()),
            ));
        } else {
            items.push(DiagItem::error(
                cat,
                format!("whisper model 'base' not found at {}", model_file.display()),
            ));
        }

        assert_eq!(items.len(), 1);
        assert_eq!(items[0].severity, Severity::Ok);
        assert!(items[0].message.contains("found"));
    }
}
