//! Filesystem scanning for plugin directories.
//!
//! Discovers plugins from multiple sources in priority order:
//! 1. CLI `--plugin-dir` paths (scope: `CliOverride`)
//! 2. `.kigi/plugins/*/` (scope: `Project`, walked from cwd to worktree root)
//! 3. `.claude/plugins/*/` (scope: `Project`, compat)
//! 4. `~/.kigi/plugins/*/` (scope: `User`)
//! 5. `~/.claude/plugins/*/` (scope: `User`, compat)
//!    `~/.kigi/installed-plugins/*/` (scope: `User`, marketplace installs)
//!    Installed plugins from `~/.claude/plugins/installed_plugins.json` (scope: `User`)
//! 6. Paths from `[plugins].paths` in config (scope: `ConfigPath`)
//!
//! Deduplicates by canonical path and resolves name conflicts via
//! the canonical source precedence.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use sha2::{Digest, Sha256};

use super::manifest::{ManifestLoadResult, PluginManifest, load_manifest, name_from_dirname};
use super::trust::TrustStore;

// ── Public types ──────────────────────────────────────────────────────

/// Where a plugin was discovered from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PluginScope {
    /// `--plugin-dir` (highest priority, always trusted)
    CliOverride = 0,
    /// `.kigi/plugins/` or `.claude/plugins/` in project (requires trust)
    Project = 1,
    /// `~/.kigi/plugins/` or `~/.claude/plugins/` (always trusted)
    User = 2,
    /// `[plugins].paths` in config (trust depends on location)
    ConfigPath = 3,
}

impl PluginScope {
    /// Label used in `PluginId` format.
    pub fn id_label(&self) -> &'static str {
        match self {
            Self::CliOverride => "cli",
            Self::Project => "project",
            Self::User => "user",
            Self::ConfigPath => "config",
        }
    }
}

impl std::fmt::Display for PluginScope {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::CliOverride => write!(f, "cli"),
            Self::Project => write!(f, "project"),
            Self::User => write!(f, "user"),
            Self::ConfigPath => write!(f, "config"),
        }
    }
}

/// The concrete discovery source a plugin came from.
///
/// Finer-grained than [`PluginScope`]: recorded at scan time so consumers
/// (e.g. the pager's plugins list) don't have to re-derive provenance from
/// paths. Not part of [`PluginId`], which stays scope-based.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginOrigin {
    /// CLI `--plugin-dir`.
    CliOverride,
    /// Project `.kigi/plugins/`.
    ProjectKigi,
    /// Project `.claude/plugins/`.
    ProjectClaude,
    /// `$KIGI_SHARE_DIR/plugins/`.
    UserKigi,
    /// `~/.claude/plugins/`.
    UserClaude,
    /// A compat marketplace clone (project `extraKnownMarketplaces`
    /// or user `known_marketplaces.json`).
    ClaudeMarketplace {
        /// Marketplace name from the settings/registry entry.
        marketplace: String,
    },
    /// Install recorded in `~/.claude/plugins/installed_plugins.json`.
    ClaudeInstalled {
        /// Marketplace name from the `name@marketplace` JSON key, when present.
        marketplace: Option<String>,
    },
    /// Kigi's install registry (`~/.kigi/installed-plugins`).
    MarketplaceInstall {
        /// Git URL of the installed repo (None for local installs).
        git_url: Option<String>,
    },
    /// `[plugins].paths` in config.
    ConfigPath,
}

/// Stable internal identity for a plugin.
///
/// Format: `<scope>/<hex8>/<name>`
/// - `<scope>`: lowercase scope string (cli, project, user, config)
/// - `<hex8>`: first 8 hex chars of SHA-256 of the canonical plugin root path
/// - `<name>`: the plugin_name
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PluginId(pub String);

impl PluginId {
    /// Construct a plugin ID from its components.
    pub fn new(scope: PluginScope, canonical_root: &Path, name: &str) -> Self {
        let path_str = canonical_root.to_string_lossy();
        let mut hasher = Sha256::new();
        hasher.update(path_str.as_bytes());
        let hash = hasher.finalize();
        let hex8 = format!(
            "{:02x}{:02x}{:02x}{:02x}",
            hash[0], hash[1], hash[2], hash[3]
        );
        Self(format!("{}/{}/{}", scope.id_label(), hex8, name))
    }
}

impl std::fmt::Display for PluginId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// A plugin candidate discovered on the filesystem.
#[derive(Debug, Clone)]
pub struct DiscoveredPlugin {
    /// Parsed manifest (or synthetic for convention-based plugins).
    pub manifest: PluginManifest,
    /// Stable internal identity.
    pub id: PluginId,
    /// Absolute path to the plugin root directory.
    pub root: PathBuf,
    /// Canonical (symlink-resolved) root path.
    pub canonical_root: PathBuf,
    /// Where this plugin was discovered.
    pub scope: PluginScope,
    /// The concrete discovery source this plugin came from.
    pub origin: PluginOrigin,
    /// Whether the plugin is trusted for executable operations.
    pub trusted: bool,
    /// Resolved skill directories.
    pub skill_dirs: Vec<PathBuf>,
    pub command_dirs: Vec<PathBuf>,
    /// Resolved agent directories.
    pub agent_dirs: Vec<PathBuf>,
    /// Resolved hooks file path.
    pub hooks_path: Option<PathBuf>,
    /// Resolved MCP config file path.
    pub mcp_config_path: Option<PathBuf>,
    /// Resolved LSP config file path.
    pub lsp_config_path: Option<PathBuf>,
    /// Warning message when this plugin won a name collision.
    pub conflict: Option<String>,
}

impl DiscoveredPlugin {
    /// User-facing plugin name (from manifest or directory name).
    pub fn plugin_name(&self) -> &str {
        &self.manifest.name
    }
}

/// Configuration for plugin discovery.
#[derive(Debug, Clone, Default)]
pub struct DiscoveryConfig {
    /// CLI `--plugin-dir` paths.
    pub cli_plugin_dirs: Vec<PathBuf>,
    /// `[plugins].paths` from config.
    pub config_paths: Vec<PathBuf>,
    /// `[plugins].disabled` plugin IDs or names.
    pub disabled: Vec<String>,
    /// `[plugins].enabled` plugin IDs or names (overrides default-disabled for project plugins).
    pub enabled: Vec<String>,
}

impl DiscoveryConfig {
    /// Ensure every discovered plugin appears in either `enabled` or `disabled`.
    ///
    /// Plugins from auto-enabled scopes (`CliOverride`, `ConfigPath`) are added
    /// to `enabled`. All others (`User`, `Project`) are added to `disabled`.
    /// Plugins already present in either list are left untouched.
    pub fn populate_plugin_lists(&mut self, discovered: &[DiscoveredPlugin]) {
        for dp in discovered {
            let name = &dp.manifest.name;
            let already_listed = self.enabled.iter().any(|e| e == name || e == &dp.id.0)
                || self.disabled.iter().any(|d| d == name || d == &dp.id.0);
            if already_listed {
                tracing::debug!(plugin = %name, id = %dp.id.0, "plugin already in enabled/disabled list");
                continue;
            }
            if matches!(dp.scope, PluginScope::CliOverride | PluginScope::ConfigPath) {
                tracing::debug!(plugin = %name, scope = ?dp.scope, "auto-adding to enabled list");
                self.enabled.push(name.clone());
            } else {
                tracing::debug!(plugin = %name, scope = ?dp.scope, "auto-adding to disabled list");
                self.disabled.push(name.clone());
            }
        }
    }
}

// ── Discovery entry point ─────────────────────────────────────────────

/// User plugin directories in priority order: `$KIGI_SHARE_DIR/plugins` then
/// `~/.claude/plugins`.
///
/// Unlike agent discovery, plugins are intentionally NOT discovered from a
/// legacy `~/.kigi/plugins`: plugin trust, persisted plugin-data, and install
/// paths all resolve under `kigi_home()`, so a plugin scanned from the legacy
/// tree would appear untrusted and lose its persisted state. Keeping plugins on
/// `kigi_home()` only avoids that half-initialized state.
fn user_plugin_dirs(home: Option<&Path>, kigi: Option<&Path>) -> Vec<(PathBuf, PluginOrigin)> {
    let mut dirs = Vec::new();
    if let Some(g) = kigi {
        dirs.push((g.join("plugins"), PluginOrigin::UserKigi));
    }
    if let Some(h) = home {
        dirs.push((h.join(".claude").join("plugins"), PluginOrigin::UserClaude));
    }
    dirs
}

/// Origin for a project plugins parent dir: `.claude/plugins` vs `.kigi/plugins`.
fn project_plugins_dir_origin(plugins_dir: &Path) -> PluginOrigin {
    let is_claude = plugins_dir
        .parent()
        .and_then(|p| p.file_name())
        .is_some_and(|n| n == ".claude");
    if is_claude {
        PluginOrigin::ProjectClaude
    } else {
        PluginOrigin::ProjectKigi
    }
}

/// Project-scoped plugin parent dirs (`.kigi/plugins`, `.claude/plugins`) that
/// exist along the `cwd`→git-worktree-root walk (inclusive), or just `cwd`'s own
/// when `cwd` is not inside a git repo, paired with the resolved git worktree
/// root (when any). This is the exact set [`discover_plugins`] scans for
/// `PluginScope::Project`; the folder-trust gate reuses the same chain via
/// [`project_plugin_dirs_in`] so detection and discovery can never drift. The
/// returned root lets `discover_plugins` reuse it for the marketplace
/// `resolve(root)` branch instead of resolving the repo a second time.
pub fn project_plugin_dirs(cwd: Option<&Path>) -> (Vec<PathBuf>, Option<PathBuf>) {
    let Some(cwd) = cwd else {
        return (Vec::new(), None);
    };
    let chain = crate::repo::RepoDirChain::resolve(cwd);
    (project_plugin_dirs_in(&chain.dirs), chain.git_root)
}

/// Existing project plugin parent dirs (`.kigi/plugins`, `.claude/plugins`)
/// under each dir of a precomputed cwd→git-root chain
/// ([`crate::repo::RepoDirChain`]). The folder-trust gate reuses its one shared
/// chain here so detection and discovery can never drift.
pub fn project_plugin_dirs_in(chain_dirs: &[PathBuf]) -> Vec<PathBuf> {
    crate::repo::existing_subdirs_along(chain_dirs, &[".kigi/plugins", ".claude/plugins"])
}

/// Discover all plugins from the filesystem.
///
/// `cwd` is used to find the git worktree root for project-scope plugins.
/// `project_trusted` is the folder-trust verdict for `cwd`; it gates
/// `Project`-scope plugins (CLI/User/ConfigPath scopes are unaffected).
/// Returns plugins deduplicated by canonical path, with name conflicts
/// resolved by scope precedence.
pub fn discover_plugins(
    cwd: Option<&Path>,
    config: &DiscoveryConfig,
    trust_store: &TrustStore,
    project_trusted: bool,
) -> Vec<DiscoveredPlugin> {
    let _plugin_discovery_timer = crate::timing::timer("plugin_discovery");
    let mut seen_paths: HashSet<PathBuf> = HashSet::new();
    let mut candidates: Vec<DiscoveredPlugin> = Vec::new();

    // 1. CLI --plugin-dir paths
    for dir in &config.cli_plugin_dirs {
        if dir.is_dir() {
            collect_plugin(
                dir,
                PluginScope::CliOverride,
                PluginOrigin::CliOverride,
                trust_store,
                project_trusted,
                &mut seen_paths,
                &mut candidates,
            );
        } else {
            tracing::warn!(path = %dir.display(), "CLI --plugin-dir path is not a directory; skipping");
        }
    }

    // 2-3. Project plugins (.kigi/plugins/, .claude/plugins/) — scan the SAME
    // dirs the folder-trust gate detects, via the shared `project_plugin_dirs`
    // walk (cwd→git root), so discovery and gating can never drift.
    if let Some(cwd) = cwd {
        let (project_dirs, git_root) = project_plugin_dirs(Some(cwd));
        for plugins_dir in project_dirs {
            let origin = project_plugins_dir_origin(&plugins_dir);
            scan_plugin_dir(
                &plugins_dir,
                PluginScope::Project,
                origin,
                trust_store,
                project_trusted,
                &mut seen_paths,
                &mut candidates,
            );
        }

        // 3b. Marketplace plugins (extraKnownMarketplaces in .claude/settings.json).
        // Reuse the git root resolved above (one walk, no second discover).
        if let Some(ref root) = git_root {
            for marketplace in &super::marketplace::resolve(root) {
                for dir in &marketplace.plugin_dirs {
                    collect_plugin(
                        dir,
                        PluginScope::Project,
                        PluginOrigin::ClaudeMarketplace {
                            marketplace: marketplace.name.clone(),
                        },
                        trust_store,
                        project_trusted,
                        &mut seen_paths,
                        &mut candidates,
                    );
                }
            }
        }
    }

    // 4-5. User plugins: $KIGI_SHARE_DIR/plugins, legacy ~/.kigi/plugins, ~/.claude/plugins.
    // Gate the kigi plugins dir on user_kigi_home() so a project's .kigi/plugins
    // is never scanned as user-global when no home resolves.
    let kigi = kigi_config::user_kigi_home();
    let plugin_dirs = user_plugin_dirs(dirs::home_dir().as_deref(), kigi.as_deref());
    for (plugins_dir, origin) in plugin_dirs {
        if plugins_dir.is_dir() {
            scan_plugin_dir(
                &plugins_dir,
                PluginScope::User,
                origin,
                trust_store,
                project_trusted,
                &mut seen_paths,
                &mut candidates,
            );
        }
    }

    // 5a. Known marketplaces (~/.claude/plugins/known_marketplaces.json).
    // Marketplace repos are cloned locally and registered here.
    // Tracks marketplace installs in installed_plugins.json with explicit
    // Each marketplace has a plugins/ (and optionally external_plugins/) subdirectory.
    for marketplace in &super::marketplace::resolve_known_marketplaces() {
        for dir in &marketplace.plugin_dirs {
            collect_plugin(
                dir,
                PluginScope::User,
                PluginOrigin::ClaudeMarketplace {
                    marketplace: marketplace.name.clone(),
                },
                trust_store,
                project_trusted,
                &mut seen_paths,
                &mut candidates,
            );
        }
    }

    // 5b. Installed plugins (from install registry's managed directory)
    {
        // Installed plugins are always User scope (auto-trusted).
        // The user explicitly installed them via marketplace or CLI,
        // so they should be trusted regardless of install_dir location.
        let registry = super::install_registry::InstallRegistry::load();
        collect_installed_plugins(
            &registry,
            PluginScope::User,
            trust_store,
            project_trusted,
            &mut seen_paths,
            &mut candidates,
        );
    }

    // 5c. Installed plugins (~/.claude/plugins/installed_plugins.json).
    // installPath entries (nested under cache/<marketplace>/<plugin>/<version>/).
    // scope wins. Within same scope, first-found (alphabetical by canonical
    // with explicit installPath entries (nested under cache/<marketplace>/<plugin>/<version>/).
    // The plugin name is extracted from the JSON key ("name@marketplace").
    if let Some(home) = dirs::home_dir() {
        let installed_json = home
            .join(".claude")
            .join("plugins")
            .join("installed_plugins.json");
        for (name, marketplace, path) in read_claude_installed_plugins(&installed_json, cwd) {
            if path.is_dir() {
                let before = candidates.len();
                collect_plugin(
                    &path,
                    PluginScope::User,
                    PluginOrigin::ClaudeInstalled { marketplace },
                    trust_store,
                    project_trusted,
                    &mut seen_paths,
                    &mut candidates,
                );
                // Override dirname-derived name with the real name from the JSON key.
                if candidates.len() > before {
                    let plugin = candidates.last_mut().unwrap();
                    if plugin.manifest.name != name {
                        plugin.id = PluginId::new(plugin.scope, &plugin.canonical_root, &name);
                        plugin.manifest.name = name;
                    }
                }
            }
        }
    }

    // 6. Config-path plugins
    for dir in &config.config_paths {
        if dir.is_dir() {
            collect_plugin(
                dir,
                PluginScope::ConfigPath,
                PluginOrigin::ConfigPath,
                trust_store,
                project_trusted,
                &mut seen_paths,
                &mut candidates,
            );
        } else {
            tracing::warn!(path = %dir.display(), "[plugins].paths entry is not a directory; skipping");
        }
    }

    // Resolve name conflicts: within the same plugin_name, highest-priority
    // scope wins. Within same scope, first-found (alphabetical by canonical
    // path) wins.
    resolve_name_conflicts(&mut candidates);

    for p in &candidates {
        tracing::info!(
            name = %p.manifest.name,
            scope = %p.scope,
            root = %p.root.display(),
            skills = p.manifest.skill_dirs(&p.root).len(),
            agents = p.manifest.agent_dirs(&p.root).len(),
            has_hooks = p.hooks_path.is_some(),
            has_mcp = p.mcp_config_path.is_some(),
            has_lsp = p.lsp_config_path.is_some(),
            "plugin discovered"
        );
    }
    tracing::info!(
        discovered = candidates.len(),
        cli = candidates
            .iter()
            .filter(|p| p.scope == PluginScope::CliOverride)
            .count(),
        project = candidates
            .iter()
            .filter(|p| p.scope == PluginScope::Project)
            .count(),
        user = candidates
            .iter()
            .filter(|p| p.scope == PluginScope::User)
            .count(),
        config = candidates
            .iter()
            .filter(|p| p.scope == PluginScope::ConfigPath)
            .count(),
        "plugins: discovery complete"
    );

    candidates
}

// ── Internal helpers ──────────────────────────────────────────────────

/// Scan a plugins parent directory (e.g. `~/.kigi/plugins/`) and collect
/// each subdirectory as a plugin candidate.
fn scan_plugin_dir(
    plugins_dir: &Path,
    scope: PluginScope,
    origin: PluginOrigin,
    trust_store: &TrustStore,
    project_trusted: bool,
    seen_paths: &mut HashSet<PathBuf>,
    candidates: &mut Vec<DiscoveredPlugin>,
) {
    let entries = match std::fs::read_dir(plugins_dir) {
        Ok(entries) => entries,
        Err(e) => {
            tracing::warn!(
                path = %plugins_dir.display(),
                error = %e,
                "failed to read plugins directory"
            );
            return;
        }
    };

    let mut subdirs: Vec<PathBuf> = entries
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_dir()) // follows symlinks
        .map(|e| e.path())
        .collect();

    // Sort for deterministic ordering within same scope
    subdirs.sort();

    for subdir in subdirs {
        collect_plugin(
            &subdir,
            scope,
            origin.clone(),
            trust_store,
            project_trusted,
            seen_paths,
            candidates,
        );
    }
}

fn collect_installed_plugins(
    registry: &super::install_registry::InstallRegistry,
    scope: PluginScope,
    trust_store: &TrustStore,
    project_trusted: bool,
    seen_paths: &mut HashSet<PathBuf>,
    candidates: &mut Vec<DiscoveredPlugin>,
) {
    for (_key, repo) in registry.list() {
        let origin = PluginOrigin::MarketplaceInstall {
            git_url: match &repo.kind {
                super::install_registry::InstallKind::Git { url, .. } => Some(url.clone()),
                super::install_registry::InstallKind::Local { .. } => None,
            },
        };
        for plugin in repo.plugins.values() {
            let plugin_root = match plugin.subdir.as_deref() {
                Some(sub) => {
                    if subdir_escapes(sub) {
                        tracing::warn!(
                            repo = %repo.path.display(),
                            subdir = sub,
                            "skipping installed plugin: registry subdir escapes repo root"
                        );
                        continue;
                    }
                    repo.path.join(sub)
                }
                None => repo.path.clone(),
            };
            if plugin_root.is_dir() {
                collect_plugin(
                    &plugin_root,
                    scope,
                    origin.clone(),
                    trust_store,
                    project_trusted,
                    seen_paths,
                    candidates,
                );
            }
        }
    }
}

fn subdir_escapes(subdir: &str) -> bool {
    let p = Path::new(subdir);
    p.is_absolute()
        || p.components().any(|c| {
            matches!(
                c,
                std::path::Component::ParentDir
                    | std::path::Component::RootDir
                    | std::path::Component::Prefix(_)
            )
        })
}

/// Attempt to load a single plugin directory and add it to candidates.
fn collect_plugin(
    plugin_root: &Path,
    scope: PluginScope,
    origin: PluginOrigin,
    trust_store: &TrustStore,
    project_trusted: bool,
    seen_paths: &mut HashSet<PathBuf>,
    candidates: &mut Vec<DiscoveredPlugin>,
) {
    // Canonicalize for dedup
    let canonical = match dunce::canonicalize(plugin_root) {
        Ok(c) => c,
        Err(e) => {
            tracing::warn!(
                path = %plugin_root.display(),
                error = %e,
                "failed to canonicalize plugin root; skipping"
            );
            return;
        }
    };

    // Deduplicate by canonical path
    if !seen_paths.insert(canonical.clone()) {
        tracing::debug!(
            path = %plugin_root.display(),
            canonical = %canonical.display(),
            "plugin directory already discovered via another path; skipping"
        );
        return;
    }

    // Load manifest or derive from directory name
    let manifest = match load_manifest(plugin_root) {
        Ok(ManifestLoadResult::Found(m)) => *m,
        Ok(ManifestLoadResult::NotFound) => {
            // Convention-based: derive name from directory, check for
            // skills/ or agents/ or .mcp.json or hooks/hooks.json
            let Some(name) = name_from_dirname(plugin_root) else {
                tracing::debug!(
                    path = %plugin_root.display(),
                    "cannot derive plugin name from directory; skipping"
                );
                return;
            };

            // Only treat as a plugin if it has at least one component
            let has_skills =
                plugin_root.join("skills").is_dir() || plugin_root.join("commands").is_dir();
            let has_agents = plugin_root.join("agents").is_dir();
            let has_mcp = plugin_root.join(".mcp.json").is_file();
            let has_lsp = plugin_root.join(".lsp.json").is_file();
            let has_hooks = plugin_root.join("hooks").join("hooks.json").is_file();

            if !has_skills && !has_agents && !has_mcp && !has_lsp && !has_hooks {
                tracing::debug!(
                    path = %plugin_root.display(),
                    "directory has no manifest and no recognized plugin components; skipping"
                );
                return;
            }

            PluginManifest {
                name,
                version: None,
                description: None,
                author: None,
                homepage: None,
                repository: None,
                license: None,
                keywords: vec![],
                skills: None,
                commands: None,
                agents: None,
                hooks: None,
                mcp_servers: None,
                lsp_servers: None,
            }
        }
        Err(e) => {
            tracing::warn!(
                path = %plugin_root.display(),
                error = %e,
                "failed to load plugin manifest; skipping"
            );
            return;
        }
    };

    // Determine trust status. Exhaustive match so a new PluginScope variant is a
    // compile error rather than a silent default.
    let trusted = match scope {
        PluginScope::CliOverride | PluginScope::User => true,
        PluginScope::ConfigPath => {
            TrustStore::is_config_path_auto_trusted(plugin_root)
                || trust_store.is_trusted(plugin_root)
        }
        // Project trust now comes from folder-trust (passed by the caller).
        PluginScope::Project => project_trusted,
    };

    // Build PluginId
    let id = PluginId::new(scope, &canonical, &manifest.name);

    // Resolve component paths
    let skill_dirs = manifest.skill_dirs(plugin_root);
    let command_dirs = manifest.command_dirs(plugin_root);
    let agent_dirs = manifest.agent_dirs(plugin_root);
    let hooks_path = manifest.hooks_path(plugin_root);
    let mcp_config_path = manifest.mcp_config_path(plugin_root);
    let lsp_config_path = manifest.lsp_config_path(plugin_root);

    candidates.push(DiscoveredPlugin {
        manifest,
        id,
        root: plugin_root.to_path_buf(),
        canonical_root: canonical,
        scope,
        origin,
        trusted,
        skill_dirs,
        command_dirs,
        agent_dirs,
        hooks_path,
        mcp_config_path,
        lsp_config_path,
        conflict: None,
    });
}

/// Resolve plugin_name conflicts across scopes.
///
/// Within each name group, keep only the highest-priority candidate
/// (lowest scope ordinal). Log warnings for dropped duplicates.
fn resolve_name_conflicts(candidates: &mut Vec<DiscoveredPlugin>) {
    let mut name_map: HashMap<String, usize> = HashMap::new();
    let mut to_remove: Vec<usize> = Vec::new();
    // (winner_idx, conflict message) pairs to apply after the scan.
    let mut conflict_msgs: Vec<(usize, String)> = Vec::new();

    for (idx, candidate) in candidates.iter().enumerate() {
        let name = candidate.manifest.name.clone();
        match name_map.get(&name) {
            Some(&existing_idx) => {
                let existing = &candidates[existing_idx];
                // Lower scope ordinal = higher priority
                if (candidate.scope as u8) < (existing.scope as u8) {
                    // New candidate wins
                    tracing::warn!(
                        plugin_name = %name,
                        winner = %candidate.root.display(),
                        loser = %existing.root.display(),
                        "plugin name collision resolved by scope precedence"
                    );
                    let msg = format!(
                        "Name collision: shadowing \"{}\" from {}",
                        name,
                        existing.root.display()
                    );
                    conflict_msgs.push((idx, msg));
                    to_remove.push(existing_idx);
                    name_map.insert(name, idx);
                } else {
                    // Existing wins
                    tracing::warn!(
                        plugin_name = %name,
                        winner = %existing.root.display(),
                        loser = %candidate.root.display(),
                        "plugin name collision resolved by scope precedence"
                    );
                    let msg = format!(
                        "Name collision: shadowing \"{}\" from {}",
                        name,
                        candidate.root.display()
                    );
                    conflict_msgs.push((existing_idx, msg));
                    to_remove.push(idx);
                }
            }
            None => {
                name_map.insert(name, idx);
            }
        }
    }

    // Apply conflict messages to winners.
    for (idx, msg) in conflict_msgs {
        candidates[idx].conflict = Some(msg);
    }

    // Remove losers (reverse order to preserve indices)
    to_remove.sort_unstable();
    to_remove.dedup();
    for idx in to_remove.into_iter().rev() {
        candidates.remove(idx);
    }
}

// ── Compat installed_plugins.json types ───────────────────────────────

/// Compat `installed_plugins.json` format.
#[derive(serde::Deserialize)]
struct ClaudeInstalledPlugins {
    #[serde(default)]
    plugins: HashMap<String, Vec<ClaudeInstalledEntry>>,
}

/// A single install entry within `installed_plugins.json`.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClaudeInstalledEntry {
    install_path: PathBuf,
    scope: Option<String>,
    project_path: Option<PathBuf>,
}

/// Whether a compat install entry should surface for this session `cwd`.
///
/// The `local` and `project` scopes are project-tied (as is any entry with
/// a non-empty `projectPath`): only visible when `cwd` is under `project_path`
/// (path-component prefix). Missing/empty project path or cwd cannot prove
/// in-project, so they stay hidden. User/unscoped with no path always surface.
fn claude_install_visible(
    scope: Option<&str>,
    project_path: Option<&Path>,
    cwd: Option<&Path>,
) -> bool {
    let has_project_path = project_path.is_some_and(|p| !p.as_os_str().is_empty());
    let project_gated = matches!(scope, Some("local") | Some("project")) || has_project_path;
    if !project_gated {
        return true;
    }
    let Some(project) = project_path.filter(|p| !p.as_os_str().is_empty()) else {
        return false;
    };
    let Some(cwd) = cwd else {
        return false;
    };
    let cwd = dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
    let project = dunce::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
    cwd.starts_with(&project)
}

/// Read plugin names and install paths from compat `installed_plugins.json`.
///
/// Keys are `"plugin-name@marketplace"` — the plugin name is extracted from
/// before `@`, the marketplace name from after it (when present).
/// Returns `(name, marketplace, path)` tuples. Empty vec on any error.
///
/// Project-tied entries are filtered by `cwd` vs `projectPath` (see
/// [`claude_install_visible`]).
fn read_claude_installed_plugins(
    json_path: &Path,
    cwd: Option<&Path>,
) -> Vec<(String, Option<String>, PathBuf)> {
    let content = match std::fs::read_to_string(json_path) {
        Ok(c) => c,
        Err(_) => return vec![],
    };
    let registry: ClaudeInstalledPlugins = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(e) => {
            tracing::warn!(
                path = %json_path.display(),
                error = %e,
                "failed to parse installed_plugins.json"
            );
            return vec![];
        }
    };

    registry
        .plugins
        .into_iter()
        .flat_map(|(key, entries)| {
            let mut parts = key.splitn(2, '@');
            let name = parts.next().unwrap_or(&key).to_string();
            let marketplace = parts.next().map(String::from);
            entries
                .into_iter()
                .filter(|entry| {
                    claude_install_visible(
                        entry.scope.as_deref(),
                        entry.project_path.as_deref(),
                        cwd,
                    )
                })
                .map(move |entry| (name.clone(), marketplace.clone(), entry.install_path))
        })
        .collect()
}

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

    fn make_manifest_plugin(tmp: &Path, name: &str) -> PathBuf {
        let plugin_dir = tmp.join(name);
        std::fs::create_dir_all(&plugin_dir).unwrap();
        std::fs::write(
            plugin_dir.join("plugin.json"),
            format!(r#"{{"name": "{}"}}"#, name),
        )
        .unwrap();
        plugin_dir
    }

    fn make_convention_plugin(tmp: &Path, name: &str) -> PathBuf {
        let plugin_dir = tmp.join(name);
        std::fs::create_dir_all(plugin_dir.join("skills")).unwrap();
        plugin_dir
    }

    #[test]
    fn user_plugin_dirs_are_kigi_and_claude_only_no_legacy() {
        let home = Path::new("/home/u");
        let kigi = Path::new("/custom/kigihome");
        let dirs = user_plugin_dirs(Some(home), Some(kigi));
        assert!(dirs.contains(&(kigi.join("plugins"), PluginOrigin::UserKigi)));
        assert!(dirs.contains(&(
            home.join(".claude").join("plugins"),
            PluginOrigin::UserClaude
        )));
        // Plugins are not discovered from the legacy ~/.kigi tree.
        assert!(
            !dirs
                .iter()
                .any(|(p, _)| p == &home.join(".kigi").join("plugins"))
        );
    }

    #[test]
    fn user_plugin_dirs_empty_without_home_or_kigi() {
        assert!(user_plugin_dirs(None, None).is_empty());
    }

    #[test]
    fn discover_cli_plugin() {
        let tmp = tempfile::tempdir().unwrap();
        let plugin_dir = make_manifest_plugin(tmp.path(), "cli-tool");

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        collect_plugin(
            &plugin_dir,
            PluginScope::CliOverride,
            PluginOrigin::CliOverride,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );

        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].plugin_name(), "cli-tool");
        assert_eq!(candidates[0].scope, PluginScope::CliOverride);
        assert_eq!(candidates[0].origin, PluginOrigin::CliOverride);
        assert!(candidates[0].trusted);
    }

    #[test]
    fn project_plugins_dir_origin_distinguishes_kigi_and_claude() {
        assert_eq!(
            project_plugins_dir_origin(Path::new("/repo/.kigi/plugins")),
            PluginOrigin::ProjectKigi
        );
        assert_eq!(
            project_plugins_dir_origin(Path::new("/repo/.claude/plugins")),
            PluginOrigin::ProjectClaude
        );
    }

    #[test]
    fn discover_user_plugins() {
        let tmp = tempfile::tempdir().unwrap();

        // Create ~/.kigi/plugins/ structure
        let kigi_plugins = tmp.path().join(".kigi").join("plugins");
        std::fs::create_dir_all(&kigi_plugins).unwrap();
        make_manifest_plugin(&kigi_plugins, "user-tool");

        // Override home dir by directly scanning
        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        scan_plugin_dir(
            &kigi_plugins,
            PluginScope::User,
            PluginOrigin::UserKigi,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );

        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].plugin_name(), "user-tool");
        assert!(candidates[0].trusted);
    }

    #[test]
    fn discover_convention_plugin_no_manifest() {
        let tmp = tempfile::tempdir().unwrap();
        let plugins_dir = tmp.path().join("plugins");
        make_convention_plugin(&plugins_dir, "my-tool");

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        scan_plugin_dir(
            &plugins_dir,
            PluginScope::User,
            PluginOrigin::UserKigi,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );

        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].plugin_name(), "my-tool");
    }

    #[test]
    fn installed_plugins_load_at_registered_subdir_and_root() {
        use crate::plugins::install_registry::{
            InstallKind, InstallRegistry, InstalledRepo, RepoPlugin,
        };
        use std::collections::HashMap;

        let tmp = tempfile::tempdir().unwrap();
        let install_dir = tmp.path().join("installed-plugins");

        let sub_key = "acme-deadbeef";
        let sub_repo_path = install_dir.join(sub_key);
        let sub_plugin_dir = sub_repo_path.join("plugins").join("acme");
        std::fs::create_dir_all(&sub_plugin_dir).unwrap();
        std::fs::write(sub_plugin_dir.join("plugin.json"), r#"{"name": "acme"}"#).unwrap();

        let root_key = "cloud-cafef00d";
        let root_repo_path = install_dir.join(root_key);
        std::fs::create_dir_all(&root_repo_path).unwrap();
        std::fs::write(root_repo_path.join("plugin.json"), r#"{"name": "cloud"}"#).unwrap();

        let mut registry = InstallRegistry::empty(install_dir);
        registry.insert(
            sub_key.to_string(),
            InstalledRepo {
                kind: InstallKind::Local {
                    source_path: sub_repo_path.clone(),
                    subdir: None,
                },
                installed_at: String::new(),
                updated_at: String::new(),
                path: sub_repo_path,
                plugins: HashMap::from([(
                    "acme".to_string(),
                    RepoPlugin {
                        subdir: Some("plugins/acme".to_string()),
                        version: None,
                    },
                )]),
            },
        );
        registry.insert(
            root_key.to_string(),
            InstalledRepo {
                kind: InstallKind::Local {
                    source_path: root_repo_path.clone(),
                    subdir: None,
                },
                installed_at: String::new(),
                updated_at: String::new(),
                path: root_repo_path,
                plugins: HashMap::from([(
                    "cloud".to_string(),
                    RepoPlugin {
                        subdir: None,
                        version: None,
                    },
                )]),
            },
        );

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        collect_installed_plugins(
            &registry,
            PluginScope::User,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );

        let names: Vec<&str> = candidates.iter().map(|p| p.plugin_name()).collect();
        assert_eq!(candidates.len(), 2, "got {names:?}");
        assert!(
            names.contains(&"acme"),
            "subdir plugin should load, got {names:?}"
        );
        assert!(
            names.contains(&"cloud"),
            "root plugin should load, got {names:?}"
        );
        for p in &candidates {
            assert_eq!(
                p.origin,
                PluginOrigin::MarketplaceInstall { git_url: None },
                "direct local installs carry no git URL"
            );
        }
    }

    #[test]
    fn installed_plugins_record_git_origin() {
        use crate::plugins::install_registry::{
            InstallKind, InstallRegistry, InstalledRepo, RepoPlugin,
        };
        use std::collections::HashMap;

        let tmp = tempfile::tempdir().unwrap();
        let install_dir = tmp.path().join("installed-plugins");

        let git_repo_path = install_dir.join("git-plugin-22222222");
        std::fs::create_dir_all(&git_repo_path).unwrap();
        std::fs::write(
            git_repo_path.join("plugin.json"),
            r#"{"name": "git-plugin"}"#,
        )
        .unwrap();

        let mut registry = InstallRegistry::empty(install_dir);
        registry.insert(
            "git-plugin-22222222".to_string(),
            InstalledRepo {
                kind: InstallKind::Git {
                    url: "https://github.com/owner/repo.git".to_string(),
                    git_ref: None,
                    commit: "def".to_string(),
                    subdir: None,
                },
                installed_at: String::new(),
                updated_at: String::new(),
                path: git_repo_path,
                plugins: HashMap::from([(
                    "git-plugin".to_string(),
                    RepoPlugin {
                        subdir: None,
                        version: None,
                    },
                )]),
            },
        );

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        collect_installed_plugins(
            &registry,
            PluginScope::User,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );

        let git = candidates
            .iter()
            .find(|p| p.plugin_name() == "git-plugin")
            .unwrap();
        assert_eq!(
            git.origin,
            PluginOrigin::MarketplaceInstall {
                git_url: Some("https://github.com/owner/repo.git".to_string()),
            }
        );
    }

    #[test]
    fn installed_plugins_skip_subdir_that_escapes_repo() {
        use crate::plugins::install_registry::{
            InstallKind, InstallRegistry, InstalledRepo, RepoPlugin,
        };
        use std::collections::HashMap;

        let tmp = tempfile::tempdir().unwrap();
        let install_dir = tmp.path().join("installed-plugins");

        let escaped_dir = install_dir.join("escaped");
        std::fs::create_dir_all(&escaped_dir).unwrap();
        std::fs::write(
            escaped_dir.join("plugin.json"),
            r#"{"name": "escaped-evil"}"#,
        )
        .unwrap();

        let evil_key = "evil-deadbeef";
        let evil_repo_path = install_dir.join(evil_key);
        std::fs::create_dir_all(&evil_repo_path).unwrap();

        let good_key = "good-cafef00d";
        let good_repo_path = install_dir.join(good_key);
        let good_plugin_dir = good_repo_path.join("plugins").join("good");
        std::fs::create_dir_all(&good_plugin_dir).unwrap();
        std::fs::write(good_plugin_dir.join("plugin.json"), r#"{"name": "good"}"#).unwrap();

        let mut registry = InstallRegistry::empty(install_dir);
        registry.insert(
            evil_key.to_string(),
            InstalledRepo {
                kind: InstallKind::Local {
                    source_path: evil_repo_path.clone(),
                    subdir: None,
                },
                installed_at: String::new(),
                updated_at: String::new(),
                path: evil_repo_path,
                plugins: HashMap::from([(
                    "escaped-evil".to_string(),
                    RepoPlugin {
                        subdir: Some("../escaped".to_string()),
                        version: None,
                    },
                )]),
            },
        );
        registry.insert(
            good_key.to_string(),
            InstalledRepo {
                kind: InstallKind::Local {
                    source_path: good_repo_path.clone(),
                    subdir: None,
                },
                installed_at: String::new(),
                updated_at: String::new(),
                path: good_repo_path,
                plugins: HashMap::from([(
                    "good".to_string(),
                    RepoPlugin {
                        subdir: Some("plugins/good".to_string()),
                        version: None,
                    },
                )]),
            },
        );

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        collect_installed_plugins(
            &registry,
            PluginScope::User,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );

        let names: Vec<&str> = candidates.iter().map(|p| p.plugin_name()).collect();
        assert!(
            names.contains(&"good"),
            "legit subdir plugin should load, got {names:?}"
        );
        assert!(
            !names.contains(&"escaped-evil"),
            "escaping subdir must be skipped, got {names:?}"
        );
    }

    #[test]
    fn dedup_by_canonical_path() {
        let tmp = tempfile::tempdir().unwrap();
        let plugin_dir = make_manifest_plugin(tmp.path(), "dup-test");

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        // Add same plugin twice
        collect_plugin(
            &plugin_dir,
            PluginScope::CliOverride,
            PluginOrigin::CliOverride,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );
        collect_plugin(
            &plugin_dir,
            PluginScope::CliOverride,
            PluginOrigin::CliOverride,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );

        assert_eq!(candidates.len(), 1);
    }

    #[test]
    fn name_conflict_higher_priority_wins() {
        let tmp = tempfile::tempdir().unwrap();

        // Create two plugins with the same name but different scopes
        let cli_dir = tmp.path().join("cli");
        std::fs::create_dir_all(&cli_dir).unwrap();
        std::fs::write(cli_dir.join("plugin.json"), r#"{"name": "my-plugin"}"#).unwrap();

        let user_dir = tmp.path().join("user-plugins");
        std::fs::create_dir_all(&user_dir).unwrap();
        let user_plugin = user_dir.join("my-plugin");
        std::fs::create_dir_all(&user_plugin).unwrap();
        std::fs::write(user_plugin.join("plugin.json"), r#"{"name": "my-plugin"}"#).unwrap();

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();

        // Add CLI plugin first (higher priority)
        collect_plugin(
            &cli_dir,
            PluginScope::CliOverride,
            PluginOrigin::CliOverride,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );
        // Add user plugin (lower priority)
        collect_plugin(
            &user_plugin,
            PluginScope::User,
            PluginOrigin::UserKigi,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );

        resolve_name_conflicts(&mut candidates);

        assert_eq!(candidates.len(), 1);
        assert_eq!(candidates[0].scope, PluginScope::CliOverride);
    }

    #[test]
    fn plugin_id_format() {
        let id = PluginId::new(
            PluginScope::User,
            Path::new("/home/user/.kigi/plugins/my-plugin"),
            "my-plugin",
        );
        assert!(id.0.starts_with("user/"));
        assert!(id.0.ends_with("/my-plugin"));
        // Format: user/<hex8>/my-plugin
        let parts: Vec<&str> = id.0.split('/').collect();
        assert_eq!(parts.len(), 3);
        assert_eq!(parts[0], "user");
        assert_eq!(parts[1].len(), 8); // 8 hex chars
        assert_eq!(parts[2], "my-plugin");
    }

    #[test]
    fn plugin_id_deterministic() {
        let id1 = PluginId::new(PluginScope::User, Path::new("/same/path"), "test");
        let id2 = PluginId::new(PluginScope::User, Path::new("/same/path"), "test");
        assert_eq!(id1, id2);
    }

    #[test]
    fn plugin_id_different_paths() {
        let id1 = PluginId::new(PluginScope::User, Path::new("/path/a"), "test");
        let id2 = PluginId::new(PluginScope::User, Path::new("/path/b"), "test");
        assert_ne!(id1, id2);
    }

    #[test]
    fn empty_dir_skipped() {
        let tmp = tempfile::tempdir().unwrap();
        let empty_dir = tmp.path().join("empty");
        std::fs::create_dir_all(&empty_dir).unwrap();

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        collect_plugin(
            &empty_dir,
            PluginScope::User,
            PluginOrigin::UserKigi,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );

        // No manifest + no skills/agents/mcp/hooks = skipped
        assert!(candidates.is_empty());
    }

    #[test]
    fn project_plugin_untrusted_by_default() {
        let tmp = tempfile::tempdir().unwrap();
        let plugin_dir = make_manifest_plugin(tmp.path(), "project-tool");

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        // Untrusted folder (project_trusted = false) blocks the Project plugin.
        collect_plugin(
            &plugin_dir,
            PluginScope::Project,
            PluginOrigin::ProjectKigi,
            &trust,
            false,
            &mut seen,
            &mut candidates,
        );

        assert_eq!(candidates.len(), 1);
        assert!(!candidates[0].trusted);
    }

    #[test]
    fn project_plugin_trusted_when_folder_trusted() {
        let tmp = tempfile::tempdir().unwrap();
        let plugin_dir = make_manifest_plugin(tmp.path(), "trusted-tool");

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        // Trusted folder (project_trusted = true) allows the Project plugin.
        collect_plugin(
            &plugin_dir,
            PluginScope::Project,
            PluginOrigin::ProjectKigi,
            &trust,
            true,
            &mut seen,
            &mut candidates,
        );

        assert_eq!(candidates.len(), 1);
        assert!(candidates[0].trusted);
    }

    #[test]
    fn non_project_scopes_unaffected_by_project_trusted() {
        // project_trusted = false gates Project scope only: CLI/User stay
        // auto-trusted and ConfigPath keeps using its own trust store.
        let tmp = tempfile::tempdir().unwrap();
        let cli_dir = make_manifest_plugin(tmp.path(), "cli-tool");
        let user_dir = make_manifest_plugin(tmp.path(), "user-tool");
        let config_dir = make_manifest_plugin(tmp.path(), "config-tool");

        let mut trust = TrustStore::load_from(tmp.path().join("trust"));
        trust.grant_trust(&config_dir).unwrap();

        let mut seen = HashSet::new();
        let mut candidates = Vec::new();
        for (dir, scope, origin) in [
            (
                &cli_dir,
                PluginScope::CliOverride,
                PluginOrigin::CliOverride,
            ),
            (&user_dir, PluginScope::User, PluginOrigin::UserKigi),
            (
                &config_dir,
                PluginScope::ConfigPath,
                PluginOrigin::ConfigPath,
            ),
        ] {
            collect_plugin(
                dir,
                scope,
                origin,
                &trust,
                false,
                &mut seen,
                &mut candidates,
            );
        }

        assert_eq!(candidates.len(), 3);
        assert!(
            candidates.iter().all(|c| c.trusted),
            "CLI/User/ConfigPath plugins must stay trusted under project_trusted=false"
        );
    }

    #[test]
    fn discover_real_project_plugin_gated_on_project_trusted() {
        // End-to-end through discover_plugins: a repo-local `.kigi/plugins/<x>/`
        // plugin with an MCP component is trusted iff the folder-trust verdict
        // (project_trusted) allows it. Found by name so any user-scoped plugins
        // on the test host are irrelevant.
        let tmp = tempfile::tempdir().unwrap();
        let plugin_dir = tmp.path().join(".kigi").join("plugins").join("proj-mcp");
        std::fs::create_dir_all(&plugin_dir).unwrap();
        std::fs::write(plugin_dir.join("plugin.json"), r#"{"name": "proj-mcp"}"#).unwrap();
        std::fs::write(plugin_dir.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let config = DiscoveryConfig::default();

        // Untrusted folder: the project plugin comes back blocked.
        let untrusted = discover_plugins(Some(tmp.path()), &config, &trust, false);
        let p = untrusted
            .iter()
            .find(|p| p.manifest.name == "proj-mcp")
            .expect("project plugin discovered");
        assert_eq!(p.scope, PluginScope::Project);
        assert_eq!(p.origin, PluginOrigin::ProjectKigi);
        assert!(!p.trusted, "untrusted folder must block the project plugin");

        // Trusted folder: the same plugin is allowed.
        let trusted = discover_plugins(Some(tmp.path()), &config, &trust, true);
        let p = trusted
            .iter()
            .find(|p| p.manifest.name == "proj-mcp")
            .expect("project plugin discovered");
        assert!(p.trusted, "trusted folder must allow the project plugin");
    }

    #[test]
    fn discover_project_claude_plugin_records_claude_origin() {
        // Unique name: discover_plugins also scans the dev machine's real
        // user dirs, and this test finds its plugin by name.
        let name = format!("proj-claude-tool-{}", std::process::id());
        let tmp = tempfile::tempdir().unwrap();
        let plugin_dir = tmp.path().join(".claude").join("plugins").join(&name);
        std::fs::create_dir_all(plugin_dir.join("skills")).unwrap();
        std::fs::write(
            plugin_dir.join("plugin.json"),
            format!(r#"{{"name": "{name}"}}"#),
        )
        .unwrap();

        let trust = TrustStore::load_from(tmp.path().join("trust"));
        let config = DiscoveryConfig::default();
        let discovered = discover_plugins(Some(tmp.path()), &config, &trust, true);
        let p = discovered
            .iter()
            .find(|p| p.manifest.name == name)
            .expect("project claude plugin discovered");
        assert_eq!(p.scope, PluginScope::Project);
        assert_eq!(p.origin, PluginOrigin::ProjectClaude);
    }

    #[test]
    fn read_claude_installed_plugins_json() {
        let tmp = tempfile::tempdir().unwrap();

        // Create fake plugin directories matching the compat cache layout
        let cache = tmp.path().join("cache");
        let plugin_a = cache.join("marketplace-a").join("my-lsp").join("1.0.0");
        let plugin_b = cache.join("marketplace-b").join("my-tool").join("2.0.0");
        std::fs::create_dir_all(plugin_a.join("skills")).unwrap();
        std::fs::create_dir_all(&plugin_b).unwrap();
        std::fs::write(plugin_b.join("plugin.json"), r#"{"name": "my-tool"}"#).unwrap();

        // Write installed_plugins.json
        let json_path = tmp.path().join("installed_plugins.json");
        let json = serde_json::json!({
            "version": 2,
            "plugins": {
                "my-lsp@marketplace-a": [{
                    "scope": "user",
                    "installPath": plugin_a.to_string_lossy(),
                    "version": "1.0.0"
                }],
                "my-tool@marketplace-b": [{
                    "scope": "user",
                    "installPath": plugin_b.to_string_lossy(),
                    "version": "2.0.0"
                }]
            }
        });
        std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();

        let results = read_claude_installed_plugins(&json_path, None);
        assert_eq!(results.len(), 2);

        let names: Vec<&str> = results.iter().map(|(n, _, _)| n.as_str()).collect();
        assert!(names.contains(&"my-lsp"));
        assert!(names.contains(&"my-tool"));

        let paths: Vec<&PathBuf> = results.iter().map(|(_, _, p)| p).collect();
        assert!(paths.contains(&&plugin_a));
        assert!(paths.contains(&&plugin_b));

        let lsp = results.iter().find(|(n, _, _)| n == "my-lsp").unwrap();
        assert_eq!(lsp.1.as_deref(), Some("marketplace-a"));
        let tool = results.iter().find(|(n, _, _)| n == "my-tool").unwrap();
        assert_eq!(tool.1.as_deref(), Some("marketplace-b"));
    }

    #[test]
    fn read_claude_installed_plugins_key_without_marketplace() {
        let tmp = tempfile::tempdir().unwrap();
        let plugin = tmp.path().join("bare");
        std::fs::create_dir_all(&plugin).unwrap();
        let json_path = tmp.path().join("installed_plugins.json");
        let json = serde_json::json!({
            "plugins": { "bare": [{ "installPath": plugin.to_string_lossy() }] }
        });
        std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();

        let results = read_claude_installed_plugins(&json_path, None);
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].0, "bare");
        assert_eq!(results[0].1, None);
    }

    #[test]
    fn claude_local_install_included_when_cwd_under_project() {
        let tmp = tempfile::tempdir().unwrap();
        let project = tmp.path().join("proj");
        let nested = project.join("src");
        let plugin = tmp.path().join("cache").join("local-plug");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::create_dir_all(&plugin).unwrap();
        let json_path = tmp.path().join("installed_plugins.json");
        let json = serde_json::json!({
            "plugins": {
                "local-plug@mp": [{
                    "scope": "local",
                    "projectPath": project.to_string_lossy(),
                    "installPath": plugin.to_string_lossy()
                }]
            }
        });
        std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();

        let results = read_claude_installed_plugins(&json_path, Some(&nested));
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].0, "local-plug");
        assert_eq!(results[0].2, plugin);
    }

    #[test]
    fn claude_local_install_excluded_when_cwd_outside_project() {
        let tmp = tempfile::tempdir().unwrap();
        let project = tmp.path().join("proj");
        let other = tmp.path().join("other");
        let plugin = tmp.path().join("cache").join("local-plug");
        std::fs::create_dir_all(&project).unwrap();
        std::fs::create_dir_all(&other).unwrap();
        std::fs::create_dir_all(&plugin).unwrap();
        let json_path = tmp.path().join("installed_plugins.json");
        let json = serde_json::json!({
            "plugins": {
                "local-plug@mp": [{
                    "scope": "local",
                    "projectPath": project.to_string_lossy(),
                    "installPath": plugin.to_string_lossy()
                }]
            }
        });
        std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();

        let results = read_claude_installed_plugins(&json_path, Some(&other));
        assert!(results.is_empty());
    }

    #[test]
    fn claude_user_and_unscoped_installs_included_regardless_of_cwd() {
        let tmp = tempfile::tempdir().unwrap();
        let project = tmp.path().join("proj");
        let other = tmp.path().join("other");
        let user_plugin = tmp.path().join("cache").join("user-plug");
        let bare_plugin = tmp.path().join("cache").join("bare-plug");
        std::fs::create_dir_all(&project).unwrap();
        std::fs::create_dir_all(&other).unwrap();
        std::fs::create_dir_all(&user_plugin).unwrap();
        std::fs::create_dir_all(&bare_plugin).unwrap();
        let json_path = tmp.path().join("installed_plugins.json");
        let json = serde_json::json!({
            "plugins": {
                "user-plug@mp": [{
                    "scope": "user",
                    "installPath": user_plugin.to_string_lossy()
                }],
                "bare-plug@mp": [{
                    "installPath": bare_plugin.to_string_lossy()
                }],
                "local-plug@mp": [{
                    "scope": "local",
                    "projectPath": project.to_string_lossy(),
                    "installPath": tmp.path().join("cache").join("local-plug").to_string_lossy()
                }]
            }
        });
        std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();

        let results = read_claude_installed_plugins(&json_path, Some(&other));
        let names: Vec<&str> = results.iter().map(|(n, _, _)| n.as_str()).collect();
        assert!(names.contains(&"user-plug"));
        assert!(names.contains(&"bare-plug"));
        assert!(!names.contains(&"local-plug"));
    }

    #[test]
    fn claude_local_install_excluded_without_project_path_or_cwd() {
        let tmp = tempfile::tempdir().unwrap();
        let plugin = tmp.path().join("cache").join("local-plug");
        std::fs::create_dir_all(&plugin).unwrap();
        let json_path = tmp.path().join("installed_plugins.json");
        let json = serde_json::json!({
            "plugins": {
                "no-path@mp": [{
                    "scope": "local",
                    "installPath": plugin.to_string_lossy()
                }],
                "empty-path@mp": [{
                    "scope": "local",
                    "projectPath": "",
                    "installPath": plugin.to_string_lossy()
                }]
            }
        });
        std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();

        assert!(read_claude_installed_plugins(&json_path, Some(tmp.path())).is_empty());

        let project = tmp.path().join("proj");
        std::fs::create_dir_all(&project).unwrap();
        let json = serde_json::json!({
            "plugins": {
                "has-path@mp": [{
                    "scope": "local",
                    "projectPath": project.to_string_lossy(),
                    "installPath": plugin.to_string_lossy()
                }]
            }
        });
        std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();
        assert!(read_claude_installed_plugins(&json_path, None).is_empty());
    }

    #[test]
    fn claude_install_visible_path_boundary_not_string_prefix() {
        let tmp = tempfile::tempdir().unwrap();
        let project = tmp.path().join("proj");
        let sibling = tmp.path().join("proj-other");
        std::fs::create_dir_all(&project).unwrap();
        std::fs::create_dir_all(&sibling).unwrap();
        assert!(claude_install_visible(
            Some("local"),
            Some(project.as_path()),
            Some(project.as_path())
        ));
        assert!(!claude_install_visible(
            Some("local"),
            Some(project.as_path()),
            Some(sibling.as_path())
        ));
    }

    #[test]
    fn claude_project_install_excluded_when_cwd_outside_project() {
        let tmp = tempfile::tempdir().unwrap();
        let project = tmp.path().join("proj");
        let other = tmp.path().join("other");
        let plugin = tmp.path().join("cache").join("team-plug");
        std::fs::create_dir_all(&project).unwrap();
        std::fs::create_dir_all(&other).unwrap();
        std::fs::create_dir_all(&plugin).unwrap();
        let json_path = tmp.path().join("installed_plugins.json");
        let json = serde_json::json!({
            "plugins": {
                "team-plug@mp": [{
                    "scope": "project",
                    "projectPath": project.to_string_lossy(),
                    "installPath": plugin.to_string_lossy()
                }]
            }
        });
        std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();

        assert!(read_claude_installed_plugins(&json_path, Some(&other)).is_empty());
        let results = read_claude_installed_plugins(&json_path, Some(&project));
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].0, "team-plug");
    }

    #[test]
    fn claude_entry_with_project_path_gated_even_without_local_scope() {
        let tmp = tempfile::tempdir().unwrap();
        let project = tmp.path().join("proj");
        let other = tmp.path().join("other");
        let plugin = tmp.path().join("cache").join("path-only");
        std::fs::create_dir_all(&project).unwrap();
        std::fs::create_dir_all(&other).unwrap();
        std::fs::create_dir_all(&plugin).unwrap();
        let json_path = tmp.path().join("installed_plugins.json");
        let json = serde_json::json!({
            "plugins": {
                "path-only@mp": [{
                    "projectPath": project.to_string_lossy(),
                    "installPath": plugin.to_string_lossy()
                }]
            }
        });
        std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();

        assert!(read_claude_installed_plugins(&json_path, Some(&other)).is_empty());
        assert_eq!(
            read_claude_installed_plugins(&json_path, Some(&project)).len(),
            1
        );
    }
}
