//! Corpus tests for the local-client auto-discovery walk.
//!
//! Each test pins one happy path or one failure mode for the three
//! supported clients (Claude Desktop, Claude Code, Cursor). Both HOME and
//! APPDATA are rebound to a tempdir for every test so we never read the
//! developer's real config and the Windows branch (which reads
//! `%APPDATA%\Claude`) is just as deterministic as the Unix branch.

// `std::env::set_var` / `remove_var` became `unsafe` in the 2024 edition's
// stdlib because the runtime cannot prove no other thread is reading.
// Test fixtures serialize on a process-wide mutex (see HomeGuard) so the
// safety contract holds; the workspace `unsafe_code = "warn"` lint
// is suppressed here for that reason.
#![allow(unsafe_code)]

use std::fs;
use std::path::PathBuf;

use mcptest_core::discovery::clients::{
    claude_code, claude_desktop, cursor, discover_all_clients, ClientSource, DiscoveryError,
};

/// Guard that swaps `HOME` (and `APPDATA` on Windows) to a tempdir for the
/// duration of a single test. Restores the originals on drop so parallel
/// tests do not leak state through the global env table.
///
/// Tests still serialize on the same env vars; we run them with a mutex
/// to avoid races between threads inside one process.
struct HomeGuard {
    _tmp: tempfile::TempDir,
    home: PathBuf,
    appdata: PathBuf,
    prev_home: Option<std::ffi::OsString>,
    prev_appdata: Option<std::ffi::OsString>,
    _lock: std::sync::MutexGuard<'static, ()>,
}

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

impl HomeGuard {
    fn new() -> Self {
        let lock = env_lock()
            .lock()
            .unwrap_or_else(|poison| poison.into_inner());
        let tmp = tempfile::tempdir().unwrap();
        let home = tmp.path().to_path_buf();
        // Windows clients resolve their config under %APPDATA%, not $HOME, so
        // bind APPDATA to its own tempdir subtree rather than removing it.
        let appdata = tmp.path().join("AppData").join("Roaming");
        let prev_home = std::env::var_os("HOME");
        let prev_appdata = std::env::var_os("APPDATA");
        // SAFETY: tests serialize on `env_lock`; the guard restores on
        // drop. `set_var` is sound when no other thread is reading env.
        unsafe {
            std::env::set_var("HOME", &home);
            std::env::set_var("APPDATA", &appdata);
        }
        HomeGuard {
            _tmp: tmp,
            home,
            appdata,
            prev_home,
            prev_appdata,
            _lock: lock,
        }
    }

    /// Write a config under $HOME (the base for Claude Code and Cursor on
    /// every platform, and Claude Desktop on Unix).
    fn write(&self, rel: &str, body: &str) -> PathBuf {
        write_to(&self.home, rel, body)
    }

    /// Write Claude Desktop's `claude_desktop_config.json` to the base the
    /// discovery walk reads for the current platform: %APPDATA%\Claude on
    /// Windows, ~/Library/Application Support/Claude on macOS, and
    /// ~/.config/Claude on Linux.
    fn write_claude_desktop(&self, body: &str) -> PathBuf {
        if cfg!(target_os = "windows") {
            write_to(&self.appdata, "Claude/claude_desktop_config.json", body)
        } else if cfg!(target_os = "macos") {
            write_to(
                &self.home,
                "Library/Application Support/Claude/claude_desktop_config.json",
                body,
            )
        } else {
            write_to(
                &self.home,
                ".config/Claude/claude_desktop_config.json",
                body,
            )
        }
    }
}

fn write_to(base: &std::path::Path, rel: &str, body: &str) -> PathBuf {
    let path = base.join(rel);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).unwrap();
    }
    fs::write(&path, body).unwrap();
    path
}

impl Drop for HomeGuard {
    fn drop(&mut self) {
        // SAFETY: same lock as `new`.
        unsafe {
            match &self.prev_home {
                Some(v) => std::env::set_var("HOME", v),
                None => std::env::remove_var("HOME"),
            }
            match &self.prev_appdata {
                Some(v) => std::env::set_var("APPDATA", v),
                None => std::env::remove_var("APPDATA"),
            }
        }
    }
}

#[test]
fn claude_desktop_returns_declared_servers() {
    let guard = HomeGuard::new();
    guard.write_claude_desktop(
        r#"{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_secret" }
    },
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
    }
  }
}"#,
    );

    let servers = claude_desktop::discover_servers().unwrap();
    assert_eq!(servers.len(), 2);
    let github = servers.iter().find(|s| s.name == "github").unwrap();
    assert_eq!(github.source, ClientSource::ClaudeDesktop);
    assert_eq!(github.server_config.command, "npx");
    assert_eq!(github.server_config.args.len(), 2);
    assert_eq!(
        github
            .server_config
            .env
            .get("GITHUB_PERSONAL_ACCESS_TOKEN")
            .map(String::as_str),
        Some("ghp_secret"),
        "env value is preserved for the runner; logging is the caller's job"
    );
    drop(guard);
}

#[test]
fn claude_code_returns_declared_servers() {
    let guard = HomeGuard::new();
    guard.write(
        ".claude/config.json",
        r#"{
  "mcpServers": {
    "linear": {
      "command": "node",
      "args": ["linear.js"]
    }
  }
}"#,
    );

    let servers = claude_code::discover_servers().unwrap();
    assert_eq!(servers.len(), 1);
    assert_eq!(servers[0].name, "linear");
    assert_eq!(servers[0].source, ClientSource::ClaudeCode);
    assert!(servers[0].server_config.env.is_empty());
    drop(guard);
}

#[test]
fn cursor_returns_declared_servers() {
    let guard = HomeGuard::new();
    guard.write(
        ".cursor/mcp.json",
        r#"{
  "mcpServers": {
    "supabase": {
      "command": "uv",
      "args": ["run", "supabase-mcp"]
    }
  }
}"#,
    );

    let servers = cursor::discover_servers().unwrap();
    assert_eq!(servers.len(), 1);
    assert_eq!(servers[0].source, ClientSource::Cursor);
    drop(guard);
}

#[test]
fn missing_config_files_are_skipped_without_error() {
    let guard = HomeGuard::new();
    assert!(claude_desktop::discover_servers().unwrap().is_empty());
    assert!(claude_code::discover_servers().unwrap().is_empty());
    assert!(cursor::discover_servers().unwrap().is_empty());
    drop(guard);
}

#[test]
fn malformed_json_returns_a_clear_parse_error() {
    let guard = HomeGuard::new();
    guard.write(".claude/config.json", "{ not valid json");

    let err = claude_code::discover_servers().unwrap_err();
    match err {
        DiscoveryError::Parse { path, reason } => {
            assert!(path.ends_with(".claude/config.json"));
            assert!(!reason.is_empty(), "reason should describe the parse miss");
        }
        other => panic!("expected Parse error, got {other:?}"),
    }
    drop(guard);
}

#[test]
fn discover_all_clients_merges_results_across_sources() {
    let guard = HomeGuard::new();
    guard.write_claude_desktop(r#"{ "mcpServers": { "alpha": { "command": "a" } } }"#);
    guard.write(
        ".claude/config.json",
        r#"{ "mcpServers": { "beta": { "command": "b" } } }"#,
    );
    guard.write(
        ".cursor/mcp.json",
        r#"{ "mcpServers": { "gamma": { "command": "g" } } }"#,
    );

    let servers = discover_all_clients();
    assert_eq!(servers.len(), 3);
    let by_name: std::collections::BTreeMap<_, _> = servers
        .iter()
        .map(|s| (s.name.as_str(), s.source))
        .collect();
    assert_eq!(by_name.get("alpha"), Some(&ClientSource::ClaudeDesktop));
    assert_eq!(by_name.get("beta"), Some(&ClientSource::ClaudeCode));
    assert_eq!(by_name.get("gamma"), Some(&ClientSource::Cursor));
    drop(guard);
}

#[test]
fn discover_all_clients_returns_empty_when_nothing_is_configured() {
    let guard = HomeGuard::new();
    assert!(discover_all_clients().is_empty());
    drop(guard);
}

#[test]
fn empty_mcp_servers_block_yields_empty_list() {
    let guard = HomeGuard::new();
    guard.write(".claude/config.json", r#"{ "mcpServers": {} }"#);
    let servers = claude_code::discover_servers().unwrap();
    assert!(servers.is_empty());
    drop(guard);
}

#[test]
fn config_without_mcp_servers_key_yields_empty_list() {
    // Claude Code's config has many other top-level fields; we should
    // tolerate them and just look for `mcpServers`.
    let guard = HomeGuard::new();
    guard.write(
        ".claude/config.json",
        r#"{ "theme": "dark", "model": "claude-opus-4" }"#,
    );
    let servers = claude_code::discover_servers().unwrap();
    assert!(servers.is_empty());
    drop(guard);
}
