use anyhow::Result;
use serde_json::{Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
use toml::Value as TomlValue;

use super::cli::Client;
use super::convert::{
    build_copilot_servers, build_opencode_servers, extract_codex_old_mcp, identity_transform,
    json_server_to_toml,
};
use super::diff::{print_config_path, print_diff};

/// Strategy: how to sync the source MCP servers to one client's global config.
pub trait ClientSync {
    /// Run the full sync flow for this client.
    fn sync(&self, source: &Map<String, Value>, home: &Path) -> Result<()>;
}

/// Resolve a client variant to its concrete sync strategy.
pub fn strategy(client: &Client) -> Box<dyn ClientSync> {
    match client {
        Client::Claude => Box::new(JsonMcpSync::new(
            "claude",
            ".claude.json",
            "mcpServers",
            identity_transform,
        )),
        Client::Codex => Box::new(CodexSync),
        Client::Copilot => Box::new(JsonMcpSync::new(
            "copilot",
            ".copilot/mcp-config.json",
            "mcpServers",
            build_copilot_servers,
        )),
        Client::Gemini => Box::new(JsonMcpSync::new(
            "gemini",
            ".gemini/settings.json",
            "mcpServers",
            identity_transform,
        )),
        Client::Cursor => Box::new(JsonMcpSync::new(
            "cursor",
            ".cursor/mcp.json",
            "mcpServers",
            identity_transform,
        )),
        Client::Opencode => Box::new(JsonMcpSync::new(
            "opencode",
            ".config/opencode/opencode.json",
            "mcp",
            build_opencode_servers,
        )),
        Client::Droid => Box::new(JsonMcpSync::new(
            "droid",
            ".factory/mcp.json",
            "mcpServers",
            identity_transform,
        )),
    }
}

/// Transform applied to source servers before writing them to a client config.
type Transform = fn(&Map<String, Value>) -> Result<Map<String, Value>>;

/// Generic strategy for JSON clients that store servers under the `mcpServers` key.
/// Covers JSON clients; only the path, target key and transform differ.
struct JsonMcpSync {
    name: &'static str,
    rel_path: &'static str,
    target_key: &'static str,
    transform: Transform,
}

impl JsonMcpSync {
    fn new(
        name: &'static str,
        rel_path: &'static str,
        target_key: &'static str,
        transform: Transform,
    ) -> Self {
        Self {
            name,
            rel_path,
            target_key,
            transform,
        }
    }
}

impl ClientSync for JsonMcpSync {
    fn sync(&self, source: &Map<String, Value>, home: &Path) -> Result<()> {
        let path = home.join(self.rel_path);

        // Read existing config (treat a missing file as an empty object).
        let mut root = read_json_or_empty(&path)?;
        let old = root
            .get(self.target_key)
            .and_then(|v| v.as_object())
            .cloned()
            .unwrap_or_default();

        // Transform before diffing so the comparison is apples-to-apples.
        let new = (self.transform)(source)?;
        let has_changes = print_diff(self.name, &old, &new);

        if !has_changes {
            print_config_path(&path);
            println!("Done.\n");
            return Ok(());
        }

        // Replace the target MCP field, preserve all other fields.
        let obj = root
            .as_object_mut()
            .ok_or_else(|| anyhow::anyhow!("{} is not a JSON object", path.display()))?;
        obj.insert(self.target_key.to_string(), Value::Object(new));

        write_json(&path, &root)?;
        print_config_path(&path);
        println!("Done.\n");
        Ok(())
    }
}

/// Strategy for codex, which stores servers under `mcp_servers` in a TOML file.
struct CodexSync;

impl ClientSync for CodexSync {
    fn sync(&self, source: &Map<String, Value>, home: &Path) -> Result<()> {
        let path = home.join(".codex/config.toml");

        // Read existing TOML (treat a missing file as an empty table).
        let mut root: TomlValue = if path.exists() {
            let content = fs::read_to_string(&path)?;
            toml::from_str(&content)?
        } else {
            TomlValue::Table(toml::map::Map::new())
        };

        let old_mcp = extract_codex_old_mcp(&root);

        // Build a string version of the new config for diffing.
        let mut new_for_diff = Map::new();
        for (name, val) in source {
            let json_str = serde_json::to_string(val).unwrap_or_default();
            new_for_diff.insert(name.clone(), Value::String(json_str));
        }

        let has_changes = print_diff("codex", &old_mcp, &new_for_diff);

        if !has_changes {
            print_config_path(&path);
            println!("Done.\n");
            return Ok(());
        }

        // Drop the old mcp_servers section and rebuild it.
        let root_table = root
            .as_table_mut()
            .ok_or_else(|| anyhow::anyhow!("{} is not a TOML table", path.display()))?;
        root_table.remove("mcp_servers");

        let mut mcp_table = toml::map::Map::new();
        for (name, val) in source {
            mcp_table.insert(name.clone(), TomlValue::Table(json_server_to_toml(val)?));
        }
        root_table.insert("mcp_servers".to_string(), TomlValue::Table(mcp_table));

        write_toml(&path, &root)?;
        print_config_path(&path);
        println!("Done.\n");
        Ok(())
    }
}

/// Read a JSON file, returning an empty object when the file does not exist.
fn read_json_or_empty(path: &Path) -> Result<Value> {
    if !path.exists() {
        return Ok(Value::Object(Map::new()));
    }
    let content = fs::read_to_string(path)?;
    Ok(serde_json::from_str(&content)?)
}

/// Write a JSON Value with 2-space pretty formatting, creating parent dirs as needed.
fn write_json(path: &PathBuf, root: &Value) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, serde_json::to_string_pretty(root)?)?;
    Ok(())
}

/// Write a TOML Value, creating parent dirs as needed.
fn write_toml(path: &PathBuf, root: &TomlValue) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, toml::to_string_pretty(root)?)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn read_json_or_empty_returns_empty_object_for_missing_file() {
        let path = Path::new("/nonexistent/path/does/not/exist.json");
        let v = read_json_or_empty(path).unwrap();
        assert!(v.as_object().unwrap().is_empty());
    }

    #[test]
    fn json_sync_writes_custom_target_key_and_preserves_other_fields() {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let home = std::env::temp_dir().join(format!("mcp-sync-opencode-{unique}"));
        let path = home.join(".config/opencode/opencode.json");
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(
            &path,
            serde_json::to_string_pretty(&json!({
                "$schema": "https://opencode.ai/config.json",
                "model": "anthropic/claude-sonnet-4-5",
                "mcp": {
                    "old": { "type": "remote", "url": "https://old.example.com" }
                }
            }))
            .unwrap(),
        )
        .unwrap();

        let mut source = Map::new();
        source.insert(
            "srv".to_string(),
            json!({ "command": "npx", "args": ["x"] }),
        );

        JsonMcpSync::new(
            "opencode",
            ".config/opencode/opencode.json",
            "mcp",
            build_opencode_servers,
        )
        .sync(&source, &home)
        .unwrap();

        let written: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(written.get("model").unwrap(), "anthropic/claude-sonnet-4-5");
        assert!(written.get("mcpServers").is_none());
        assert_eq!(written.pointer("/mcp/srv/type").unwrap(), "local");
        assert_eq!(
            written.pointer("/mcp/srv/command").unwrap(),
            &json!(["npx", "x"])
        );
        assert!(written.pointer("/mcp/old").is_none());

        fs::remove_dir_all(home).unwrap();
    }

    #[test]
    fn codex_sync_reads_full_toml_document_with_top_level_array() {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let home = std::env::temp_dir().join(format!("mcp-sync-codex-{unique}"));
        let path = home.join(".codex/config.toml");
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(
            &path,
            r#"notify = [
    "/tmp/notifier",
    "turn-ended",
]

[features]
goals = true
"#,
        )
        .unwrap();

        let mut source = Map::new();
        source.insert(
            "srv".to_string(),
            json!({ "command": "npx", "args": ["x"] }),
        );

        CodexSync.sync(&source, &home).unwrap();

        let written = fs::read_to_string(&path).unwrap();
        assert!(written.contains("notify = ["));
        assert!(written.contains("[features]"));
        assert!(written.contains("[mcp_servers.srv]"));

        fs::remove_dir_all(home).unwrap();
    }
}
