use crate::skills::catalog::scan::discover_skills;
use crate::skills::catalog::types::{
    ConfigEntry, SkillCandidate, SkillsConfig, SkillsPaths, TargetConflictStrategy, TargetMode,
};
use crate::skills::cli::interactive::is_interactive;
use crate::skills::cli::tui_picker;
use crate::skills::cli::tui_picker::{TuiEntry, TuiEntryKind};
use crate::skills::config::load_config;
use crate::skills::shared::git::find_git_root;
use crate::skills::targets::sync::SkillSyncCancelled;
use crate::skills::targets::sync::{apply_target_diff, is_skill_present};
use anyhow::Result;
use clap::{Args, ValueEnum};
use inquire::error::InquireError;
use inquire::{Confirm, Select};
use std::collections::{HashMap, HashSet};
use std::env;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Args)]
#[command(about = "Manage skills", long_about = "Interactive skills manager")]
pub struct SkillsArgs {
    /// Removed: relink sources is no longer supported
    #[arg(long = "relink-sources", hide = true)]
    pub relink_sources_removed: bool,

    /// Conflict policy when a target entry already exists (overwrite or skip)
    #[arg(long = "target-conflict", value_enum)]
    pub target_conflict: Option<TargetConflictArg>,

    /// Override skills root directory (env: LLMAN_SKILLS_DIR)
    #[arg(long = "skills-dir")]
    pub skills_dir: Option<std::path::PathBuf>,
}

#[derive(ValueEnum, Debug, Clone, Copy)]
pub enum TargetConflictArg {
    Overwrite,
    Skip,
}

#[derive(Clone, Debug)]
struct RuntimePreset {
    skill_dirs: Vec<String>,
}

#[derive(Clone)]
struct PresetOption {
    name: String,
    skill_ids: Vec<String>,
    label: String,
}

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

#[derive(Clone)]
enum SkillOption {
    Preset(PresetOption),
    Skill { skill_id: String, label: String },
}

struct InteractiveSelection {
    target: ConfigEntry,
    selected: HashSet<String>,
}

#[derive(Clone, Debug, Default)]
struct SkillDirCatalog {
    dir_to_skill_id: HashMap<String, String>,
    skill_id_to_dir_name: HashMap<String, String>,
}

pub fn run(args: &SkillsArgs) -> Result<()> {
    if args.relink_sources_removed {
        return Err(anyhow::anyhow!(t!("skills.relink_removed")));
    }
    let interactive = is_interactive();

    let paths = SkillsPaths::resolve_with_override(args.skills_dir.as_deref())?;
    paths.ensure_dirs()?;
    let legacy_registry_path = paths.root.join("registry.json");
    if legacy_registry_path.exists() {
        eprintln!(
            "{}",
            t!(
                "skills.manager.legacy_registry_detected",
                path = legacy_registry_path.display()
            )
        );
    }
    let config = load_config(&paths)?;
    let target_conflict = args.target_conflict.map(TargetConflictStrategy::from);
    let skills = dedupe_skills(discover_skills(&paths.root)?);
    let skill_dir_catalog = build_skill_dir_catalog(&paths.root, &skills)?;
    let runtime_presets = infer_runtime_presets_from_catalog(&skill_dir_catalog);

    if skills.is_empty() {
        println!("{}", t!("skills.manager.no_skills"));
        return Ok(());
    }

    if interactive {
        let Some(interactive_selection) =
            run_interactive_selection(&skills, &config, &runtime_presets, &skill_dir_catalog)?
        else {
            return Ok(());
        };
        if !confirm_apply(&interactive_selection.target)? {
            return Ok(());
        }
        match apply_target_diff(
            &skills,
            &interactive_selection.target,
            &interactive_selection.selected,
            true,
            target_conflict,
        ) {
            Ok(()) => {}
            Err(e) if e.is::<SkillSyncCancelled>() => {
                println!("{}", t!("messages.operation_cancelled"));
                return Ok(());
            }
            Err(e) => return Err(e),
        }
    } else {
        for target in &config.targets {
            let desired: HashSet<String> = skills
                .iter()
                .filter_map(|skill| {
                    if is_skill_present(skill, target) || target.enabled {
                        Some(skill.skill_id.clone())
                    } else {
                        None
                    }
                })
                .collect();
            apply_target_diff(&skills, target, &desired, false, target_conflict)?;
        }
    }
    Ok(())
}

fn run_interactive_selection(
    skills: &[SkillCandidate],
    config: &SkillsConfig,
    runtime_presets: &HashMap<String, RuntimePreset>,
    skill_dir_catalog: &SkillDirCatalog,
) -> Result<Option<InteractiveSelection>> {
    let Some(target) = select_target(config)? else {
        return Ok(None);
    };
    let Some(selected) =
        select_skills_for_target(skills, &target, config, skill_dir_catalog, runtime_presets)?
    else {
        return Ok(None);
    };
    Ok(Some(InteractiveSelection { target, selected }))
}

fn preset_display_label(name: &str, count: usize) -> String {
    format!("{} ({} skills)", name, count)
}

fn infer_runtime_presets_from_catalog(
    skill_dir_catalog: &SkillDirCatalog,
) -> HashMap<String, RuntimePreset> {
    let mut out = HashMap::<String, RuntimePreset>::new();
    for dir_name in skill_dir_catalog.dir_to_skill_id.keys() {
        let Some((preset_name, _)) = dir_name.split_once('.') else {
            continue;
        };
        let entry = out
            .entry(preset_name.to_string())
            .or_insert_with(|| RuntimePreset {
                skill_dirs: Vec::new(),
            });
        if !entry.skill_dirs.iter().any(|existing| existing == dir_name) {
            entry.skill_dirs.push(dir_name.to_string());
        }
    }
    out
}

fn resolve_preset_skill_ids(
    presets: &HashMap<String, RuntimePreset>,
    preset_name: &str,
    skill_dir_catalog: &SkillDirCatalog,
) -> Result<Vec<String>> {
    let dirs = presets
        .get(preset_name)
        .map(|preset| preset.skill_dirs.clone())
        .unwrap_or_default();
    let mut out = Vec::new();
    for dir in dirs {
        if let Some(skill_id) = skill_dir_catalog.dir_to_skill_id.get(dir.as_str())
            && !out.iter().any(|existing| existing == skill_id)
        {
            out.push(skill_id.clone());
        }
    }
    if out.is_empty() {
        return Err(anyhow::anyhow!(t!(
            "skills.manager.preset_empty",
            preset = preset_name
        )));
    }
    Ok(out)
}

fn build_skill_dir_catalog(root: &Path, skills: &[SkillCandidate]) -> Result<SkillDirCatalog> {
    let root_skill_dirs = discover_root_skill_dirs(root)?;
    if root_skill_dirs.is_empty() {
        return Ok(SkillDirCatalog::default());
    }

    let mut canonical_to_skill_id: HashMap<PathBuf, String> = HashMap::new();
    for skill in skills {
        let Ok(canonical) = fs::canonicalize(&skill.skill_dir) else {
            continue;
        };
        canonical_to_skill_id
            .entry(canonical)
            .or_insert_with(|| skill.skill_id.clone());
    }

    let mut dir_to_skill_id = HashMap::new();
    for (dir_name, canonical_path) in root_skill_dirs {
        if let Some(skill_id) = canonical_to_skill_id.get(&canonical_path) {
            dir_to_skill_id.insert(dir_name, skill_id.clone());
        }
    }

    let mut skill_id_to_dir_name = HashMap::new();
    for (dir_name, skill_id) in &dir_to_skill_id {
        match skill_id_to_dir_name.get(skill_id) {
            None => {
                skill_id_to_dir_name.insert(skill_id.clone(), dir_name.clone());
            }
            Some(existing) if is_better_dir_name(dir_name, existing) => {
                skill_id_to_dir_name.insert(skill_id.clone(), dir_name.clone());
            }
            _ => {}
        }
    }

    Ok(SkillDirCatalog {
        dir_to_skill_id,
        skill_id_to_dir_name,
    })
}

fn is_better_dir_name(candidate: &str, existing: &str) -> bool {
    match (candidate.contains('.'), existing.contains('.')) {
        (true, false) => true,
        (false, true) => false,
        _ => candidate < existing,
    }
}

fn discover_root_skill_dirs(root: &Path) -> Result<HashMap<String, PathBuf>> {
    let mut out = HashMap::new();
    if !root.exists() {
        return Ok(out);
    }

    for entry in fs::read_dir(root)? {
        let entry = entry?;
        let path = entry.path();
        let dir_name = entry.file_name().to_string_lossy().to_string();
        if dir_name == "store" {
            continue;
        }

        let Ok(meta) = fs::symlink_metadata(&path) else {
            continue;
        };
        let is_dir = if meta.file_type().is_symlink() {
            fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false)
        } else {
            meta.is_dir()
        };
        if !is_dir {
            continue;
        }

        let skill_file = path.join("SKILL.md");
        if !fs::metadata(&skill_file)
            .map(|m| m.is_file())
            .unwrap_or(false)
        {
            continue;
        }

        let Ok(canonical_path) = fs::canonicalize(&path) else {
            continue;
        };
        out.insert(dir_name, canonical_path);
    }

    Ok(out)
}

fn skill_dir_name(skill: &SkillCandidate) -> Option<&str> {
    skill.skill_dir.file_name()?.to_str()
}

fn select_target(config: &SkillsConfig) -> Result<Option<ConfigEntry>> {
    #[derive(Clone)]
    enum AgentSelection {
        Agent { choice: AgentChoice },
        Exit { label: String },
    }

    impl fmt::Display for AgentSelection {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                Self::Agent { choice } => write!(f, "{}", choice.label),
                Self::Exit { label } => write!(f, "{label}"),
            }
        }
    }

    #[derive(Clone)]
    enum ScopeSelection {
        Scope { choice: ScopeChoice },
        Exit { label: String },
    }

    impl fmt::Display for ScopeSelection {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                Self::Scope { choice } => write!(f, "{}", choice.label),
                Self::Exit { label } => write!(f, "{label}"),
            }
        }
    }

    loop {
        let mut agent_choices: Vec<AgentSelection> = selectable_agents(config)
            .into_iter()
            .map(|choice| AgentSelection::Agent { choice })
            .collect();
        let exit_label = format!("[{}]", t!("skills.manager.exit"));
        agent_choices.push(AgentSelection::Exit {
            label: exit_label.clone(),
        });

        let selection =
            match Select::new(&t!("skills.manager.select_agent_tools"), agent_choices).prompt() {
                Ok(selection) => selection,
                Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
                    return Ok(None);
                }
                Err(e) => {
                    return Err(anyhow::anyhow!(t!(
                        "skills.manager.prompt_failed",
                        error = e
                    )));
                }
            };

        let agent = match selection {
            AgentSelection::Exit { .. } => return Ok(None),
            AgentSelection::Agent { choice } => choice.agent,
        };

        let scope_choices = scopes_for_agent(config, &agent);
        if scope_choices.is_empty() {
            continue;
        }

        let mut choices: Vec<ScopeSelection> = scope_choices
            .into_iter()
            .map(|choice| ScopeSelection::Scope { choice })
            .collect();
        choices.push(ScopeSelection::Exit { label: exit_label });

        let scope_selection =
            match Select::new(&t!("skills.manager.select_scope"), choices).prompt() {
                Ok(selection) => selection,
                Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
                    return Ok(None);
                }
                Err(e) => {
                    return Err(anyhow::anyhow!(t!(
                        "skills.manager.prompt_failed",
                        error = e
                    )));
                }
            };

        match scope_selection {
            ScopeSelection::Exit { .. } => return Ok(None),
            ScopeSelection::Scope { choice } => {
                if choice.target.mode == TargetMode::Skip {
                    println!("{}", t!("skills.manager.read_only"));
                    continue;
                }
                return Ok(Some(choice.target));
            }
        }
    }
}

#[derive(Clone)]
struct AgentChoice {
    agent: String,
    label: String,
}

#[derive(Clone)]
struct ScopeChoice {
    target: ConfigEntry,
    label: String,
}

fn selectable_agents(config: &SkillsConfig) -> Vec<AgentChoice> {
    let mut unique = HashSet::new();
    for target in &config.targets {
        unique.insert(target.agent.clone());
    }
    let mut agents = unique.into_iter().collect::<Vec<_>>();
    agents.sort_by(|a, b| agent_order(a).cmp(&agent_order(b)).then_with(|| a.cmp(b)));
    agents
        .into_iter()
        .map(|agent| AgentChoice {
            label: display_agent_label(&agent),
            agent,
        })
        .collect()
}

fn scopes_for_agent(config: &SkillsConfig, agent: &str) -> Vec<ScopeChoice> {
    let mut scopes: Vec<ScopeChoice> = config
        .targets
        .iter()
        .filter(|target| target.agent == agent)
        .map(|target| {
            let mut label = display_scope_label(agent, &target.scope);
            if target.mode == TargetMode::Skip {
                label = format!("{} - {}", t!("skills.manager.state_skip"), label);
            }
            ScopeChoice {
                target: target.clone(),
                label,
            }
        })
        .collect();

    let prefer_project = should_prefer_project_scope();
    scopes.sort_by(|a, b| {
        scope_order(&a.target.agent, &a.target.scope, prefer_project)
            .cmp(&scope_order(
                &b.target.agent,
                &b.target.scope,
                prefer_project,
            ))
            .then_with(|| a.label.cmp(&b.label))
            .then_with(|| a.target.id.cmp(&b.target.id))
    });

    let mut label_counts: HashMap<String, usize> = HashMap::new();
    for scope in &scopes {
        *label_counts.entry(scope.label.clone()).or_default() += 1;
    }
    for scope in &mut scopes {
        if label_counts.get(&scope.label).copied().unwrap_or(0) > 1 {
            scope.label = format!("{} ({})", scope.label, scope.target.id);
        }
    }

    scopes
}

fn display_agent_label(agent: &str) -> String {
    agent.to_string()
}

fn display_scope_label(agent: &str, scope: &str) -> String {
    match (agent, scope) {
        ("claude", "user") => "Personal (All your projects)".to_string(),
        ("claude", "project") => "Project (This project only)".to_string(),
        ("agents", "project") => ".agents (This project only)".to_string(),
        _ => scope.to_string(),
    }
}

fn agent_order(agent: &str) -> u8 {
    match agent {
        "claude" => 0,
        "agents" => 1,
        _ => 2,
    }
}

/// Returns `true` when the current directory is inside a git repository and
/// is not the user's home directory. In that case project-level scopes should
/// appear before the user-level (global) scope.
fn should_prefer_project_scope() -> bool {
    let cwd = match env::current_dir() {
        Ok(dir) => dir,
        Err(_) => return false,
    };

    // Home directory → always prefer global scope
    let home = crate::config::try_home_dir();
    if home.as_deref() == Some(&cwd) {
        return false;
    }

    // Inside a git repo → prefer project scope
    find_git_root(&cwd).is_some()
}

fn scope_order(agent: &str, scope: &str, prefer_project_first: bool) -> u8 {
    match (agent, scope) {
        ("claude", "user") => {
            if prefer_project_first {
                1
            } else {
                0
            }
        }
        ("claude", "project") => {
            if prefer_project_first {
                0
            } else {
                1
            }
        }
        ("agents", "project") => 2,
        _ => 10,
    }
}

fn select_skills_for_target(
    skills: &[SkillCandidate],
    target: &crate::skills::catalog::types::ConfigEntry,
    config: &SkillsConfig,
    skill_dir_catalog: &SkillDirCatalog,
    runtime_presets: &HashMap<String, RuntimePreset>,
) -> Result<Option<HashSet<String>>> {
    let visible_skills = visible_skills_for_target(skills, target, config);
    let hidden_count = skills.len().saturating_sub(visible_skills.len());
    if matches!(target.scope.as_str(), "project" | "repo") && hidden_count > 0 {
        println!(
            "{}",
            t!(
                "skills.manager.hidden_user_scope_skills_hint",
                count = hidden_count
            )
        );
    }
    let options = grouped_skill_options(&visible_skills, skill_dir_catalog, runtime_presets)?;
    let default_skill_indexes = default_skill_indexes(&visible_skills, &options, target, config);
    let defaults = default_indexes_with_preset_state(&options, &default_skill_indexes);
    let default_selected_skills = selected_skill_ids_from_indexes(&options, &defaults);

    let tui_entries = options_to_tui_entries(&options);
    tui_picker::pick(
        &t!("skills.manager.select_skills"),
        &tui_entries,
        &default_selected_skills,
    )
}

fn options_to_tui_entries(options: &[SkillOption]) -> Vec<TuiEntry> {
    options
        .iter()
        .map(|option| match option {
            SkillOption::Preset(PresetOption {
                skill_ids, label, ..
            }) => TuiEntry {
                label: label.clone(),
                kind: TuiEntryKind::Preset {
                    skill_ids: skill_ids.clone(),
                },
            },
            SkillOption::Skill { skill_id, label } => TuiEntry {
                label: label.clone(),
                kind: TuiEntryKind::Skill {
                    skill_id: skill_id.clone(),
                },
            },
        })
        .collect()
}

fn grouped_skill_options(
    skills: &[SkillCandidate],
    skill_dir_catalog: &SkillDirCatalog,
    runtime_presets: &HashMap<String, RuntimePreset>,
) -> Result<Vec<SkillOption>> {
    let mut out = preset_options_from_skills(skills, skill_dir_catalog, runtime_presets)?;

    let mut entries: Vec<(String, String)> = skills
        .iter()
        .map(|skill| {
            let dir_name = display_dir_name_for_skill(skill, skill_dir_catalog);
            let label = format!("{} ({})", skill.skill_id, dir_name);
            (skill.skill_id.clone(), label)
        })
        .collect();
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    for (skill_id, label) in entries {
        out.push(SkillOption::Skill { skill_id, label });
    }

    Ok(out)
}

fn default_indexes_with_preset_state(
    options: &[SkillOption],
    default_skill_indexes: &[usize],
) -> Vec<usize> {
    let selected_skill_ids = selected_skill_ids_from_indexes(options, default_skill_indexes);

    let mut out = default_skill_indexes.to_vec();
    for (idx, option) in options.iter().enumerate() {
        if let SkillOption::Preset(PresetOption { skill_ids, .. }) = option
            && !skill_ids.is_empty()
            && skill_ids
                .iter()
                .all(|skill_id| selected_skill_ids.contains(skill_id))
        {
            out.push(idx);
        }
    }

    out.sort_unstable();
    out.dedup();
    out
}

fn selected_skill_ids_from_indexes(options: &[SkillOption], indexes: &[usize]) -> HashSet<String> {
    indexes
        .iter()
        .filter_map(|index| match options.get(*index) {
            Some(SkillOption::Skill { skill_id, .. }) => Some(skill_id.clone()),
            _ => None,
        })
        .collect()
}

fn preset_options_from_skills(
    skills: &[SkillCandidate],
    skill_dir_catalog: &SkillDirCatalog,
    runtime_presets: &HashMap<String, RuntimePreset>,
) -> Result<Vec<SkillOption>> {
    let visible_skill_ids: HashSet<&str> =
        skills.iter().map(|skill| skill.skill_id.as_str()).collect();
    let mut out = Vec::new();

    let mut preset_names = runtime_presets.keys().cloned().collect::<Vec<_>>();
    preset_names.sort();
    for preset_name in preset_names {
        let Some(preset) = runtime_presets.get(&preset_name) else {
            continue;
        };
        let resolved_ids =
            resolve_preset_skill_ids(runtime_presets, &preset_name, skill_dir_catalog)?;
        let skill_ids = resolved_ids
            .into_iter()
            .filter(|skill_id| visible_skill_ids.contains(skill_id.as_str()))
            .collect::<Vec<_>>();
        if skill_ids.is_empty() {
            continue;
        }
        out.push(SkillOption::Preset(PresetOption {
            name: preset_name.clone(),
            skill_ids,
            label: preset_display_label(&preset_name, preset.skill_dirs.len()),
        }));
    }

    let grouped = infer_group_presets(skills, skill_dir_catalog);
    for option in grouped {
        if out
            .iter()
            .any(|existing| matches!(existing, SkillOption::Preset(existing) if existing.name == option.name))
        {
            continue;
        }
        out.push(SkillOption::Preset(option));
    }

    out.sort_by(|a, b| match (a, b) {
        (SkillOption::Preset(a), SkillOption::Preset(b)) => a.name.cmp(&b.name),
        (SkillOption::Preset(_), SkillOption::Skill { .. }) => std::cmp::Ordering::Less,
        (SkillOption::Skill { .. }, SkillOption::Preset(_)) => std::cmp::Ordering::Greater,
        (SkillOption::Skill { label: a, .. }, SkillOption::Skill { label: b, .. }) => a.cmp(b),
    });

    Ok(out)
}

fn infer_group_presets(
    skills: &[SkillCandidate],
    skill_dir_catalog: &SkillDirCatalog,
) -> Vec<PresetOption> {
    let mut groups: HashMap<String, Vec<String>> = HashMap::new();
    for skill in skills {
        let dir_name = display_dir_name_for_skill(skill, skill_dir_catalog);
        let group =
            infer_skill_group_from_dir(&dir_name).unwrap_or_else(|| "ungrouped".to_string());
        groups
            .entry(group)
            .or_default()
            .push(skill.skill_id.clone());
    }

    let mut group_names = groups.keys().cloned().collect::<Vec<_>>();
    group_names.sort_by(|a, b| {
        if a == "ungrouped" {
            return std::cmp::Ordering::Greater;
        }
        if b == "ungrouped" {
            return std::cmp::Ordering::Less;
        }
        a.cmp(b)
    });

    let mut out = Vec::new();
    for group_name in group_names {
        let Some(mut skill_ids) = groups.remove(&group_name) else {
            continue;
        };
        skill_ids.sort();
        skill_ids.dedup();
        out.push(PresetOption {
            name: group_name.clone(),
            label: preset_display_label(&group_name, skill_ids.len()),
            skill_ids,
        });
    }

    out
}

fn infer_skill_group_from_dir(dir_name: &str) -> Option<String> {
    dir_name
        .split_once('.')
        .map(|(group, _)| group.to_string())
        .filter(|group| !group.is_empty())
}

fn display_dir_name_for_skill(
    skill: &SkillCandidate,
    skill_dir_catalog: &SkillDirCatalog,
) -> String {
    skill_dir_catalog
        .skill_id_to_dir_name
        .get(&skill.skill_id)
        .cloned()
        .or_else(|| skill_dir_name(skill).map(ToOwned::to_owned))
        .unwrap_or_else(|| skill.skill_id.clone())
}

fn visible_skills_for_target(
    skills: &[SkillCandidate],
    target: &ConfigEntry,
    config: &SkillsConfig,
) -> Vec<SkillCandidate> {
    if !matches!(target.scope.as_str(), "project" | "repo") {
        return skills.to_vec();
    }

    let Some(user_target) = config
        .targets
        .iter()
        .find(|entry| entry.agent == target.agent && entry.scope == "user")
    else {
        return skills.to_vec();
    };

    skills
        .iter()
        .filter(|skill| {
            let present_in_target = is_skill_present(skill, target);
            if present_in_target {
                return true;
            }
            !is_skill_present(skill, user_target)
        })
        .cloned()
        .collect()
}

fn default_skill_indexes(
    skills: &[SkillCandidate],
    options: &[SkillOption],
    target: &ConfigEntry,
    config: &SkillsConfig,
) -> Vec<usize> {
    let mut defaults = linked_skill_indexes(skills, target);
    if !defaults.is_empty() {
        return map_skill_defaults_to_option_indexes(skills, options, &defaults);
    }

    if !matches!(target.scope.as_str(), "project" | "repo") {
        return map_skill_defaults_to_option_indexes(skills, options, &defaults);
    }

    let Some(user_target) = config
        .targets
        .iter()
        .find(|entry| entry.agent == target.agent && entry.scope == "user")
    else {
        return map_skill_defaults_to_option_indexes(skills, options, &defaults);
    };

    defaults = linked_skill_indexes(skills, user_target);
    map_skill_defaults_to_option_indexes(skills, options, &defaults)
}

fn map_skill_defaults_to_option_indexes(
    skills: &[SkillCandidate],
    options: &[SkillOption],
    skill_indexes: &[usize],
) -> Vec<usize> {
    let selected_skill_ids: HashSet<&str> = skill_indexes
        .iter()
        .filter_map(|idx| skills.get(*idx))
        .map(|skill| skill.skill_id.as_str())
        .collect();
    options
        .iter()
        .enumerate()
        .filter_map(|(idx, option)| match option {
            SkillOption::Skill { skill_id, .. }
                if selected_skill_ids.contains(skill_id.as_str()) =>
            {
                Some(idx)
            }
            _ => None,
        })
        .collect()
}

fn linked_skill_indexes(skills: &[SkillCandidate], target: &ConfigEntry) -> Vec<usize> {
    let mut indexes = Vec::new();
    for (idx, skill) in skills.iter().enumerate() {
        if is_skill_present(skill, target) {
            indexes.push(idx);
        }
    }
    indexes
}

fn confirm_apply(target: &crate::skills::catalog::types::ConfigEntry) -> Result<bool> {
    let prompt = t!("skills.manager.confirm_apply", target = target.id);
    let confirmation = match Confirm::new(&prompt).with_default(true).prompt() {
        Ok(confirmation) => confirmation,
        Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
            return Ok(false);
        }
        Err(e) => {
            return Err(anyhow::anyhow!(t!(
                "skills.manager.prompt_failed",
                error = e
            )));
        }
    };
    Ok(confirmation)
}

fn dedupe_skills(skills: Vec<SkillCandidate>) -> Vec<SkillCandidate> {
    let mut seen: HashMap<String, SkillCandidate> = HashMap::new();
    for skill in skills {
        if let Some(existing) = seen.get(&skill.skill_id) {
            let existing_is_link = is_symlink_dir(&existing.skill_dir);
            let incoming_is_link = is_symlink_dir(&skill.skill_dir);
            let replace = existing_is_link && !incoming_is_link;
            if replace {
                eprintln!(
                    "{}",
                    t!(
                        "skills.manager.duplicate_skill_id",
                        skill = skill.skill_id,
                        path = existing.skill_dir.display(),
                        chosen = skill.skill_dir.display()
                    )
                );
                seen.insert(skill.skill_id.clone(), skill);
            } else {
                eprintln!(
                    "{}",
                    t!(
                        "skills.manager.duplicate_skill_id",
                        skill = skill.skill_id,
                        path = skill.skill_dir.display(),
                        chosen = existing.skill_dir.display()
                    )
                );
            }
            continue;
        }
        seen.insert(skill.skill_id.clone(), skill);
    }
    let mut values = seen.into_values().collect::<Vec<_>>();
    values.sort_by(|a, b| a.skill_id.cmp(&b.skill_id));
    values
}

fn is_symlink_dir(path: &std::path::Path) -> bool {
    fs::symlink_metadata(path)
        .map(|meta| meta.file_type().is_symlink())
        .unwrap_or(false)
}

impl From<TargetConflictArg> for TargetConflictStrategy {
    fn from(value: TargetConflictArg) -> Self {
        match value {
            TargetConflictArg::Overwrite => TargetConflictStrategy::Overwrite,
            TargetConflictArg::Skip => TargetConflictStrategy::Skip,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::skills::catalog::types::{ConfigEntry, SkillsConfig};
    use std::path::PathBuf;

    fn grouped_options_for(
        skills: &[SkillCandidate],
        catalog: &SkillDirCatalog,
        runtime_presets: &HashMap<String, RuntimePreset>,
    ) -> Vec<SkillOption> {
        grouped_skill_options(skills, catalog, runtime_presets).expect("options")
    }

    #[test]
    fn test_selectable_agents_uses_expected_order() {
        let config = SkillsConfig {
            targets: vec![
                ConfigEntry {
                    id: "agents_project".to_string(),
                    agent: "agents".to_string(),
                    scope: "project".to_string(),
                    path: PathBuf::from("/tmp/agents-project"),
                    enabled: true,
                    mode: TargetMode::Link,
                },
                ConfigEntry {
                    id: "claude_user".to_string(),
                    agent: "claude".to_string(),
                    scope: "user".to_string(),
                    path: PathBuf::from("/tmp/claude-user"),
                    enabled: true,
                    mode: TargetMode::Link,
                },
            ],
        };

        let agents = selectable_agents(&config);
        let labels: Vec<String> = agents.iter().map(|choice| choice.label.clone()).collect();
        assert_eq!(labels, vec!["claude", "agents"]);
    }

    #[test]
    fn test_scope_label_for_known_agent_and_scope() {
        assert_eq!(
            display_scope_label("claude", "user"),
            "Personal (All your projects)"
        );
        assert_eq!(
            display_scope_label("claude", "project"),
            "Project (This project only)"
        );
        assert_eq!(
            display_scope_label("agents", "project"),
            ".agents (This project only)"
        );
    }

    #[test]
    fn test_scope_label_fallback_for_unknown_scope() {
        assert_eq!(display_scope_label("claude", "team"), "team");
        assert_eq!(display_scope_label("unknown", "custom"), "custom");
    }

    #[test]
    fn test_agent_scopes_filters_and_orders_expected_scopes() {
        let config = SkillsConfig {
            targets: vec![
                ConfigEntry {
                    id: "claude_project".to_string(),
                    agent: "claude".to_string(),
                    scope: "project".to_string(),
                    path: PathBuf::from("/tmp/claude-project"),
                    enabled: true,
                    mode: TargetMode::Link,
                },
                ConfigEntry {
                    id: "claude_user".to_string(),
                    agent: "claude".to_string(),
                    scope: "user".to_string(),
                    path: PathBuf::from("/tmp/claude-user"),
                    enabled: true,
                    mode: TargetMode::Link,
                },
                ConfigEntry {
                    id: "claude_team".to_string(),
                    agent: "claude".to_string(),
                    scope: "team".to_string(),
                    path: PathBuf::from("/tmp/claude-team"),
                    enabled: true,
                    mode: TargetMode::Link,
                },
            ],
        };

        let scopes = scopes_for_agent(&config, "claude");
        let labels: Vec<String> = scopes.iter().map(|choice| choice.label.clone()).collect();

        // Order depends on whether running from a git repo (project first) or not (user first).
        assert_eq!(scopes.len(), 3);
        let first_two: Vec<&str> = labels[..2].iter().map(|s| s.as_str()).collect();
        assert!(
            first_two
                == vec![
                    "Project (This project only)",
                    "Personal (All your projects)"
                ]
                || first_two
                    == vec![
                        "Personal (All your projects)",
                        "Project (This project only)"
                    ],
            "First two should be Project and Personal in some order, got: {:?}",
            first_two
        );
        assert_eq!(labels[2], "team");
        assert_eq!(scopes[2].target.id, "claude_team");
        // Verify the first two items have correct target IDs
        let first_ids: Vec<&str> = scopes[..2].iter().map(|s| s.target.id.as_str()).collect();
        assert!(first_ids.contains(&"claude_user"));
        assert!(first_ids.contains(&"claude_project"));
    }

    #[test]
    fn test_default_skill_indexes_prefers_target_links() {
        let skills = vec![
            SkillCandidate {
                skill_id: "alpha".to_string(),
                skill_dir: PathBuf::from("/tmp/alpha"),
            },
            SkillCandidate {
                skill_id: "beta".to_string(),
                skill_dir: PathBuf::from("/tmp/beta"),
            },
        ];

        let config = SkillsConfig {
            targets: vec![
                ConfigEntry {
                    id: "claude_user".to_string(),
                    agent: "claude".to_string(),
                    scope: "user".to_string(),
                    path: PathBuf::from("/tmp/claude-user"),
                    enabled: true,
                    mode: TargetMode::Link,
                },
                ConfigEntry {
                    id: "claude_project".to_string(),
                    agent: "claude".to_string(),
                    scope: "project".to_string(),
                    path: PathBuf::from("/tmp/claude-project"),
                    enabled: true,
                    mode: TargetMode::Link,
                },
            ],
        };

        let target = config
            .targets
            .iter()
            .find(|entry| entry.id == "claude_project")
            .expect("target");

        let catalog = SkillDirCatalog::default();
        let options = grouped_options_for(&skills, &catalog, &HashMap::new());

        let defaults = default_skill_indexes(&skills, &options, target, &config);
        assert!(defaults.is_empty());
    }

    #[cfg(unix)]
    #[test]
    fn test_default_skill_indexes_falls_back_to_user_links_for_project_scope() {
        use std::fs;
        use std::os::unix::fs as unix_fs;
        use tempfile::TempDir;

        let temp = TempDir::new().expect("temp dir");
        let root = temp.path();
        let alpha_dir = root.join("alpha");
        let beta_dir = root.join("beta");
        fs::create_dir_all(&alpha_dir).expect("alpha dir");
        fs::create_dir_all(&beta_dir).expect("beta dir");

        let claude_user = root.join("claude-user");
        let claude_project = root.join("claude-project");
        fs::create_dir_all(&claude_user).expect("claude user dir");
        fs::create_dir_all(&claude_project).expect("claude project dir");
        unix_fs::symlink(&alpha_dir, claude_user.join("alpha")).expect("link alpha");

        let skills = vec![
            SkillCandidate {
                skill_id: "alpha".to_string(),
                skill_dir: alpha_dir,
            },
            SkillCandidate {
                skill_id: "beta".to_string(),
                skill_dir: beta_dir,
            },
        ];

        let config = SkillsConfig {
            targets: vec![
                ConfigEntry {
                    id: "claude_user".to_string(),
                    agent: "claude".to_string(),
                    scope: "user".to_string(),
                    path: claude_user,
                    enabled: true,
                    mode: TargetMode::Link,
                },
                ConfigEntry {
                    id: "claude_project".to_string(),
                    agent: "claude".to_string(),
                    scope: "project".to_string(),
                    path: claude_project,
                    enabled: true,
                    mode: TargetMode::Link,
                },
            ],
        };

        let target = config
            .targets
            .iter()
            .find(|entry| entry.id == "claude_project")
            .expect("target");

        let catalog = SkillDirCatalog::default();
        let options = grouped_options_for(&skills, &catalog, &HashMap::new());

        let defaults = default_skill_indexes(&skills, &options, target, &config);
        assert_eq!(defaults, vec![1]);
    }

    #[test]
    fn test_infer_runtime_presets_from_skills() {
        let catalog = SkillDirCatalog {
            dir_to_skill_id: HashMap::from([
                (
                    "superpowers.brainstorming".to_string(),
                    "brainstorming".to_string(),
                ),
                (
                    "superpowers.writing-plans".to_string(),
                    "writing-plans".to_string(),
                ),
                ("mermaid-expert".to_string(), "mermaid-expert".to_string()),
            ]),
            skill_id_to_dir_name: HashMap::from([
                (
                    "brainstorming".to_string(),
                    "superpowers.brainstorming".to_string(),
                ),
                (
                    "writing-plans".to_string(),
                    "superpowers.writing-plans".to_string(),
                ),
                ("mermaid-expert".to_string(), "mermaid-expert".to_string()),
            ]),
        };

        let inferred = infer_runtime_presets_from_catalog(&catalog);
        let superpowers = inferred.get("superpowers").expect("superpowers preset");
        assert_eq!(superpowers.skill_dirs.len(), 2);
        assert!(
            superpowers
                .skill_dirs
                .contains(&"superpowers.brainstorming".to_string())
        );
        assert!(
            superpowers
                .skill_dirs
                .contains(&"superpowers.writing-plans".to_string())
        );
        assert!(!inferred.contains_key("mermaid-expert"));
    }

    #[test]
    fn test_resolve_preset_skill_dirs_extends_and_dedupes() {
        let presets = HashMap::from([(
            "full-stack".to_string(),
            RuntimePreset {
                skill_dirs: vec![
                    "superpowers.brainstorming".to_string(),
                    "superpowers.brainstorming".to_string(),
                    "vercel-labs.react-best-practices".to_string(),
                ],
            },
        )]);

        let catalog = SkillDirCatalog {
            dir_to_skill_id: HashMap::from([
                (
                    "superpowers.brainstorming".to_string(),
                    "brainstorming".to_string(),
                ),
                (
                    "vercel-labs.react-best-practices".to_string(),
                    "react-best-practices".to_string(),
                ),
            ]),
            skill_id_to_dir_name: HashMap::from([
                (
                    "brainstorming".to_string(),
                    "superpowers.brainstorming".to_string(),
                ),
                (
                    "react-best-practices".to_string(),
                    "vercel-labs.react-best-practices".to_string(),
                ),
            ]),
        };

        let resolved =
            resolve_preset_skill_ids(&presets, "full-stack", &catalog).expect("resolve preset");
        assert_eq!(resolved.len(), 2);
        assert!(resolved.contains(&"brainstorming".to_string()));
        assert!(resolved.contains(&"react-best-practices".to_string()));
    }

    #[test]
    fn test_resolve_preset_selections_uses_dir_catalog_mapping() {
        let presets = HashMap::from([(
            "superpowers".to_string(),
            RuntimePreset {
                skill_dirs: vec!["superpowers.brainstorming".to_string()],
            },
        )]);

        let catalog = SkillDirCatalog {
            dir_to_skill_id: HashMap::from([(
                "superpowers.brainstorming".to_string(),
                "brainstorming".to_string(),
            )]),
            skill_id_to_dir_name: HashMap::from([(
                "brainstorming".to_string(),
                "superpowers.brainstorming".to_string(),
            )]),
        };

        let selected =
            resolve_preset_skill_ids(&presets, "superpowers", &catalog).expect("resolve selected");
        assert!(selected.contains(&"brainstorming".to_string()));
    }

    #[test]
    fn test_resolve_preset_selections_unknown_dir_errors() {
        let presets = HashMap::from([(
            "daily".to_string(),
            RuntimePreset {
                skill_dirs: vec!["missing.skill".to_string()],
            },
        )]);

        let catalog = SkillDirCatalog {
            dir_to_skill_id: HashMap::from([("alpha".to_string(), "alpha".to_string())]),
            skill_id_to_dir_name: HashMap::from([("alpha".to_string(), "alpha".to_string())]),
        };

        let err =
            resolve_preset_skill_ids(&presets, "daily", &catalog).expect_err("missing mapping");
        assert!(err.to_string().contains("resolves to an empty skill set"));
    }

    #[test]
    fn test_resolve_preset_selections_empty_preset_errors() {
        let presets = HashMap::from([("daily".to_string(), RuntimePreset { skill_dirs: vec![] })]);

        let catalog = SkillDirCatalog {
            dir_to_skill_id: HashMap::from([("alpha".to_string(), "alpha".to_string())]),
            skill_id_to_dir_name: HashMap::from([("alpha".to_string(), "alpha".to_string())]),
        };

        let err = resolve_preset_skill_ids(&presets, "daily", &catalog).expect_err("empty preset");
        assert!(err.to_string().contains("resolves to an empty skill set"));
    }

    #[test]
    fn test_resolve_preset_selections_merges_and_dedupes() {
        let presets = HashMap::from([
            (
                "superpowers".to_string(),
                RuntimePreset {
                    skill_dirs: vec![
                        "superpowers.brainstorming".to_string(),
                        "superpowers.writing-plans".to_string(),
                    ],
                },
            ),
            (
                "daily".to_string(),
                RuntimePreset {
                    skill_dirs: vec![
                        "superpowers.brainstorming".to_string(),
                        "mermaid-expert".to_string(),
                    ],
                },
            ),
        ]);

        let catalog = SkillDirCatalog {
            dir_to_skill_id: HashMap::from([
                (
                    "superpowers.brainstorming".to_string(),
                    "brainstorming".to_string(),
                ),
                (
                    "superpowers.writing-plans".to_string(),
                    "writing-plans".to_string(),
                ),
                ("mermaid-expert".to_string(), "mermaid-expert".to_string()),
            ]),
            skill_id_to_dir_name: HashMap::from([
                (
                    "brainstorming".to_string(),
                    "superpowers.brainstorming".to_string(),
                ),
                (
                    "writing-plans".to_string(),
                    "superpowers.writing-plans".to_string(),
                ),
                ("mermaid-expert".to_string(), "mermaid-expert".to_string()),
            ]),
        };

        let mut selected = HashSet::new();
        for preset_name in ["superpowers", "daily"] {
            let resolved =
                resolve_preset_skill_ids(&presets, preset_name, &catalog).expect("resolve preset");
            for skill_id in resolved {
                selected.insert(skill_id);
            }
        }

        assert_eq!(selected.len(), 3);
        assert!(selected.contains("brainstorming"));
        assert!(selected.contains("writing-plans"));
        assert!(selected.contains("mermaid-expert"));
    }

    #[test]
    fn test_build_runtime_presets_prefers_dir_name_over_skill_id() {
        let catalog = SkillDirCatalog {
            dir_to_skill_id: HashMap::from([(
                "superpowers.brainstorming".to_string(),
                "brainstorming".to_string(),
            )]),
            skill_id_to_dir_name: HashMap::from([(
                "brainstorming".to_string(),
                "superpowers.brainstorming".to_string(),
            )]),
        };

        let presets = infer_runtime_presets_from_catalog(&catalog);
        let superpowers = presets.get("superpowers").expect("superpowers preset");
        assert_eq!(
            superpowers.skill_dirs,
            vec!["superpowers.brainstorming".to_string()]
        );
    }

    #[test]
    fn test_grouped_skill_options_includes_group_headers() {
        let skills = vec![
            SkillCandidate {
                skill_id: "superpowers.brainstorming".to_string(),
                skill_dir: PathBuf::from("/tmp/superpowers.brainstorming"),
            },
            SkillCandidate {
                skill_id: "mermaid-expert".to_string(),
                skill_dir: PathBuf::from("/tmp/mermaid-expert"),
            },
        ];

        let catalog = SkillDirCatalog::default();
        let options = grouped_options_for(&skills, &catalog, &HashMap::new());
        assert!(matches!(options.first(), Some(SkillOption::Preset(_))));
        let skill_count = options
            .iter()
            .filter(|option| matches!(option, SkillOption::Skill { .. }))
            .count();
        assert_eq!(skill_count, 2);

        let preset_labels: Vec<String> = options
            .iter()
            .filter_map(|option| match option {
                SkillOption::Preset(PresetOption { label, .. }) => Some(label.clone()),
                SkillOption::Skill { .. } => None,
            })
            .collect();
        assert!(
            preset_labels
                .iter()
                .any(|label| label == "superpowers (1 skills)")
        );

        let labels: Vec<String> = options
            .iter()
            .filter_map(|option| match option {
                SkillOption::Skill { label, .. } => Some(label.clone()),
                SkillOption::Preset(_) => None,
            })
            .collect();
        assert!(
            labels
                .iter()
                .any(|label| { label == "superpowers.brainstorming (superpowers.brainstorming)" })
        );
        assert!(
            labels
                .iter()
                .any(|label| label == "mermaid-expert (mermaid-expert)")
        );
    }

    #[test]
    fn test_grouped_skill_options_displays_mapped_dir_name() {
        let skills = vec![SkillCandidate {
            skill_id: "brainstorming".to_string(),
            skill_dir: PathBuf::from("/tmp/__submodules__/op7418.Humanizer-zh"),
        }];
        let catalog = SkillDirCatalog {
            dir_to_skill_id: HashMap::from([(
                "superpowers.brainstorming".to_string(),
                "brainstorming".to_string(),
            )]),
            skill_id_to_dir_name: HashMap::from([(
                "brainstorming".to_string(),
                "superpowers.brainstorming".to_string(),
            )]),
        };

        let options = grouped_options_for(&skills, &catalog, &HashMap::new());
        let labels: Vec<String> = options
            .iter()
            .filter_map(|option| match option {
                SkillOption::Skill { label, .. } => Some(label.clone()),
                SkillOption::Preset(_) => None,
            })
            .collect();

        assert_eq!(
            labels,
            vec!["brainstorming (superpowers.brainstorming)".to_string()]
        );
    }

    #[test]
    fn test_default_indexes_with_preset_state_only_marks_fully_selected_presets() {
        let options = vec![
            SkillOption::Preset(PresetOption {
                name: "dakesan".to_string(),
                skill_ids: vec!["marimo-editor".to_string(), "marimo-inspect".to_string()],
                label: "dakesan (2 skills)".to_string(),
            }),
            SkillOption::Skill {
                skill_id: "marimo-editor".to_string(),
                label: "marimo-editor (dakesan.marimo-editor)".to_string(),
            },
            SkillOption::Skill {
                skill_id: "marimo-inspect".to_string(),
                label: "marimo-inspect (dakesan.marimo-inspect)".to_string(),
            },
            SkillOption::Preset(PresetOption {
                name: "astral-sh".to_string(),
                skill_ids: vec!["ruff".to_string()],
                label: "astral-sh (1 skills)".to_string(),
            }),
            SkillOption::Skill {
                skill_id: "ruff".to_string(),
                label: "ruff (astral-sh.ruff)".to_string(),
            },
        ];

        let indexes = default_indexes_with_preset_state(&options, &[1, 4]);
        assert_eq!(indexes, vec![1, 3, 4]);
    }

    #[test]
    fn test_grouped_skill_options_includes_runtime_and_group_presets() {
        let skills = vec![
            SkillCandidate {
                skill_id: "brainstorming".to_string(),
                skill_dir: PathBuf::from("/tmp/superpowers.brainstorming"),
            },
            SkillCandidate {
                skill_id: "mermaid-expert".to_string(),
                skill_dir: PathBuf::from("/tmp/mermaid-expert"),
            },
        ];
        let catalog = SkillDirCatalog {
            dir_to_skill_id: HashMap::from([
                (
                    "superpowers.brainstorming".to_string(),
                    "brainstorming".to_string(),
                ),
                ("mermaid-expert".to_string(), "mermaid-expert".to_string()),
            ]),
            skill_id_to_dir_name: HashMap::from([
                (
                    "brainstorming".to_string(),
                    "superpowers.brainstorming".to_string(),
                ),
                ("mermaid-expert".to_string(), "mermaid-expert".to_string()),
            ]),
        };
        let runtime_presets = HashMap::from([(
            "daily".to_string(),
            RuntimePreset {
                skill_dirs: vec!["superpowers.brainstorming".to_string()],
            },
        )]);

        let options = grouped_options_for(&skills, &catalog, &runtime_presets);
        let preset_labels: Vec<String> = options
            .iter()
            .filter_map(|option| match option {
                SkillOption::Preset(PresetOption { label, .. }) => Some(label.clone()),
                SkillOption::Skill { .. } => None,
            })
            .collect();

        assert!(
            preset_labels
                .iter()
                .any(|label| label == "daily (1 skills)")
        );
        assert!(
            preset_labels
                .iter()
                .any(|label| label == "superpowers (1 skills)")
        );
    }

    #[test]
    fn test_grouped_skill_options_runtime_preset_label_count_uses_direct_skill_dirs() {
        let skills = vec![
            SkillCandidate {
                skill_id: "brainstorming".to_string(),
                skill_dir: PathBuf::from("/tmp/superpowers.brainstorming"),
            },
            SkillCandidate {
                skill_id: "mermaid-expert".to_string(),
                skill_dir: PathBuf::from("/tmp/mermaid-expert"),
            },
        ];
        let catalog = SkillDirCatalog {
            dir_to_skill_id: HashMap::from([
                (
                    "superpowers.brainstorming".to_string(),
                    "brainstorming".to_string(),
                ),
                ("mermaid-expert".to_string(), "mermaid-expert".to_string()),
            ]),
            skill_id_to_dir_name: HashMap::from([
                (
                    "brainstorming".to_string(),
                    "superpowers.brainstorming".to_string(),
                ),
                ("mermaid-expert".to_string(), "mermaid-expert".to_string()),
            ]),
        };
        let runtime_presets = HashMap::from([
            (
                "base".to_string(),
                RuntimePreset {
                    skill_dirs: vec!["superpowers.brainstorming".to_string()],
                },
            ),
            (
                "daily".to_string(),
                RuntimePreset {
                    skill_dirs: vec!["mermaid-expert".to_string()],
                },
            ),
        ]);

        let options = grouped_options_for(&skills, &catalog, &runtime_presets);
        let daily_label = options
            .iter()
            .find_map(|option| match option {
                SkillOption::Preset(PresetOption { name, label, .. }) if name == "daily" => {
                    Some(label.clone())
                }
                _ => None,
            })
            .expect("daily preset label");

        assert_eq!(daily_label, "daily (1 skills)");
    }

    #[cfg(unix)]
    #[test]
    fn test_visible_skills_excludes_user_linked_for_project_scope() {
        use std::fs;
        use std::os::unix::fs as unix_fs;
        use tempfile::TempDir;

        let temp = TempDir::new().expect("temp dir");
        let root = temp.path();
        let alpha_dir = root.join("alpha");
        let beta_dir = root.join("beta");
        fs::create_dir_all(&alpha_dir).expect("alpha dir");
        fs::create_dir_all(&beta_dir).expect("beta dir");

        let claude_user = root.join("claude-user");
        let claude_project = root.join("claude-project");
        fs::create_dir_all(&claude_user).expect("claude user dir");
        fs::create_dir_all(&claude_project).expect("claude project dir");
        unix_fs::symlink(&alpha_dir, claude_user.join("alpha")).expect("link alpha user");

        let skills = vec![
            SkillCandidate {
                skill_id: "alpha".to_string(),
                skill_dir: alpha_dir,
            },
            SkillCandidate {
                skill_id: "beta".to_string(),
                skill_dir: beta_dir,
            },
        ];

        let config = SkillsConfig {
            targets: vec![
                ConfigEntry {
                    id: "claude_user".to_string(),
                    agent: "claude".to_string(),
                    scope: "user".to_string(),
                    path: claude_user,
                    enabled: true,
                    mode: TargetMode::Link,
                },
                ConfigEntry {
                    id: "claude_project".to_string(),
                    agent: "claude".to_string(),
                    scope: "project".to_string(),
                    path: claude_project,
                    enabled: true,
                    mode: TargetMode::Link,
                },
            ],
        };

        let target = config
            .targets
            .iter()
            .find(|entry| entry.id == "claude_project")
            .expect("target");

        let visible = visible_skills_for_target(&skills, target, &config);
        let visible_ids: Vec<String> = visible.iter().map(|skill| skill.skill_id.clone()).collect();
        assert_eq!(visible_ids, vec!["beta".to_string()]);
    }

    #[cfg(unix)]
    #[test]
    fn test_visible_skills_keeps_project_linked_even_if_user_linked() {
        use std::fs;
        use std::os::unix::fs as unix_fs;
        use tempfile::TempDir;

        let temp = TempDir::new().expect("temp dir");
        let root = temp.path();
        let alpha_dir = root.join("alpha");
        let beta_dir = root.join("beta");
        fs::create_dir_all(&alpha_dir).expect("alpha dir");
        fs::create_dir_all(&beta_dir).expect("beta dir");

        let claude_user = root.join("claude-user");
        let claude_project = root.join("claude-project");
        fs::create_dir_all(&claude_user).expect("claude user dir");
        fs::create_dir_all(&claude_project).expect("claude project dir");
        unix_fs::symlink(&alpha_dir, claude_user.join("alpha")).expect("link alpha user");
        unix_fs::symlink(&alpha_dir, claude_project.join("alpha")).expect("link alpha project");

        let skills = vec![
            SkillCandidate {
                skill_id: "alpha".to_string(),
                skill_dir: alpha_dir,
            },
            SkillCandidate {
                skill_id: "beta".to_string(),
                skill_dir: beta_dir,
            },
        ];

        let config = SkillsConfig {
            targets: vec![
                ConfigEntry {
                    id: "claude_user".to_string(),
                    agent: "claude".to_string(),
                    scope: "user".to_string(),
                    path: claude_user,
                    enabled: true,
                    mode: TargetMode::Link,
                },
                ConfigEntry {
                    id: "claude_project".to_string(),
                    agent: "claude".to_string(),
                    scope: "project".to_string(),
                    path: claude_project,
                    enabled: true,
                    mode: TargetMode::Link,
                },
            ],
        };

        let target = config
            .targets
            .iter()
            .find(|entry| entry.id == "claude_project")
            .expect("target");

        let visible = visible_skills_for_target(&skills, target, &config);
        let visible_ids: Vec<String> = visible.iter().map(|skill| skill.skill_id.clone()).collect();
        assert_eq!(visible_ids, vec!["alpha".to_string(), "beta".to_string()]);
    }
}
