//! Auto-discovery of MCP servers from local client configs.
//!
//! Hand-configuring server paths in `mcptest.yml` is a wall most users hit
//! in the first five minutes. This module reads the configs that Claude
//! Desktop, Claude Code, Cursor, VS Code, Windsurf, and Codex already
//! maintain on the developer's machine, returning the declared servers in a
//! shape the runner can consume directly. Read-only by design: we never write
//! to a client config.
//!
//! Each submodule owns one client and exposes
//! `discover_servers() -> Result<Vec<DiscoveredServer>, DiscoveryError>`.
//! Missing files are returned as an empty vec rather than an error so a
//! user with only one client installed gets a clean discovery walk.
//! Malformed JSON does surface as an error because that is almost always
//! a real misconfiguration the user wants to see.
//!
//! ## Secrets discipline
//!
//! Client configs frequently embed API tokens in `env` maps. The parser
//! preserves names and values so the runner can launch the subprocess,
//! but the logging surface in `mod.rs` only ever emits the env-var
//! *names*, never their values. Downstream callers that log a
//! [`DiscoveredServer`] must keep the same rule.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use thiserror::Error;

pub mod claude_code;
pub mod claude_desktop;
pub mod codex;
pub mod cursor;
pub mod install;
pub mod vscode;
pub mod windsurf;

/// Which local client a discovered server came from.
///
/// The runner uses this to render provenance in `mcptest servers` output
/// so the user can tell `github` (from Claude Desktop) apart from `github`
/// (from Cursor) when both define a server with the same name.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientSource {
    /// Claude Desktop's `claude_desktop_config.json`.
    ClaudeDesktop,
    /// Claude Code's `~/.claude/config.json`.
    ClaudeCode,
    /// Cursor's `~/.cursor/mcp.json`.
    Cursor,
    /// VS Code's user-profile `mcp.json`.
    VsCode,
    /// Windsurf's `~/.codeium/windsurf/mcp_config.json`.
    Windsurf,
    /// Codex's `~/.codex/mcp.json`.
    Codex,
}

impl ClientSource {
    /// Short human-readable label used in CLI output.
    pub fn label(&self) -> &'static str {
        match self {
            ClientSource::ClaudeDesktop => "claude-desktop",
            ClientSource::ClaudeCode => "claude-code",
            ClientSource::Cursor => "cursor",
            ClientSource::VsCode => "vscode",
            ClientSource::Windsurf => "windsurf",
            ClientSource::Codex => "codex",
        }
    }

    /// The MCP config file `mcptest skill --install --agent <client>` writes
    /// its registration into, the same file the discovery walk reads back.
    /// `None` for clients with no single mcp-config file we register into:
    /// Claude Code and Claude Desktop are served by the skill pack and the
    /// `mcp-server --install` flow instead, and any client resolves to `None`
    /// when the home directory is unset.
    pub fn install_config_path(&self) -> Option<PathBuf> {
        match self {
            ClientSource::Cursor => cursor::config_path(),
            ClientSource::VsCode => vscode::config_path(),
            ClientSource::Windsurf => windsurf::config_path(),
            ClientSource::Codex => codex::config_path(),
            ClientSource::ClaudeCode | ClientSource::ClaudeDesktop => None,
        }
    }
}

/// Minimal launch spec for an MCP subprocess server.
///
/// Mirrors the shape every supported client uses (`command`, `args`,
/// `env`). A `BTreeMap` keeps env iteration deterministic so test
/// snapshots stay stable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerConfig {
    /// Executable to launch (for example `npx` or `/usr/local/bin/node`).
    pub command: String,
    /// Arguments passed to the executable.
    #[serde(default)]
    pub args: Vec<String>,
    /// Environment variables to set on the child process.
    #[serde(default)]
    pub env: BTreeMap<String, String>,
}

/// A single server discovered from a local client config.
///
/// `name` is the key the client used in its `mcpServers` map. The same
/// name may appear from more than one source; the merger in
/// [`discover_all_clients`] preserves that and lets the runner display
/// each with its source.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveredServer {
    /// Server identifier as it appears in the client's `mcpServers` map.
    pub name: String,
    /// Which client this entry came from.
    pub source: ClientSource,
    /// Launch spec the runner hands to its transport layer.
    pub server_config: ServerConfig,
}

/// Discovery-specific error type for the local-client walk.
///
/// Missing files are not errors (see module docs); a returned
/// [`DiscoveryError`] always points to a real problem the user wants to
/// see, such as a malformed JSON file or a config that resolves to a
/// place we cannot read.
#[derive(Debug, Error)]
pub enum DiscoveryError {
    /// Could not determine the home or platform-specific config directory.
    /// Almost always means the runner is in a sandbox without `$HOME`.
    #[error("could not resolve config path for {client}: {reason}")]
    NoConfigPath {
        /// Client label, for example `claude-desktop`.
        client: &'static str,
        /// Underlying reason (missing env var, OS not supported, etc.).
        reason: String,
    },
    /// File read failed for a reason other than `NotFound`.
    #[error("could not read {path}: {source}")]
    Io {
        /// Absolute path the loader attempted to read.
        path: PathBuf,
        /// Underlying I/O error.
        source: std::io::Error,
    },
    /// File parsed as JSON but did not match the expected shape.
    #[error("config at {path} is not valid: {reason}")]
    Parse {
        /// Absolute path that failed to parse.
        path: PathBuf,
        /// Parser message (top-level reason, never includes env values).
        reason: String,
    },
}

/// Walk every supported client and return the union of discovered servers.
///
/// Missing config files are skipped silently. A malformed config file
/// short-circuits the walk for *that* client only; servers from other
/// clients still appear in the result. Returns the empty vec when no
/// client has any server declared (the runner renders the "no servers
/// discovered" message in that case).
pub fn discover_all_clients() -> Vec<DiscoveredServer> {
    let mut out = Vec::new();
    for result in [
        claude_desktop::discover_servers(),
        claude_code::discover_servers(),
        cursor::discover_servers(),
        vscode::discover_servers(),
        windsurf::discover_servers(),
        codex::discover_servers(),
    ] {
        match result {
            Ok(mut servers) => out.append(&mut servers),
            Err(err) => {
                // Per acceptance criteria the walk should not fail when
                // one client's config is broken. We log on the `warn`
                // channel so the user still sees the problem.
                tracing_warn_safe(&err);
            }
        }
    }
    out
}

/// Emit a `tracing::warn` line about a discovery failure.
///
/// Pulled into its own helper so the `discover_all_clients` body stays
/// readable and so we can stay disciplined about never logging env-var
/// values. The [`DiscoveryError`] `Display` impl owns the safe shape.
fn tracing_warn_safe(err: &DiscoveryError) {
    // The `tracing` crate is already a workspace dep through other
    // crates; mcptest-core itself does not depend on it, so we stick to
    // `eprintln!` here to keep the dep graph small. The CLI wraps stderr
    // into its own log pipeline anyway.
    eprintln!("mcptest: discovery warning: {err}");
}

/// Shared JSON shape the supported clients use.
///
/// Most clients (Claude Desktop, Claude Code, Cursor, Windsurf, Codex)
/// serialize their MCP server list under an `mcpServers` map keyed by name;
/// VS Code uses `servers`. We accept both so one deserializer covers every
/// client and each submodule only has to know where its file lives. When a
/// file carries both keys, `mcpServers` entries win on a name collision.
#[derive(Debug, Deserialize)]
struct McpServersDocument {
    #[serde(default, rename = "mcpServers")]
    mcp_servers: BTreeMap<String, ServerConfig>,
    #[serde(default)]
    servers: BTreeMap<String, ServerConfig>,
}

/// Parse the `mcpServers` block out of a config file at `path`.
///
/// Returns an empty vec when the file is absent (lets the walker skip
/// uninstalled clients). Returns a [`DiscoveryError::Parse`] when the
/// file exists but the JSON cannot be decoded.
pub(super) fn parse_mcp_servers_file(
    path: &Path,
    source: ClientSource,
) -> Result<Vec<DiscoveredServer>, DiscoveryError> {
    let bytes = match std::fs::read(path) {
        Ok(b) => b,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(err) => {
            return Err(DiscoveryError::Io {
                path: path.to_path_buf(),
                source: err,
            });
        }
    };
    let doc: McpServersDocument = serde_json::from_slice(&bytes).map_err(|err| {
        // The `serde_json` error message can contain JSON snippets but
        // never the raw env values from a well-formed config (those parse
        // into the BTreeMap and never appear in error text). Still, we
        // keep the message at the top-level reason only.
        DiscoveryError::Parse {
            path: path.to_path_buf(),
            reason: err.to_string(),
        }
    })?;

    // Merge the `servers` alias (VS Code) under the `mcpServers` map, letting
    // an `mcpServers` entry win a name collision.
    let mut merged = doc.servers;
    merged.extend(doc.mcp_servers);
    Ok(merged
        .into_iter()
        .map(|(name, server_config)| DiscoveredServer {
            name,
            source,
            server_config,
        })
        .collect())
}

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

    fn write(body: &str) -> tempfile::NamedTempFile {
        let mut f = tempfile::NamedTempFile::new().expect("temp file");
        f.write_all(body.as_bytes()).expect("write");
        f
    }

    #[test]
    fn parses_the_vscode_servers_alias_and_tags_the_source() {
        // VS Code keys its map `servers`, not `mcpServers`.
        let f = write(r#"{"servers":{"fs":{"command":"npx","args":["server-fs"]}}}"#);
        let found = parse_mcp_servers_file(f.path(), ClientSource::VsCode).expect("parse");
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].name, "fs");
        assert_eq!(found[0].source, ClientSource::VsCode);
        assert_eq!(found[0].server_config.command, "npx");
    }

    #[test]
    fn mcp_servers_wins_a_name_collision_with_servers() {
        let f = write(r#"{"mcpServers":{"x":{"command":"a"}},"servers":{"x":{"command":"b"}}}"#);
        let found = parse_mcp_servers_file(f.path(), ClientSource::VsCode).expect("parse");
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].server_config.command, "a");
    }

    #[test]
    fn a_missing_file_is_an_empty_walk_not_an_error() {
        let found = parse_mcp_servers_file(Path::new("/no/such/config.json"), ClientSource::Codex);
        assert!(found.is_ok());
        assert!(found.unwrap().is_empty());
    }

    #[test]
    fn install_config_path_resolves_for_registerable_clients_only() {
        // Claude Code and Desktop are served by other install flows.
        assert!(ClientSource::ClaudeCode.install_config_path().is_none());
        assert!(ClientSource::ClaudeDesktop.install_config_path().is_none());
        // The home-based clients resolve to a path when HOME is set.
        if std::env::var_os("HOME").is_some() {
            assert!(ClientSource::Codex.install_config_path().is_some());
            assert!(ClientSource::Cursor.install_config_path().is_some());
            assert!(ClientSource::Windsurf.install_config_path().is_some());
        }
    }
}
