//! Host-specific MCP spawn configuration (Cursor, Claude, Codex).
//!
//! McPact servers are host-neutral stdio JSON-RPC binaries; only registration
//! differs per MCP client. See ADR-0022 and docs/HOSTS.md.

use mcpact_manifest::Manifest;
use std::{
    fs,
    path::{Path, PathBuf},
};
use thiserror::Error;

/// Supported MCP host identifiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostId {
    /// Cursor IDE (`mcp.json`).
    Cursor,
    /// Claude Desktop / Claude Code (`claude_desktop_config.json` fragment).
    Claude,
    /// OpenAI Codex CLI (`config.toml` fragment).
    Codex,
}

impl HostId {
    /// Parse comma-separated host names (`cursor`, `claude`, `codex`).
    pub fn parse_list(text: &str) -> Result<Vec<Self>, HostEmitError> {
        let mut hosts = Vec::new();
        for part in text.split(',') {
            let part = part.trim();
            if part.is_empty() {
                continue;
            }
            hosts.push(match part.to_ascii_lowercase().as_str() {
                "cursor" => HostId::Cursor,
                "claude" => HostId::Claude,
                "codex" => HostId::Codex,
                other => {
                    return Err(HostEmitError::Invalid(format!(
                        "unknown host `{other}` (expected cursor, claude, codex)"
                    )));
                }
            });
        }
        if hosts.is_empty() {
            return Err(HostEmitError::Invalid("at least one host required".into()));
        }
        Ok(hosts)
    }

    /// All supported hosts.
    #[must_use]
    pub fn all() -> Vec<Self> {
        vec![HostId::Cursor, HostId::Claude, HostId::Codex]
    }
}

/// Options for emitting host registration files.
#[derive(Debug, Clone)]
pub struct HostEmitOptions {
    /// Generated adapter crate root (MCP server `cwd`).
    pub generated_dir: PathBuf,
    /// Built MCP server binary. Defaults to `{generated_dir}/target/debug/{package.name}`.
    pub server_binary: Option<PathBuf>,
    /// Directory prepended to `PATH` so the wrapped CLI resolves (e.g. fixture binary dir).
    pub cli_path_prefix: Option<PathBuf>,
    /// Which hosts to emit.
    pub hosts: Vec<HostId>,
}

impl HostEmitOptions {
    /// Emit all host configs into `{generated_dir}/.mcpact/hosts/`.
    #[must_use]
    pub fn for_generated_tree(generated_dir: impl Into<PathBuf>) -> Self {
        Self {
            generated_dir: generated_dir.into(),
            server_binary: None,
            cli_path_prefix: None,
            hosts: HostId::all(),
        }
    }
}

/// Host config emission error.
#[derive(Debug, Error)]
pub enum HostEmitError {
    /// I/O failed.
    #[error("host config I/O failed: {0}")]
    Io(#[from] std::io::Error),
    /// Invalid input.
    #[error("{0}")]
    Invalid(String),
    /// JSON serialization failed.
    #[error("host config JSON failed: {0}")]
    Json(#[from] serde_json::Error),
}

/// Resolved paths for a host registration snippet.
#[derive(Debug, Clone)]
pub struct HostCommandConfig {
    /// MCP server key under the host config.
    pub server_name: String,
    /// Command the host should execute.
    pub command: PathBuf,
    /// Command arguments passed to the host.
    pub args: Vec<String>,
    /// Working directory used by the host.
    pub cwd: PathBuf,
    /// Optional PATH override or prefix.
    pub path_env: Option<String>,
}

fn resolve_paths(manifest: &Manifest, opts: &HostEmitOptions) -> HostCommandConfig {
    let server_cwd = opts.generated_dir.clone();
    let default_server = server_cwd.join("target/debug").join(&manifest.package.name);
    let server_binary = opts.server_binary.clone().unwrap_or(default_server);

    // Pre-build generation uses stable relative paths so lockfiles stay deterministic.
    let (server_binary, server_cwd) = if opts.server_binary.is_none() && !server_binary.is_file() {
        (
            PathBuf::from("target/debug").join(&manifest.package.name),
            PathBuf::from("."),
        )
    } else {
        (
            canonicalize_if_exists(&server_binary),
            canonicalize_if_exists(&server_cwd),
        )
    };

    let path_env = build_path_env(manifest, opts.cli_path_prefix.as_ref());

    HostCommandConfig {
        server_name: server_key(manifest),
        command: server_binary,
        args: Vec::new(),
        cwd: server_cwd,
        path_env,
    }
}

fn canonicalize_if_exists(path: &Path) -> PathBuf {
    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}

fn build_path_env(manifest: &Manifest, cli_path_prefix: Option<&PathBuf>) -> Option<String> {
    let mut parts = Vec::new();
    if let Some(prefix) = cli_path_prefix {
        parts.push(canonicalize_if_exists(prefix));
    }
    let binary = Path::new(&manifest.cli.binary);
    if binary.is_absolute() {
        if let Some(parent) = binary.parent() {
            parts.push(parent.to_path_buf());
        }
    }
    if parts.is_empty() {
        return None;
    }
    if let Ok(path) = std::env::var("PATH") {
        parts.extend(std::env::split_paths(&path));
    }
    std::env::join_paths(parts)
        .ok()
        .map(|p| p.to_string_lossy().into_owned())
}

fn server_key(manifest: &Manifest) -> String {
    manifest.package.name.clone()
}

fn render_cursor_json(config: &HostCommandConfig) -> String {
    let mut entry = serde_json::json!({
        "command": normalize_path_sep(&config.command.to_string_lossy()),
        "args": config.args,
        "cwd": normalize_path_sep(&config.cwd.to_string_lossy()),
    });
    if let Some(path_env) = &config.path_env {
        entry["env"] = serde_json::json!({ "PATH": path_env });
    }
    let root = serde_json::json!({ "mcpServers": { config.server_name.clone(): entry } });
    serde_json::to_string_pretty(&root).expect("cursor json")
}

/// Normalize path separators to forward slashes for cross-platform determinism in config files.
fn normalize_path_sep(s: &str) -> String {
    s.replace('\\', "/")
}

fn render_claude_json(config: &HostCommandConfig) -> String {
    // Claude Desktop uses the same top-level `mcpServers` shape as Cursor.
    render_cursor_json(config)
}

fn render_codex_toml(config: &HostCommandConfig) -> String {
    let mut lines = vec![
        "# McPact-generated MCP server registration for OpenAI Codex CLI.".into(),
        "# Merge into ~/.codex/config.toml under [mcp_servers].".into(),
        format!("[mcp_servers.{}]", config.server_name),
        format!(
            "command = {:?}",
            normalize_path_sep(&config.command.to_string_lossy())
        ),
        format!(
            "cwd = {:?}",
            normalize_path_sep(&config.cwd.to_string_lossy())
        ),
    ];
    if !config.args.is_empty() {
        lines.push(format!("args = {:?}", config.args));
    }
    if let Some(path_env) = &config.path_env {
        lines.push(format!("env_PATH = {path_env:?}"));
    }
    lines.join("\n") + "\n"
}

/// Write host registration files under `{out}/.mcpact/hosts/`.
pub fn emit_command_host_configs(
    config: &HostCommandConfig,
    hosts: &[HostId],
    out: impl AsRef<Path>,
) -> Result<(), HostEmitError> {
    let hosts_dir = out.as_ref().join(".mcpact/hosts");
    fs::create_dir_all(&hosts_dir)?;
    for host in hosts {
        let (filename, content) = match host {
            HostId::Cursor => ("cursor.json", render_cursor_json(config)),
            HostId::Claude => ("claude.json", render_claude_json(config)),
            HostId::Codex => ("codex.toml", render_codex_toml(config)),
        };
        fs::write(hosts_dir.join(filename), content)?;
    }
    Ok(())
}

/// Write host registration files under `{out}/.mcpact/hosts/`.
pub fn emit_host_configs(
    manifest: &Manifest,
    opts: &HostEmitOptions,
    out: impl AsRef<Path>,
) -> Result<(), HostEmitError> {
    let config = resolve_paths(manifest, opts);
    emit_command_host_configs(&config, &opts.hosts, out)
}

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

    const MANIFEST: &str = r#"
schema_version = "mcpact.manifest.v1"
[package]
name = "mcp-echo"
description = "echo"
trust = "Reviewed"
[cli]
name = "echo"
binary = "echo"
[[tools]]
name = "echo_text"
description = "echo"
command = []
[tools.args.text]
type = "string"
required = true
position = 1
[tools.policy]
authority = "Observe"
"#;

    #[test]
    fn emits_all_host_files() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let opts = HostEmitOptions::for_generated_tree(dir.path());
        emit_host_configs(&manifest, &opts, dir.path()).unwrap();
        assert!(dir.path().join(".mcpact/hosts/cursor.json").is_file());
        assert!(dir.path().join(".mcpact/hosts/claude.json").is_file());
        assert!(dir.path().join(".mcpact/hosts/codex.toml").is_file());
        let cursor = fs::read_to_string(dir.path().join(".mcpact/hosts/cursor.json")).unwrap();
        assert!(cursor.contains("mcp-echo"));
        assert!(cursor.contains("target/debug/mcp-echo"));
    }

    #[test]
    fn parses_host_list() {
        let hosts = HostId::parse_list("cursor,claude").unwrap();
        assert_eq!(hosts, vec![HostId::Cursor, HostId::Claude]);
    }

    #[test]
    fn command_host_configs_preserve_args() {
        let dir = tempdir().unwrap();
        let config = HostCommandConfig {
            server_name: "mcpact".into(),
            command: PathBuf::from("mcpact"),
            args: vec!["serve".into()],
            cwd: PathBuf::from("."),
            path_env: None,
        };
        emit_command_host_configs(&config, &[HostId::Cursor, HostId::Codex], dir.path()).unwrap();
        let cursor = fs::read_to_string(dir.path().join(".mcpact/hosts/cursor.json")).unwrap();
        let codex = fs::read_to_string(dir.path().join(".mcpact/hosts/codex.toml")).unwrap();
        assert!(cursor.contains("\"serve\""));
        assert!(codex.contains("args = [\"serve\"]"));
    }
}
