// `std::env::set_var` / `remove_var` became `unsafe` in the 2024 edition's
// stdlib. The HomeGuard mutex serializes env access across test threads
// so the safety contract holds; the workspace `unsafe_code = "warn"`
// lint is suppressed here for that reason.
#![allow(unsafe_code)]

//! Cross-platform corpus for auto-discovery.
//!
//! This file extends `tests/auto_discover_corpus.rs` with the harder cases
//! that only show up on real filesystems: per-OS path resolution, empty
//! and missing `mcpServers` blocks, multi-client merge ordering, hostile
//! filenames, and permission errors. The goal is to fence regressions
//! that the unit tests next to the discovery clients cannot catch
//! because they sidestep the host filesystem.
//!
//! Conventions:
//!
//! - Every test grabs a [`HomeGuard`] so `HOME` (and `APPDATA` on
//!   Windows) point at a tempdir for the duration of the test. The
//!   guard serializes on a process-wide mutex so parallel test threads
//!   never trample each other's env.
//! - Platform-specific assertions live behind `#[cfg(target_os = ...)]`
//!   so the suite runs on macOS, Linux, and Windows without skipping
//!   tests on the active OS.
//! - No network access: discovery never makes a network call, so the
//!   corpus does not need fixtures.
//!
//! The doctor-side corpus for this ticket lives in
//! `crates/mcptest/tests/doctor_corpus.rs` since `mcptest doctor`
//! depends on the binary crate's transport plumbing.

use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs;
use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard, OnceLock};

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

/// Process-wide lock so two test threads cannot race on `HOME`/`APPDATA`.
///
/// The discovery clients read environment variables directly, and Rust's
/// `std::env` table is global. Without this lock, parallel tests would
/// observe each other's tempdirs and produce flaky results.
fn env_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

/// Test guard that points `HOME` (and `APPDATA` on Windows) at a fresh
/// tempdir, then restores the previous values on drop.
///
/// Mirrors the helper in `auto_discover_corpus.rs` so both corpora stay
/// in lockstep. We re-declare it here rather than expose it from `src/`
/// because production code has no reason to swap env vars.
struct HomeGuard {
    _tmp: tempfile::TempDir,
    home: PathBuf,
    prev_home: Option<OsString>,
    prev_appdata: Option<OsString>,
    _lock: MutexGuard<'static, ()>,
}

impl HomeGuard {
    /// Acquire the env lock, allocate a tempdir, and bind it to `HOME`.
    ///
    /// On Windows we also bind `APPDATA` to the same tempdir so the
    /// Claude Desktop loader has a deterministic root. On Unix we unset
    /// `APPDATA` so the Windows branch never fires accidentally if
    /// someone runs the macOS/Linux tests on a hybrid host.
    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();
        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.
        unsafe {
            std::env::set_var("HOME", &home);
            if cfg!(target_os = "windows") {
                std::env::set_var("APPDATA", &home);
            } else {
                std::env::remove_var("APPDATA");
            }
        }
        HomeGuard {
            _tmp: tmp,
            home,
            prev_home,
            prev_appdata,
            _lock: lock,
        }
    }

    /// Write `body` to `<home>/rel`, creating parent directories as needed.
    fn write(&self, rel: &str, body: &str) -> PathBuf {
        let path = self.home.join(rel);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(&path, body).unwrap();
        path
    }

    /// Return the absolute path the loader would consult for Claude
    /// Desktop on the current OS. Used by the per-OS path-resolution
    /// tests to assert that the file we wrote is the one the loader read.
    fn claude_desktop_path(&self) -> PathBuf {
        if cfg!(target_os = "macos") {
            self.home
                .join("Library")
                .join("Application Support")
                .join("Claude")
                .join("claude_desktop_config.json")
        } else if cfg!(target_os = "windows") {
            self.home.join("Claude").join("claude_desktop_config.json")
        } else {
            self.home
                .join(".config")
                .join("Claude")
                .join("claude_desktop_config.json")
        }
    }
}

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"),
            }
        }
    }
}

/// Relative path inside the tempdir for Claude Desktop on the current OS.
///
/// Tests use this to drop a fixture in the right spot for the OS they
/// happen to be running on. The triple is intentionally narrow: each OS
/// gets exactly one supported path per AGENTS.md / .
fn claude_desktop_rel() -> &'static str {
    if cfg!(target_os = "macos") {
        "Library/Application Support/Claude/claude_desktop_config.json"
    } else if cfg!(target_os = "windows") {
        "Claude/claude_desktop_config.json"
    } else {
        ".config/Claude/claude_desktop_config.json"
    }
}

// ---------------------------------------------------------------------------
// Happy-path per client
// ---------------------------------------------------------------------------

#[test]
fn claude_desktop_happy_path_reads_servers_from_canonical_location() {
    let guard = HomeGuard::new();
    let written = guard.write(
        claude_desktop_rel(),
        r#"{ "mcpServers": { "alpha": { "command": "alpha-bin" } } }"#,
    );
    assert_eq!(
        written,
        guard.claude_desktop_path(),
        "fixture path drift means the loader and the test disagree"
    );

    let servers = claude_desktop::discover_servers().unwrap();
    assert_eq!(servers.len(), 1);
    assert_eq!(servers[0].name, "alpha");
    assert_eq!(servers[0].source, ClientSource::ClaudeDesktop);
    assert_eq!(servers[0].server_config.command, "alpha-bin");
    drop(guard);
}

#[test]
fn claude_code_happy_path_reads_dot_claude_config_json() {
    let guard = HomeGuard::new();
    guard.write(
        ".claude/config.json",
        r#"{ "mcpServers": { "beta": { "command": "beta-bin", "args": ["--flag"] } } }"#,
    );

    let servers = claude_code::discover_servers().unwrap();
    assert_eq!(servers.len(), 1);
    assert_eq!(servers[0].name, "beta");
    assert_eq!(servers[0].source, ClientSource::ClaudeCode);
    assert_eq!(servers[0].server_config.args, vec!["--flag".to_string()]);
    drop(guard);
}

#[test]
fn cursor_happy_path_reads_dot_cursor_mcp_json() {
    let guard = HomeGuard::new();
    guard.write(
        ".cursor/mcp.json",
        r#"{ "mcpServers": { "gamma": { "command": "gamma-bin" } } }"#,
    );

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

// ---------------------------------------------------------------------------
// Missing-config graceful behavior
// ---------------------------------------------------------------------------

#[test]
fn claude_desktop_returns_empty_when_config_is_missing() {
    let guard = HomeGuard::new();
    let servers = claude_desktop::discover_servers().unwrap();
    assert!(
        servers.is_empty(),
        "missing config should not surface as an error"
    );
    drop(guard);
}

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

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

// ---------------------------------------------------------------------------
// Malformed JSON surfaces a typed Parse error per client
// ---------------------------------------------------------------------------

#[test]
fn claude_desktop_malformed_json_returns_parse_error_with_path() {
    let guard = HomeGuard::new();
    let path = guard.write(claude_desktop_rel(), "{ \"mcpServers\": ");

    let err = claude_desktop::discover_servers().unwrap_err();
    match err {
        DiscoveryError::Parse {
            path: reported,
            reason,
        } => {
            assert_eq!(reported, path);
            assert!(!reason.is_empty(), "parse error must carry a reason");
        }
        other => panic!("expected Parse, got {other:?}"),
    }
    drop(guard);
}

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

    let err = claude_code::discover_servers().unwrap_err();
    match err {
        DiscoveryError::Parse {
            path: reported,
            reason,
        } => {
            assert_eq!(reported, path);
            assert!(!reason.is_empty());
        }
        other => panic!("expected Parse, got {other:?}"),
    }
    drop(guard);
}

#[test]
fn cursor_malformed_json_returns_parse_error_with_path() {
    let guard = HomeGuard::new();
    let path = guard.write(".cursor/mcp.json", "}{");

    let err = cursor::discover_servers().unwrap_err();
    match err {
        DiscoveryError::Parse {
            path: reported,
            reason,
        } => {
            assert_eq!(reported, path);
            assert!(!reason.is_empty());
        }
        other => panic!("expected Parse, got {other:?}"),
    }
    drop(guard);
}

// ---------------------------------------------------------------------------
// Per-OS path resolution. Each test runs only on the OS whose path it
// exercises; the others are gated out at compile time. Tests on all three
// OSes co-exist in this file so a developer reading the corpus can see the
// supported path matrix in one place.
// ---------------------------------------------------------------------------

#[cfg(target_os = "macos")]
#[test]
fn macos_claude_desktop_resolves_under_library_application_support() {
    let guard = HomeGuard::new();
    let expected = guard
        .home
        .join("Library")
        .join("Application Support")
        .join("Claude")
        .join("claude_desktop_config.json");
    let written = guard.write(
        "Library/Application Support/Claude/claude_desktop_config.json",
        r#"{ "mcpServers": { "mac": { "command": "m" } } }"#,
    );
    assert_eq!(written, expected);

    let servers = claude_desktop::discover_servers().unwrap();
    assert_eq!(servers.len(), 1);
    assert_eq!(servers[0].name, "mac");
    drop(guard);
}

#[cfg(target_os = "linux")]
#[test]
fn linux_claude_desktop_resolves_under_dot_config() {
    let guard = HomeGuard::new();
    let expected = guard
        .home
        .join(".config")
        .join("Claude")
        .join("claude_desktop_config.json");
    let written = guard.write(
        ".config/Claude/claude_desktop_config.json",
        r#"{ "mcpServers": { "linux": { "command": "l" } } }"#,
    );
    assert_eq!(written, expected);

    let servers = claude_desktop::discover_servers().unwrap();
    assert_eq!(servers.len(), 1);
    assert_eq!(servers[0].name, "linux");
    drop(guard);
}

#[cfg(target_os = "windows")]
#[test]
fn windows_claude_desktop_resolves_under_appdata() {
    let guard = HomeGuard::new();
    // The HomeGuard binds APPDATA to the tempdir on Windows, so the
    // expected location is `<APPDATA>/Claude/claude_desktop_config.json`.
    let expected = guard.home.join("Claude").join("claude_desktop_config.json");
    let written = guard.write(
        "Claude/claude_desktop_config.json",
        r#"{ "mcpServers": { "windows": { "command": "w" } } }"#,
    );
    assert_eq!(written, expected);

    let servers = claude_desktop::discover_servers().unwrap();
    assert_eq!(servers.len(), 1);
    assert_eq!(servers[0].name, "windows");
    drop(guard);
}

// ---------------------------------------------------------------------------
// Edge cases inside the JSON document
// ---------------------------------------------------------------------------

#[test]
fn empty_mcp_servers_block_yields_zero_servers_for_every_client() {
    let guard = HomeGuard::new();
    guard.write(claude_desktop_rel(), r#"{ "mcpServers": {} }"#);
    guard.write(".claude/config.json", r#"{ "mcpServers": {} }"#);
    guard.write(".cursor/mcp.json", r#"{ "mcpServers": {} }"#);

    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 valid_json_without_mcp_servers_key_yields_zero_servers() {
    // Claude Code in particular ships configs with many siblings of
    // `mcpServers` (theme, model, ...). The loader must tolerate them.
    let guard = HomeGuard::new();
    guard.write(
        ".claude/config.json",
        r#"{ "theme": "dark", "model": "claude-opus-4", "telemetry": false }"#,
    );
    guard.write(
        claude_desktop_rel(),
        r#"{ "schemaVersion": 1, "notes": "no servers configured" }"#,
    );

    assert!(claude_code::discover_servers().unwrap().is_empty());
    assert!(claude_desktop::discover_servers().unwrap().is_empty());
    drop(guard);
}

#[test]
fn empty_file_is_treated_as_a_parse_error_not_an_empty_list() {
    // An empty file is almost always a sign of a botched write; surface
    // it loudly so the user investigates rather than wonders why no
    // servers show up.
    let guard = HomeGuard::new();
    guard.write(".cursor/mcp.json", "");

    let err = cursor::discover_servers().unwrap_err();
    assert!(
        matches!(err, DiscoveryError::Parse { .. }),
        "expected Parse for empty file, got {err:?}"
    );
    drop(guard);
}

// ---------------------------------------------------------------------------
// Cross-client merge ordering
// ---------------------------------------------------------------------------

#[test]
fn two_clients_configured_merge_in_stable_order() {
    let guard = HomeGuard::new();
    guard.write(
        claude_desktop_rel(),
        r#"{ "mcpServers": { "shared": { "command": "from-desktop" } } }"#,
    );
    guard.write(
        ".cursor/mcp.json",
        r#"{ "mcpServers": { "shared": { "command": "from-cursor" } } }"#,
    );

    // Stable across runs: the walker always visits desktop, then code,
    // then cursor.
    for _ in 0..3 {
        let servers = discover_all_clients();
        let sources: Vec<_> = servers.iter().map(|s| s.source).collect();
        assert_eq!(
            sources,
            vec![ClientSource::ClaudeDesktop, ClientSource::Cursor],
            "merge order must be deterministic across calls"
        );
    }
    drop(guard);
}

#[test]
fn all_three_clients_configured_produce_all_three_entries() {
    let guard = HomeGuard::new();
    guard.write(
        claude_desktop_rel(),
        r#"{ "mcpServers": { "a": { "command": "x" } } }"#,
    );
    guard.write(
        ".claude/config.json",
        r#"{ "mcpServers": { "b": { "command": "y" } } }"#,
    );
    guard.write(
        ".cursor/mcp.json",
        r#"{ "mcpServers": { "c": { "command": "z" } } }"#,
    );

    let servers = discover_all_clients();
    let by_source: BTreeMap<_, _> = servers
        .iter()
        .map(|s| (s.source.label(), s.name.clone()))
        .collect();
    assert_eq!(
        by_source.get("claude-desktop").map(String::as_str),
        Some("a")
    );
    assert_eq!(by_source.get("claude-code").map(String::as_str), Some("b"));
    assert_eq!(by_source.get("cursor").map(String::as_str), Some("c"));
    drop(guard);
}

#[test]
fn merge_does_not_short_circuit_when_one_client_has_malformed_json() {
    // The walker logs and skips a broken client config so a single
    // bad file does not lose data from the other two.
    let guard = HomeGuard::new();
    guard.write(claude_desktop_rel(), "{ this is not json");
    guard.write(
        ".claude/config.json",
        r#"{ "mcpServers": { "ok": { "command": "ok" } } }"#,
    );

    let servers = discover_all_clients();
    let names: Vec<_> = servers.iter().map(|s| s.name.as_str()).collect();
    assert_eq!(names, vec!["ok"]);
    drop(guard);
}

// ---------------------------------------------------------------------------
// Hostile filenames inside the env map
// ---------------------------------------------------------------------------

#[test]
fn hostile_server_names_round_trip_through_the_loader() {
    // The discovery walker treats server names as opaque BTreeMap keys.
    // None of these should panic, fail to load, or get silently
    // renamed. Downstream callers are responsible for quoting them when
    // they hit the shell.
    let guard = HomeGuard::new();
    let very_long = "x".repeat(250);
    let body = format!(
        r#"{{
            "mcpServers": {{
                "server with spaces": {{ "command": "ws" }},
                "name-with-\"quotes\"": {{ "command": "q" }},
                "サーバー": {{ "command": "u" }},
                "{very_long}": {{ "command": "l" }}
            }}
        }}"#
    );
    guard.write(".cursor/mcp.json", &body);

    let servers = cursor::discover_servers().unwrap();
    assert_eq!(servers.len(), 4);
    let names: std::collections::BTreeSet<_> = servers.iter().map(|s| s.name.clone()).collect();
    assert!(names.contains("server with spaces"));
    assert!(names.contains("name-with-\"quotes\""));
    assert!(names.contains("サーバー"));
    assert!(names.contains(&very_long));
    drop(guard);
}

#[test]
fn unicode_paths_in_args_and_env_round_trip_unchanged() {
    let guard = HomeGuard::new();
    guard.write(
        ".cursor/mcp.json",
        r#"{
            "mcpServers": {
                "unicode": {
                    "command": "/usr/local/bin/サーバー",
                    "args": ["--path", "/tmp/データ/file.json"],
                    "env": { "GREETING": "こんにちは" }
                }
            }
        }"#,
    );

    let servers = cursor::discover_servers().unwrap();
    assert_eq!(servers.len(), 1);
    let s = &servers[0];
    assert_eq!(s.server_config.command, "/usr/local/bin/サーバー");
    assert_eq!(s.server_config.args[1], "/tmp/データ/file.json");
    assert_eq!(
        s.server_config.env.get("GREETING").map(String::as_str),
        Some("こんにちは")
    );
    drop(guard);
}

// ---------------------------------------------------------------------------
// Permissions: a config file we cannot read surfaces as a typed Io error.
// Skipped on Windows because Unix mode bits do not apply there.
// ---------------------------------------------------------------------------

#[cfg(unix)]
#[test]
fn unreadable_config_surfaces_io_error_not_panic() {
    use std::os::unix::fs::PermissionsExt;

    // Root sidesteps mode 000, so the test only makes sense for a
    // non-root UID. On the rare CI runner that executes as root, skip
    // the assertion (root can read anything).
    if is_running_as_root() {
        return;
    }

    let guard = HomeGuard::new();
    let path = guard.write(
        ".cursor/mcp.json",
        r#"{ "mcpServers": { "x": { "command": "x" } } }"#,
    );
    fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();

    let err = cursor::discover_servers().unwrap_err();
    match err {
        DiscoveryError::Io {
            path: reported,
            source,
        } => {
            assert_eq!(reported, path);
            assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
        }
        other => panic!("expected Io PermissionDenied, got {other:?}"),
    }

    // Restore permissions so the tempdir can clean itself up.
    fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
    drop(guard);
}

#[cfg(unix)]
fn is_running_as_root() -> bool {
    // `geteuid` is not in std; we shell out to `id -u` so we do not pull
    // a new dep just for one branch. Best-effort: if the call fails we
    // assume non-root so the test still runs.
    std::process::Command::new("id")
        .arg("-u")
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim() == "0")
        .unwrap_or(false)
}
