//! Agent discovery from Zed settings and binary resolution.
//!
//! Reads Zed's `settings.json` (which may contain JSONC comments), extracts
//! `agent_servers` configuration, and resolves downloaded binary paths from
//! the data directory.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// Agent server type as defined in Zed settings.
///
/// Defaults to `Registry` when the `"type"` field is omitted in settings.json,
/// matching Zed's own behavior where omitting type means it's a registry agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum AgentServerType {
    #[default]
    Registry,
    Custom,
    Extension,
}

impl std::fmt::Display for AgentServerType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AgentServerType::Registry => f.write_str("registry"),
            AgentServerType::Custom => f.write_str("custom"),
            AgentServerType::Extension => f.write_str("extension"),
        }
    }
}

/// A discovered Zed agent with its configuration and resolved binary path.
#[derive(Debug, Clone)]
pub struct ZedAgent {
    /// Agent name (key in `agent_servers` map).
    pub name: String,
    /// Server type: registry, custom, or extension.
    pub agent_type: AgentServerType,
    /// Resolved binary path (populated by `resolve_binary_paths`).
    pub binary_path: Option<PathBuf>,
    /// Environment variable overrides from settings.
    pub env_overrides: HashMap<String, String>,
    /// Display name from the ACP registry (e.g. "Claude Agent", "Codex CLI").
    pub display_name: Option<String>,
    /// Description from the ACP registry.
    pub description: Option<String>,
    /// Command arguments from the registry distribution config (e.g. `["--acp"]`).
    pub args: Vec<String>,
    /// Path to a locally cached icon file (SVG) from the registry.
    pub icon_local_path: Option<PathBuf>,
    /// Icon URL from the registry CDN.
    pub icon_url: Option<String>,
}

/// Raw serde model for an entry in the `agent_servers` map in settings.json.
#[derive(Debug, Deserialize)]
struct AgentServerEntry {
    #[serde(rename = "type", default)]
    server_type: AgentServerType,
    #[serde(default)]
    env: HashMap<String, String>,
    // We ignore other fields like default_mode, default_model, command, args, etc.
    // for now; we only need type and env for discovery.
}

/// Raw serde model for the top-level settings.json (only the fields we care about).
#[derive(Debug, Deserialize)]
struct ZedSettings {
    #[serde(default)]
    agent_servers: HashMap<String, AgentServerEntry>,
}

/// Discover agents from Zed's `settings.json` in the given config directory.
///
/// Reads `{config_dir}/settings.json`, strips JSONC comments, and parses
/// the `agent_servers` key into a list of `ZedAgent` values.
///
/// Returns an empty vec if the file doesn't exist or can't be parsed.
pub fn discover_agents_from_settings(config_dir: &Path) -> Vec<ZedAgent> {
    let settings_path = config_dir.join("settings.json");

    let content = match std::fs::read_to_string(&settings_path) {
        Ok(c) => c,
        Err(e) => {
            tracing::debug!(
                path = %settings_path.display(),
                error = %e,
                "Could not read Zed settings.json"
            );
            return Vec::new();
        }
    };

    let stripped = strip_jsonc_comments(&content);

    let settings: ZedSettings = match serde_json::from_str(&stripped) {
        Ok(s) => s,
        Err(e) => {
            tracing::warn!(
                path = %settings_path.display(),
                error = %e,
                "Failed to parse Zed settings.json"
            );
            return Vec::new();
        }
    };

    settings
        .agent_servers
        .into_iter()
        .map(|(name, entry)| ZedAgent {
            name,
            agent_type: entry.server_type,
            binary_path: None,
            env_overrides: entry.env,
            display_name: None,
            description: None,
            args: Vec::new(),
            icon_local_path: None,
            icon_url: None,
        })
        .collect()
}

/// Discover agents from the `external_agents/` directory in the Zed data dir.
///
/// Zed downloads registry agents to `{data_dir}/external_agents/{package_name}/`.
/// These may not appear in `settings.json` if the user hasn't customized them.
/// This function scans the directory and returns agents for any packages not
/// already present in the `existing_agents` list (matched by fuzzy name).
pub fn discover_agents_from_external_dir(
    data_dir: &Path,
    existing_agents: &[ZedAgent],
) -> Vec<ZedAgent> {
    let agents_dir = data_dir.join("external_agents");

    if !agents_dir.is_dir() {
        return Vec::new();
    }

    let existing_names: Vec<String> = existing_agents
        .iter()
        .map(|a| a.name.to_lowercase())
        .collect();

    let mut discovered = Vec::new();

    let entries = match std::fs::read_dir(&agents_dir) {
        Ok(e) => e,
        Err(_) => return Vec::new(),
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }

        let dir_name = entry.file_name().to_string_lossy().to_string();

        // Skip the registry directory — it contains cached registry metadata
        // and downloaded binaries indexed by URL hash, not agent configurations.
        if dir_name == "registry" {
            tracing::debug!("Skipping registry directory in external_agents");
            continue;
        }

        let dir_lower = dir_name.to_lowercase();

        // Check if this directory already matches an existing agent from settings.
        let already_covered = existing_names.iter().any(|existing| {
            dir_lower == *existing
                || dir_lower.contains(existing)
                || existing.contains(&dir_lower)
        });

        if already_covered {
            continue;
        }

        tracing::debug!(
            dir = %dir_name,
            "Discovered external agent not in settings"
        );

        discovered.push(ZedAgent {
            name: dir_name,
            agent_type: AgentServerType::Registry,
            binary_path: None,
            env_overrides: HashMap::new(),
            display_name: None,
            description: None,
            args: Vec::new(),
            icon_local_path: None,
            icon_url: None,
        });
    }

    discovered
}

/// Resolve binary paths for registry agents from the Zed data directory.
///
/// For each agent with `agent_type == Registry`, looks for downloaded binaries
/// under `{data_dir}/external_agents/`. Zed stores agents directly under
/// `external_agents/{package_name}/{version}/` — package names often differ
/// from settings keys (e.g., settings key `"claude"` maps to directory
/// `claude-agent-acp` or `claude-code-acp`).
///
/// The resolution strategy:
/// 1. Try exact directory name match first
/// 2. Scan all directories in `external_agents/` for fuzzy matches
///    (directory name contains agent name, or agent name contains directory name)
/// 3. Within matched directories, find the latest version subdirectory
/// 4. Look for native executables and Node.js bin entries
pub fn resolve_binary_paths(agents: &mut [ZedAgent], data_dir: &Path) {
    let agents_dir = data_dir.join("external_agents");

    if !agents_dir.is_dir() {
        tracing::debug!(
            path = %agents_dir.display(),
            "external_agents directory not found"
        );
        return;
    }

    // Collect all directories in external_agents/ for fuzzy matching.
    let available_dirs: Vec<(String, PathBuf)> = std::fs::read_dir(&agents_dir)
        .ok()
        .into_iter()
        .flatten()
        .flatten()
        .filter_map(|entry| {
            let path = entry.path();
            if path.is_dir() {
                let name = entry.file_name().to_string_lossy().to_string();
                Some((name, path))
            } else {
                None
            }
        })
        .collect();

    for agent in agents.iter_mut() {
        if agent.agent_type != AgentServerType::Registry {
            continue;
        }

        // Find matching directories for this agent name.
        let matching_dirs = find_matching_agent_dirs(&agent.name, &available_dirs);

        if matching_dirs.is_empty() {
            tracing::debug!(
                agent = %agent.name,
                "No matching directory found in external_agents"
            );
            continue;
        }

        // When multiple dirs match (e.g., claude-agent-acp and claude-code-acp),
        // pick the one with the most recently modified version directory.
        let mut best_binary: Option<(PathBuf, std::time::SystemTime)> = None;

        for agent_dir in &matching_dirs {
            let version_dir = match find_latest_version_dir(agent_dir) {
                Some(d) => d,
                None => continue,
            };

            let dir_mtime = std::fs::metadata(&version_dir)
                .and_then(|m| m.modified())
                .unwrap_or(std::time::SystemTime::UNIX_EPOCH);

            if let Some(binary) = find_nodejs_bin(&agent.name, &version_dir)
                .or_else(|| find_native_binary(&version_dir))
            {
                match &best_binary {
                    Some((_, best_time)) if dir_mtime <= *best_time => {}
                    _ => {
                        best_binary = Some((binary, dir_mtime));
                    }
                }
            }
        }

        if let Some((binary, _)) = best_binary {
            tracing::debug!(
                agent = %agent.name,
                binary = %binary.display(),
                "Resolved agent binary path"
            );
            agent.binary_path = Some(binary);
        }
    }
}

/// Enrich discovered agents with metadata from the parsed registry.
///
/// For each agent, looks up the corresponding registry entry using fuzzy name
/// matching and populates display name, description, args, icon path, and icon URL.
pub fn enrich_agents_from_registry(
    agents: &mut [ZedAgent],
    registry: &std::collections::HashMap<String, crate::registry::RegistryAgentInfo>,
) {
    for agent in agents.iter_mut() {
        if let Some(info) = crate::registry::find_registry_match(&agent.name, registry) {
            if agent.display_name.is_none() {
                agent.display_name = Some(info.display_name.clone());
            }
            if agent.description.is_none() {
                agent.description = Some(info.description.clone());
            }
            if agent.args.is_empty() {
                agent.args = info.args.clone();
            }
            if agent.icon_local_path.is_none() {
                agent.icon_local_path = info.icon_local_path.clone();
            }
            if agent.icon_url.is_none() {
                agent.icon_url = info.icon_url.clone();
            }

            tracing::debug!(
                agent = %agent.name,
                registry_id = %info.id,
                display_name = %info.display_name,
                "Enriched agent from registry"
            );
        }
    }
}

/// Find directories in `external_agents/` that match an agent settings name.
///
/// Matching strategy (in priority order):
/// 1. Exact match: directory name equals agent name
/// 2. Directory name contains agent name (e.g., "claude-agent-acp" contains "claude")
/// 3. Agent name contains directory name (e.g., "claude-acp" contains "claude")
fn find_matching_agent_dirs<'a>(
    agent_name: &str,
    available_dirs: &'a [(String, PathBuf)],
) -> Vec<&'a Path> {
    let agent_lower = agent_name.to_lowercase();
    let mut exact = Vec::new();
    let mut contains = Vec::new();

    for (dir_name, dir_path) in available_dirs {
        let dir_lower = dir_name.to_lowercase();
        if dir_lower == agent_lower {
            exact.push(dir_path.as_path());
        } else if dir_lower.contains(&agent_lower) || agent_lower.contains(&dir_lower) {
            contains.push(dir_path.as_path());
        }
    }

    if !exact.is_empty() {
        exact
    } else {
        contains
    }
}

/// Find the most recently modified version directory inside `agent_dir`.
///
/// Zed uses various version directory formats: `v0.9.2`, `0.20.0`, `v_abc123`.
/// We accept any subdirectory as a potential version directory.
fn find_latest_version_dir(agent_dir: &Path) -> Option<PathBuf> {
    let read_dir = std::fs::read_dir(agent_dir).ok()?;

    let mut best: Option<(PathBuf, std::time::SystemTime)> = None;

    for entry in read_dir.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        // Skip hidden directories and node_modules at this level.
        let name = entry.file_name();
        let name_str = name.to_string_lossy();
        if name_str.starts_with('.') || name_str == "node_modules" {
            continue;
        }

        let modified = entry.metadata().and_then(|m| m.modified()).ok();
        if let Some(mod_time) = modified {
            match &best {
                Some((_, best_time)) if mod_time > *best_time => {
                    best = Some((path, mod_time));
                }
                None => {
                    best = Some((path, mod_time));
                }
                _ => {}
            }
        } else {
            if best.is_none() {
                best = Some((path, std::time::SystemTime::UNIX_EPOCH));
            }
        }
    }

    best.map(|(path, _)| path)
}

/// Find a Node.js executable in `node_modules/.bin/` within a version directory.
///
/// Many Zed agents are npm packages. Their executables are symlinked in
/// `node_modules/.bin/`. We prefer a binary whose file stem matches the agent
/// name (case-insensitive, with common ACP suffixes stripped). If no match is
/// found, we fall back to the first executable -- this covers agents whose
/// binary name differs from their directory name.
///
/// The name-matching preference is important because npm packages often install
/// dependency binaries alongside the agent binary (e.g., `acorn`, `glob`), and
/// without matching we might pick the wrong one.
fn find_nodejs_bin(agent_name: &str, version_dir: &Path) -> Option<PathBuf> {
    let bin_dir = version_dir.join("node_modules").join(".bin");
    if !bin_dir.is_dir() {
        return None;
    }

    let read_dir = std::fs::read_dir(&bin_dir).ok()?;

    let agent_lower = agent_name.to_lowercase();
    let agent_core = crate::registry::strip_acp_suffixes(&agent_lower);

    let mut first_executable: Option<PathBuf> = None;

    for entry in read_dir.flatten() {
        let path = entry.path();
        if path.is_dir() {
            continue;
        }
        if !is_executable(&path) {
            continue;
        }

        // Check if this binary's stem matches the agent name.
        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
            let stem_lower = stem.to_lowercase();
            if stem_lower == agent_lower || stem_lower == agent_core {
                return Some(path);
            }
        }

        if first_executable.is_none() {
            first_executable = Some(path);
        }
    }

    first_executable
}

/// Find a native executable binary inside a version directory.
///
/// Looks for executable files directly in the version directory, recursing
/// one level for extracted archives. Skips `node_modules/` and hidden dirs.
fn find_native_binary(dir: &Path) -> Option<PathBuf> {
    let read_dir = std::fs::read_dir(dir).ok()?;

    for entry in read_dir.flatten() {
        let path = entry.path();
        let name = entry.file_name();
        let name_str = name.to_string_lossy();

        if path.is_dir() {
            // Skip node_modules and hidden directories.
            if name_str == "node_modules" || name_str.starts_with('.') {
                continue;
            }
            // Recurse one level for extracted archives.
            if let Some(binary) = find_native_binary(&path) {
                return Some(binary);
            }
            continue;
        }

        // Skip non-binary files (package.json, lock files, etc.)
        if name_str.ends_with(".json") || name_str.ends_with(".lock") {
            continue;
        }

        if is_executable(&path) {
            return Some(path);
        }
    }

    None
}

/// Check if a file is executable.
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
    use std::os::unix::fs::PermissionsExt;
    path.metadata()
        .map(|m| m.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}

#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
    path.extension()
        .map(|ext| ext == "exe" || ext == "cmd" || ext == "bat")
        .unwrap_or(false)
}

/// Strip JSONC comments from input text.
///
/// Handles:
/// - Line comments (`// ...`)
/// - Block comments (`/* ... */`)
/// - Does NOT strip inside quoted strings
/// - Handles escaped quotes inside strings
pub fn strip_jsonc_comments(input: &str) -> String {
    let mut output = String::with_capacity(input.len());
    let chars: Vec<char> = input.chars().collect();
    let len = chars.len();
    let mut i = 0;

    while i < len {
        let ch = chars[i];

        // Inside a JSON string — pass through verbatim, respecting escape sequences.
        if ch == '"' {
            output.push(ch);
            i += 1;
            while i < len {
                let c = chars[i];
                output.push(c);
                if c == '\\' {
                    // Escaped character: push the next char unconditionally.
                    i += 1;
                    if i < len {
                        output.push(chars[i]);
                    }
                } else if c == '"' {
                    break;
                }
                i += 1;
            }
            i += 1;
            continue;
        }

        // Check for line comment `//`
        if ch == '/' && i + 1 < len && chars[i + 1] == '/' {
            // Skip until end of line.
            i += 2;
            while i < len && chars[i] != '\n' {
                i += 1;
            }
            continue;
        }

        // Check for block comment `/* ... */`
        if ch == '/' && i + 1 < len && chars[i + 1] == '*' {
            i += 2;
            while i + 1 < len {
                if chars[i] == '*' && chars[i + 1] == '/' {
                    i += 2;
                    break;
                }
                // Preserve newlines so line numbers stay meaningful for error messages.
                if chars[i] == '\n' {
                    output.push('\n');
                }
                i += 1;
            }
            continue;
        }

        output.push(ch);
        i += 1;
    }

    output
}

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

    /// Returns a binary file name appropriate for the platform.
    /// On Windows, appends `.exe`; on Unix, returns the name as-is.
    fn platform_binary(name: &str) -> String {
        if cfg!(windows) {
            format!("{}.exe", name)
        } else {
            name.to_string()
        }
    }

    #[test]
    fn test_strip_jsonc_line_comments() {
        let input = r#"{
  "key": "value", // this is a comment
  "other": 42 // another comment
}"#;
        let stripped = strip_jsonc_comments(input);
        assert!(!stripped.contains("// this is"));
        assert!(!stripped.contains("// another"));
        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
        assert_eq!(parsed["key"], "value");
        assert_eq!(parsed["other"], 42);
    }

    #[test]
    fn test_strip_jsonc_block_comments() {
        let input = r#"{
  /* block comment */
  "key": "value",
  "other": /* inline block */ 42
}"#;
        let stripped = strip_jsonc_comments(input);
        assert!(!stripped.contains("block comment"));
        assert!(!stripped.contains("inline block"));
        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
        assert_eq!(parsed["key"], "value");
        assert_eq!(parsed["other"], 42);
    }

    #[test]
    fn test_strip_jsonc_preserves_strings() {
        let input = r#"{
  "url": "https://example.com/path", // comment after url
  "comment_like": "this has // inside and /* block */ too"
}"#;
        let stripped = strip_jsonc_comments(input);
        // The comment after url should be stripped.
        assert!(!stripped.contains("// comment after url"));
        // But the strings should be intact.
        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
        assert_eq!(parsed["url"], "https://example.com/path");
        assert_eq!(
            parsed["comment_like"],
            "this has // inside and /* block */ too"
        );
    }

    #[test]
    fn test_strip_jsonc_escaped_quotes() {
        let input = r#"{
  "escaped": "he said \"hello\" // not a comment",
  "real": 1 // real comment
}"#;
        let stripped = strip_jsonc_comments(input);
        assert!(!stripped.contains("// real comment"));
        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
        assert_eq!(
            parsed["escaped"],
            r#"he said "hello" // not a comment"#
        );
        assert_eq!(parsed["real"], 1);
    }

    #[test]
    fn test_parse_agent_servers() {
        let dir = tempfile::tempdir().unwrap();
        let settings_content = r#"{
  // Agent configuration
  "agent_servers": {
    "claude-acp": {
      "type": "registry",
      "default_mode": "plan",
      "env": {
        "CLAUDE_CODE_EXECUTABLE": "/usr/local/bin/claude"
      }
    },
    "codex-acp": {
      "type": "registry",
      "default_model": "o4-mini"
    },
    "My Custom Agent": {
      "type": "custom",
      "command": "node",
      "args": ["~/projects/agent/index.js", "--acp"],
      "env": {}
    }
  }
}"#;
        let settings_path = dir.path().join("settings.json");
        let mut f = std::fs::File::create(&settings_path).unwrap();
        f.write_all(settings_content.as_bytes()).unwrap();

        let agents = discover_agents_from_settings(dir.path());
        assert_eq!(agents.len(), 3);

        let claude = agents.iter().find(|a| a.name == "claude-acp").unwrap();
        assert_eq!(claude.agent_type, AgentServerType::Registry);
        assert_eq!(
            claude.env_overrides.get("CLAUDE_CODE_EXECUTABLE"),
            Some(&"/usr/local/bin/claude".to_string())
        );

        let codex = agents.iter().find(|a| a.name == "codex-acp").unwrap();
        assert_eq!(codex.agent_type, AgentServerType::Registry);
        assert!(codex.env_overrides.is_empty());

        let custom = agents
            .iter()
            .find(|a| a.name == "My Custom Agent")
            .unwrap();
        assert_eq!(custom.agent_type, AgentServerType::Custom);
    }

    #[test]
    fn test_discover_missing_file() {
        let dir = tempfile::tempdir().unwrap();
        let agents = discover_agents_from_settings(dir.path());
        assert!(agents.is_empty());
    }

    #[test]
    fn test_discover_no_agent_servers_key() {
        let dir = tempfile::tempdir().unwrap();
        let settings_path = dir.path().join("settings.json");
        std::fs::write(&settings_path, r#"{ "theme": "dark" }"#).unwrap();

        let agents = discover_agents_from_settings(dir.path());
        assert!(agents.is_empty());
    }

    #[test]
    fn test_resolve_binary_paths_no_data() {
        let dir = tempfile::tempdir().unwrap();
        let mut agents = vec![ZedAgent {
            name: "claude-acp".to_string(),
            agent_type: AgentServerType::Registry,
            binary_path: None,
            env_overrides: HashMap::new(),
            display_name: None,
            description: None,
            args: Vec::new(),
            icon_local_path: None,
            icon_url: None,
        }];

        resolve_binary_paths(&mut agents, dir.path());
        assert!(agents[0].binary_path.is_none());
    }

    #[test]
    fn test_resolve_native_binary_exact_name() {
        // Simulates: external_agents/codex/v0.9.2/codex-acp (native ELF)
        let dir = tempfile::tempdir().unwrap();
        let version_dir = dir
            .path()
            .join("external_agents")
            .join("codex")
            .join("v0.9.2");
        std::fs::create_dir_all(&version_dir).unwrap();

        let binary_path = version_dir.join(platform_binary("codex-acp"));
        std::fs::write(&binary_path, b"#!/bin/sh\necho hello").unwrap();

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755))
                .unwrap();
        }

        let mut agents = vec![ZedAgent {
            name: "codex".to_string(),
            agent_type: AgentServerType::Registry,
            binary_path: None,
            env_overrides: HashMap::new(),
            display_name: None,
            description: None,
            args: Vec::new(),
            icon_local_path: None,
            icon_url: None,
        }];

        resolve_binary_paths(&mut agents, dir.path());
        assert!(agents[0].binary_path.is_some());
        assert!(agents[0]
            .binary_path
            .as_ref()
            .unwrap()
            .to_string_lossy()
            .contains("codex-acp"));
    }

    #[test]
    fn test_resolve_nodejs_bin() {
        // Simulates: external_agents/claude-agent-acp/0.20.0/node_modules/.bin/claude-agent-acp
        let dir = tempfile::tempdir().unwrap();
        let bin_dir = dir
            .path()
            .join("external_agents")
            .join("claude-agent-acp")
            .join("0.20.0")
            .join("node_modules")
            .join(".bin");
        std::fs::create_dir_all(&bin_dir).unwrap();

        let binary_path = bin_dir.join(platform_binary("claude-agent-acp"));
        std::fs::write(&binary_path, b"#!/usr/bin/env node\nconsole.log('hi')").unwrap();

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755))
                .unwrap();
        }

        // Settings key is "claude" but directory is "claude-agent-acp" — fuzzy match.
        let mut agents = vec![ZedAgent {
            name: "claude".to_string(),
            agent_type: AgentServerType::Registry,
            binary_path: None,
            env_overrides: HashMap::new(),
            display_name: None,
            description: None,
            args: Vec::new(),
            icon_local_path: None,
            icon_url: None,
        }];

        resolve_binary_paths(&mut agents, dir.path());
        assert!(
            agents[0].binary_path.is_some(),
            "Should resolve Node.js bin via fuzzy directory match"
        );
        assert!(agents[0]
            .binary_path
            .as_ref()
            .unwrap()
            .to_string_lossy()
            .contains("claude-agent-acp"));
    }

    #[test]
    fn test_resolve_semver_version_dirs() {
        // Zed uses semver version dirs like "0.20.0", not just "v_" prefixed.
        let dir = tempfile::tempdir().unwrap();
        let version_dir = dir
            .path()
            .join("external_agents")
            .join("test-agent")
            .join("1.2.3");
        std::fs::create_dir_all(&version_dir).unwrap();

        let binary_path = version_dir.join(platform_binary("test-agent"));
        std::fs::write(&binary_path, b"#!/bin/sh\necho hello").unwrap();

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755))
                .unwrap();
        }

        let mut agents = vec![ZedAgent {
            name: "test-agent".to_string(),
            agent_type: AgentServerType::Registry,
            binary_path: None,
            env_overrides: HashMap::new(),
            display_name: None,
            description: None,
            args: Vec::new(),
            icon_local_path: None,
            icon_url: None,
        }];

        resolve_binary_paths(&mut agents, dir.path());
        assert!(
            agents[0].binary_path.is_some(),
            "Should find binary in semver-named version directory"
        );
    }

    #[test]
    fn test_resolve_picks_latest_version() {
        // Two version dirs, the newer one should win.
        let dir = tempfile::tempdir().unwrap();
        let agents_base = dir.path().join("external_agents").join("my-agent");

        let old_dir = agents_base.join("0.1.0");
        std::fs::create_dir_all(&old_dir).unwrap();
        let old_bin = old_dir.join(platform_binary("my-agent"));
        std::fs::write(&old_bin, b"old").unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&old_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
        }

        // Sleep briefly to ensure different mtime
        std::thread::sleep(std::time::Duration::from_millis(50));

        let new_dir = agents_base.join("0.2.0");
        std::fs::create_dir_all(&new_dir).unwrap();
        let new_bin = new_dir.join(platform_binary("my-agent"));
        std::fs::write(&new_bin, b"new").unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&new_bin, std::fs::Permissions::from_mode(0o755)).unwrap();
        }

        let mut agents = vec![ZedAgent {
            name: "my-agent".to_string(),
            agent_type: AgentServerType::Registry,
            binary_path: None,
            env_overrides: HashMap::new(),
            display_name: None,
            description: None,
            args: Vec::new(),
            icon_local_path: None,
            icon_url: None,
        }];

        resolve_binary_paths(&mut agents, dir.path());
        assert!(agents[0].binary_path.is_some());
        let resolved = agents[0].binary_path.as_ref().unwrap();
        assert!(
            resolved.to_string_lossy().contains("0.2.0"),
            "Should pick the newer version directory, got: {}",
            resolved.display()
        );
    }

    #[test]
    fn test_resolve_skips_custom_agents() {
        let dir = tempfile::tempdir().unwrap();
        let mut agents = vec![ZedAgent {
            name: "my-agent".to_string(),
            agent_type: AgentServerType::Custom,
            binary_path: None,
            env_overrides: HashMap::new(),
            display_name: None,
            description: None,
            args: Vec::new(),
            icon_local_path: None,
            icon_url: None,
        }];

        resolve_binary_paths(&mut agents, dir.path());
        assert!(agents[0].binary_path.is_none());
    }

    #[test]
    fn test_fuzzy_match_dir_contains_agent_name() {
        // Directory "claude-code-acp" should match agent name "claude"
        let dirs = vec![
            ("claude-code-acp".to_string(), PathBuf::from("/fake/claude-code-acp")),
            ("codex".to_string(), PathBuf::from("/fake/codex")),
        ];
        let matches = find_matching_agent_dirs("claude", &dirs);
        assert_eq!(matches.len(), 1);
        assert!(matches[0].to_string_lossy().contains("claude-code-acp"));
    }

    #[test]
    fn test_fuzzy_match_exact_preferred() {
        // Exact match "codex" should be preferred over fuzzy "codex-extra"
        let dirs = vec![
            ("codex".to_string(), PathBuf::from("/fake/codex")),
            ("codex-extra".to_string(), PathBuf::from("/fake/codex-extra")),
        ];
        let matches = find_matching_agent_dirs("codex", &dirs);
        assert_eq!(matches.len(), 1);
        assert!(matches[0].to_string_lossy().contains("/codex"));
    }

    #[test]
    fn test_fuzzy_match_multiple_fuzzy_results() {
        // "claude" matches both "claude-agent-acp" and "claude-code-acp"
        let dirs = vec![
            ("claude-agent-acp".to_string(), PathBuf::from("/fake/claude-agent-acp")),
            ("claude-code-acp".to_string(), PathBuf::from("/fake/claude-code-acp")),
            ("codex".to_string(), PathBuf::from("/fake/codex")),
        ];
        let matches = find_matching_agent_dirs("claude", &dirs);
        assert_eq!(matches.len(), 2);
    }

    #[test]
    fn test_agent_server_type_display() {
        assert_eq!(AgentServerType::Registry.to_string(), "registry");
        assert_eq!(AgentServerType::Custom.to_string(), "custom");
        assert_eq!(AgentServerType::Extension.to_string(), "extension");
    }

    #[test]
    fn test_agent_server_type_default_is_registry() {
        assert_eq!(AgentServerType::default(), AgentServerType::Registry);
    }

    #[test]
    fn test_parse_missing_type_defaults_to_registry() {
        let dir = tempfile::tempdir().unwrap();
        let settings = r#"{
            "agent_servers": {
                "codex": {
                    "command": "codex-acp",
                    "args": [],
                    "env": {}
                }
            }
        }"#;
        std::fs::write(dir.path().join("settings.json"), settings).unwrap();

        let agents = discover_agents_from_settings(dir.path());
        assert_eq!(agents.len(), 1);
        assert_eq!(agents[0].name, "codex");
        assert_eq!(agents[0].agent_type, AgentServerType::Registry);
    }

    #[test]
    fn test_discover_external_agents_not_in_settings() {
        let data_dir = tempfile::tempdir().unwrap();
        let ext_dir = data_dir.path().join("external_agents");

        // Create directories simulating Zed-downloaded agents
        std::fs::create_dir_all(ext_dir.join("claude-agent-acp")).unwrap();
        std::fs::create_dir_all(ext_dir.join("claude-code-acp")).unwrap();
        std::fs::create_dir_all(ext_dir.join("codex")).unwrap();
        std::fs::create_dir_all(ext_dir.join("gemini")).unwrap();

        // Existing agents from settings: only "codex"
        let existing = vec![ZedAgent {
            name: "codex".to_string(),
            agent_type: AgentServerType::Custom,
            binary_path: None,
            env_overrides: HashMap::new(),
            display_name: None,
            description: None,
            args: Vec::new(),
            icon_local_path: None,
            icon_url: None,
        }];

        let discovered = discover_agents_from_external_dir(data_dir.path(), &existing);

        // "codex" is already in settings (exact match), so it's excluded.
        // "claude-agent-acp" and "claude-code-acp" are new, and "gemini" is new.
        assert_eq!(discovered.len(), 3);

        let names: Vec<&str> = discovered.iter().map(|a| a.name.as_str()).collect();
        assert!(names.contains(&"claude-agent-acp"));
        assert!(names.contains(&"claude-code-acp"));
        assert!(names.contains(&"gemini"));

        // All should be Registry type
        for agent in &discovered {
            assert_eq!(agent.agent_type, AgentServerType::Registry);
        }
    }

    #[test]
    fn test_discover_external_agents_fuzzy_excludes() {
        let data_dir = tempfile::tempdir().unwrap();
        let ext_dir = data_dir.path().join("external_agents");

        std::fs::create_dir_all(ext_dir.join("claude-agent-acp")).unwrap();

        // "claude" in settings should fuzzy-match "claude-agent-acp"
        let existing = vec![ZedAgent {
            name: "claude".to_string(),
            agent_type: AgentServerType::Registry,
            binary_path: None,
            env_overrides: HashMap::new(),
            display_name: None,
            description: None,
            args: Vec::new(),
            icon_local_path: None,
            icon_url: None,
        }];

        let discovered = discover_agents_from_external_dir(data_dir.path(), &existing);
        assert!(
            discovered.is_empty(),
            "claude-agent-acp should be excluded by fuzzy match with 'claude'"
        );
    }

    #[test]
    fn test_discover_external_agents_no_dir() {
        let data_dir = tempfile::tempdir().unwrap();
        // No external_agents/ directory exists
        let discovered = discover_agents_from_external_dir(data_dir.path(), &[]);
        assert!(discovered.is_empty());
    }

    #[test]
    fn test_discover_excludes_registry_dir() {
        let tmp = tempfile::tempdir().unwrap();
        let agents_dir = tmp.path().join("external_agents");
        std::fs::create_dir_all(agents_dir.join("registry")).unwrap();
        std::fs::create_dir_all(agents_dir.join("some-agent")).unwrap();

        let discovered = discover_agents_from_external_dir(tmp.path(), &[]);
        let names: Vec<&str> = discovered.iter().map(|a| a.name.as_str()).collect();
        assert!(!names.contains(&"registry"), "registry/ should be excluded");
        assert!(names.contains(&"some-agent"));
    }

    #[test]
    fn test_find_nodejs_bin_prefers_agent_name() {
        // Simulates the Gemini scenario: multiple binaries in .bin/,
        // the agent binary should be preferred over dependency binaries.
        let dir = tempfile::tempdir().unwrap();
        let bin_dir = dir
            .path()
            .join("external_agents")
            .join("gemini")
            .join("0.23.0")
            .join("node_modules")
            .join(".bin");
        std::fs::create_dir_all(&bin_dir).unwrap();

        // Create dependency binaries that sort alphabetically before "gemini"
        for name in &["acorn", "esparse", "extract-zip", "gemini", "glob"] {
            let path = bin_dir.join(platform_binary(name));
            std::fs::write(&path, b"#!/usr/bin/env node\n").unwrap();
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
            }
        }

        let version_dir = dir
            .path()
            .join("external_agents")
            .join("gemini")
            .join("0.23.0");

        let result = find_nodejs_bin("gemini", &version_dir);
        assert!(result.is_some(), "Should find a binary");
        let resolved = result.unwrap();
        let stem = resolved.file_stem().unwrap().to_string_lossy().to_string();
        assert_eq!(
            stem, "gemini",
            "Should prefer 'gemini' binary over dependency binaries, got: {}",
            resolved.display()
        );
    }
}
