//! Register mcptest as an MCP server in a local client config.
//!
//! This is the inverse of the rest of this module: the discovery walk reads
//! the `mcpServers` map a client maintains, and this writes mcptest itself
//! into that map so an agent (Claude Code, Cursor, an inspector) picks it up as
//! a callable MCP server. The one-line install behind
//! `mcptest mcp-server --install` calls this.
//!
//! Writing is surgical: an existing config keeps every other server it
//! declares, and only the `mcptest` entry is added or replaced. A missing file
//! (and its parent directory) is created.

use std::path::Path;

use serde_json::{json, Map, Value};

use super::{DiscoveryError, ServerConfig};

/// The launch spec to register under the `mcptest` key. Mirrors the
/// [`ServerConfig`] shape every supported client reads.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallSpec {
    /// Executable to launch (an absolute path to the mcptest binary).
    pub command: String,
    /// Arguments, for example `["mcp-server", "--workspace", "."]`.
    pub args: Vec<String>,
}

impl InstallSpec {
    /// Build the standard registration for `mcptest mcp-server`. `binary` is
    /// the path an agent should launch (typically the absolute path to the
    /// current mcptest binary); `enable_writes` adds the `--enable-writes`
    /// flag so the agent gets the write verbs.
    pub fn for_mcp_server(binary: impl Into<String>, enable_writes: bool) -> Self {
        let mut args = vec![
            "mcp-server".to_string(),
            "--workspace".to_string(),
            ".".to_string(),
        ];
        if enable_writes {
            args.push("--enable-writes".to_string());
        }
        Self {
            command: binary.into(),
            args,
        }
    }
}

/// Outcome of an install: whether the `mcptest` entry was newly added or
/// replaced an existing one. Lets the CLI print an honest message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallOutcome {
    /// No `mcptest` entry existed; one was added.
    Added,
    /// An `mcptest` entry already existed and was overwritten.
    Replaced,
}

/// Register `mcptest` in the `mcpServers` map of the config at `path`.
///
/// Preserves every other server in the file. Creates the file and its parent
/// directory when absent. Returns whether the entry was added or replaced.
pub fn register_mcptest(path: &Path, spec: &InstallSpec) -> Result<InstallOutcome, DiscoveryError> {
    let mut root = read_root(path)?;
    let map = root
        .as_object_mut()
        .expect("read_root always returns a JSON object");
    // Most clients key the map `mcpServers`; VS Code uses `servers`. Write into
    // whichever the existing file already uses, defaulting to `mcpServers` for
    // a fresh file (the majority shape).
    let key = if map.contains_key("servers") && !map.contains_key("mcpServers") {
        "servers"
    } else {
        "mcpServers"
    };
    let servers = map
        .entry(key)
        .or_insert_with(|| Value::Object(Map::new()))
        .as_object_mut()
        .ok_or_else(|| DiscoveryError::Parse {
            path: path.to_path_buf(),
            reason: format!("`{key}` is not a JSON object"),
        })?;
    let outcome = if servers.contains_key("mcptest") {
        InstallOutcome::Replaced
    } else {
        InstallOutcome::Added
    };
    servers.insert("mcptest".to_string(), server_config_value(spec));
    write_root(path, &root)?;
    Ok(outcome)
}

/// Read the config file into a JSON object, or an empty object when absent.
fn read_root(path: &Path) -> Result<Value, DiscoveryError> {
    match std::fs::read(path) {
        Ok(bytes) => {
            let value: Value =
                serde_json::from_slice(&bytes).map_err(|err| DiscoveryError::Parse {
                    path: path.to_path_buf(),
                    reason: err.to_string(),
                })?;
            if value.is_object() {
                Ok(value)
            } else {
                Err(DiscoveryError::Parse {
                    path: path.to_path_buf(),
                    reason: "top-level config is not a JSON object".to_string(),
                })
            }
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Value::Object(Map::new())),
        Err(err) => Err(DiscoveryError::Io {
            path: path.to_path_buf(),
            source: err,
        }),
    }
}

/// Write `root` back as pretty JSON, creating the parent directory if needed.
fn write_root(path: &Path, root: &Value) -> Result<(), DiscoveryError> {
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent).map_err(|source| DiscoveryError::Io {
                path: parent.to_path_buf(),
                source,
            })?;
        }
    }
    let mut text = serde_json::to_string_pretty(root).unwrap_or_default();
    text.push('\n');
    std::fs::write(path, text).map_err(|source| DiscoveryError::Io {
        path: path.to_path_buf(),
        source,
    })
}

/// Serialize an [`InstallSpec`] into the `{command, args}` shape, omitting an
/// empty args array so the entry stays compact.
fn server_config_value(spec: &InstallSpec) -> Value {
    if spec.args.is_empty() {
        json!({ "command": spec.command })
    } else {
        json!({ "command": spec.command, "args": spec.args })
    }
}

/// Read the `mcptest` entry back from a config, if present. A convenience over
/// the discovery parser for the install round-trip and the CLI confirmation.
pub fn read_mcptest(path: &Path) -> Option<ServerConfig> {
    super::parse_mcp_servers_file(path, super::ClientSource::ClaudeCode)
        .ok()?
        .into_iter()
        .find(|s| s.name == "mcptest")
        .map(|s| s.server_config)
}

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

    fn spec() -> InstallSpec {
        InstallSpec::for_mcp_server("/usr/local/bin/mcptest", false)
    }

    #[test]
    fn install_writes_an_entry_the_discovery_reader_finds() {
        let dir = tempdir().unwrap();
        let path = dir.path().join(".claude").join("mcp.json");
        let outcome = register_mcptest(&path, &spec()).unwrap();
        assert_eq!(outcome, InstallOutcome::Added);
        // Round-trip: the discovery parser reads back the mcptest server.
        let found = read_mcptest(&path).expect("mcptest registered");
        assert_eq!(found.command, "/usr/local/bin/mcptest");
        assert_eq!(found.args, vec!["mcp-server", "--workspace", "."]);
    }

    #[test]
    fn install_creates_the_parent_directory() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("nested").join("config").join("mcp.json");
        register_mcptest(&path, &spec()).unwrap();
        assert!(path.exists());
    }

    #[test]
    fn install_preserves_existing_servers() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("mcp.json");
        std::fs::write(
            &path,
            r#"{"mcpServers":{"github":{"command":"npx","args":["server-github"]}}}"#,
        )
        .unwrap();
        register_mcptest(&path, &spec()).unwrap();
        // Both the pre-existing github server and the new mcptest entry survive.
        let parsed =
            super::super::parse_mcp_servers_file(&path, super::super::ClientSource::Cursor)
                .unwrap();
        let names: Vec<&str> = parsed.iter().map(|s| s.name.as_str()).collect();
        assert!(names.contains(&"github"));
        assert!(names.contains(&"mcptest"));
    }

    #[test]
    fn re_installing_reports_replaced_and_overwrites() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("mcp.json");
        register_mcptest(&path, &InstallSpec::for_mcp_server("/old/mcptest", false)).unwrap();
        let outcome =
            register_mcptest(&path, &InstallSpec::for_mcp_server("/new/mcptest", true)).unwrap();
        assert_eq!(outcome, InstallOutcome::Replaced);
        let found = read_mcptest(&path).unwrap();
        assert_eq!(found.command, "/new/mcptest");
        // The write spec carried --enable-writes.
        assert!(found.args.contains(&"--enable-writes".to_string()));
    }

    #[test]
    fn install_uses_the_servers_key_when_the_file_already_does() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("mcp.json");
        // VS Code style: a `servers` map, not `mcpServers`.
        std::fs::write(&path, r#"{"servers":{"fs":{"command":"npx"}}}"#).unwrap();
        register_mcptest(&path, &spec()).unwrap();
        let text = std::fs::read_to_string(&path).unwrap();
        let value: Value = serde_json::from_str(&text).unwrap();
        // mcptest landed under the existing `servers` key, not a new one.
        assert!(value["servers"]["mcptest"].is_object());
        assert!(value.get("mcpServers").is_none());
    }

    #[test]
    fn for_mcp_server_adds_enable_writes_only_when_requested() {
        assert!(!InstallSpec::for_mcp_server("m", false)
            .args
            .contains(&"--enable-writes".to_string()));
        assert!(InstallSpec::for_mcp_server("m", true)
            .args
            .contains(&"--enable-writes".to_string()));
    }
}
