//! Built-in expert skills management.
//!
//! Experts are curated skills (from obra/superpowers) that codeg bundles
//! into its binary via `include_dir!`. On startup they are extracted to a
//! central directory `~/.codeg/skills/<id>/`. Users can then enable an
//! expert for any ACP agent by creating a symbolic link (or Windows
//! junction) from the agent's skill directory into the central copy.
//!
//! The central store is the single source of truth. Enabling/disabling is
//! purely "does a link exist in the agent's skill dir" — there is no
//! database state, and updates propagate automatically when codeg upgrades
//! and re-extracts the bundled files.

use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use chrono::Utc;
use include_dir::{include_dir, Dir, DirEntry};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;

use crate::commands::acp::{
    preferred_scope_skill_dir, remove_skill_entry, scoped_skill_dirs, skill_storage_spec,
    validate_skill_id,
};
use crate::acp::types::AgentSkillScope;
use crate::models::agent::AgentType;

// ─── Embedded bundle ────────────────────────────────────────────────────

static EXPERTS_BUNDLE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/experts");

const CENTRAL_DIR_NAME: &str = ".codeg";
const CENTRAL_SKILLS_SUBDIR: &str = "skills";
const MANIFEST_FILE: &str = ".manifest.json";
const EXPERTS_TOML: &str = "experts.toml";

// ─── Error type ─────────────────────────────────────────────────────────

#[derive(Debug, thiserror::Error)]
pub enum ExpertsError {
    #[error("expert not found: {0}")]
    NotFound(String),
    #[error("agent does not support skills: {0:?}")]
    UnsupportedAgent(AgentType),
    #[error("a real directory already exists at '{path}' — delete or rename it first")]
    NameCollision { path: String },
    #[error("a different link already exists at '{path}' (points to '{found}') — remove it first")]
    ForeignLink { path: String, found: String },
    #[error("io error: {0}")]
    Io(String),
    #[error("metadata error: {0}")]
    Metadata(String),
    #[error("central expert store is unavailable: {0}")]
    CentralUnavailable(String),
}

impl Serialize for ExpertsError {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.serialize_str(&self.to_string())
    }
}

impl From<io::Error> for ExpertsError {
    fn from(err: io::Error) -> Self {
        ExpertsError::Io(err.to_string())
    }
}

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

#[derive(Debug, Clone, Serialize)]
pub struct ExpertMetadata {
    pub id: String,
    pub category: String,
    pub icon: Option<String>,
    pub sort_order: i32,
    pub display_name: BTreeMap<String, String>,
    pub description: BTreeMap<String, String>,
    pub bundled_hash: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct ExpertListItem {
    pub metadata: ExpertMetadata,
    pub installed_centrally: bool,
    pub user_modified: bool,
    pub central_path: String,
}

#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExpertLinkState {
    NotLinked,
    LinkedToCodeg,
    LinkedElsewhere,
    BlockedByRealDirectory,
    Broken,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExpertInstallStatus {
    pub expert_id: String,
    pub agent_type: AgentType,
    pub state: ExpertLinkState,
    pub link_path: String,
    pub target_path: Option<String>,
    pub expected_target_path: String,
    pub copy_mode: bool,
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct InstallReport {
    pub installed_count: usize,
    pub updated_count: usize,
    pub pending_user_review: Vec<String>,
    pub errors: Vec<String>,
}

// ─── Manifest ───────────────────────────────────────────────────────────

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Manifest {
    #[serde(default)]
    codeg_version: String,
    #[serde(default)]
    installed_at: String,
    #[serde(default)]
    experts: BTreeMap<String, ManifestEntry>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ManifestEntry {
    #[serde(default)]
    hash: String,
    #[serde(default)]
    installed_at: String,
    #[serde(default)]
    pending_user_review: bool,
}

// ─── Concurrency ────────────────────────────────────────────────────────

fn mutation_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

// ─── Paths ──────────────────────────────────────────────────────────────

fn home_dir_or_default() -> PathBuf {
    dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
}

pub(crate) fn central_experts_dir() -> PathBuf {
    home_dir_or_default()
        .join(CENTRAL_DIR_NAME)
        .join(CENTRAL_SKILLS_SUBDIR)
}

fn manifest_path() -> PathBuf {
    central_experts_dir().join(MANIFEST_FILE)
}

fn expert_central_path(expert_id: &str) -> PathBuf {
    central_experts_dir().join(expert_id)
}

fn agent_link_path(agent: AgentType, expert_id: &str) -> Result<PathBuf, ExpertsError> {
    let dir = preferred_scope_skill_dir(agent, AgentSkillScope::Global, None)
        .map_err(|_| ExpertsError::UnsupportedAgent(agent))?;
    Ok(dir.join(expert_id))
}

// ─── Metadata loading ───────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
struct ExpertsTomlRoot {
    #[serde(default)]
    expert: Vec<ExpertTomlEntry>,
}

#[derive(Debug, Deserialize)]
struct ExpertTomlEntry {
    id: String,
    category: String,
    #[serde(default)]
    icon: Option<String>,
    #[serde(default)]
    sort_order: i32,
    #[serde(default)]
    display_name: BTreeMap<String, String>,
    #[serde(default)]
    description: BTreeMap<String, String>,
}

fn bundled_metadata() -> &'static [ExpertMetadata] {
    static METADATA: OnceLock<Vec<ExpertMetadata>> = OnceLock::new();
    METADATA.get_or_init(|| match load_bundled_metadata_inner() {
        Ok(list) => list,
        Err(err) => {
            eprintln!("[Experts] failed to load bundled metadata: {err}");
            Vec::new()
        }
    })
}

fn load_bundled_metadata_inner() -> Result<Vec<ExpertMetadata>, ExpertsError> {
    let toml_file = EXPERTS_BUNDLE
        .get_file(EXPERTS_TOML)
        .ok_or_else(|| ExpertsError::Metadata(format!("{EXPERTS_TOML} missing from bundle")))?;
    let toml_str = toml_file
        .contents_utf8()
        .ok_or_else(|| ExpertsError::Metadata(format!("{EXPERTS_TOML} is not valid UTF-8")))?;
    let root: ExpertsTomlRoot = toml::from_str(toml_str)
        .map_err(|e| ExpertsError::Metadata(format!("failed to parse {EXPERTS_TOML}: {e}")))?;

    let mut out = Vec::with_capacity(root.expert.len());
    for entry in root.expert {
        let bundled_hash = hash_bundled_expert(&entry.id)?;
        out.push(ExpertMetadata {
            id: entry.id,
            category: entry.category,
            icon: entry.icon,
            sort_order: entry.sort_order,
            display_name: entry.display_name,
            description: entry.description,
            bundled_hash,
        });
    }
    out.sort_by(|a, b| {
        a.sort_order
            .cmp(&b.sort_order)
            .then_with(|| a.id.cmp(&b.id))
    });
    Ok(out)
}

fn find_metadata(expert_id: &str) -> Result<&'static ExpertMetadata, ExpertsError> {
    bundled_metadata()
        .iter()
        .find(|m| m.id == expert_id)
        .ok_or_else(|| ExpertsError::NotFound(expert_id.to_string()))
}

// ─── Hashing ────────────────────────────────────────────────────────────

fn hash_bundled_expert(expert_id: &str) -> Result<String, ExpertsError> {
    let skill_dir = format!("skills/{expert_id}");
    let dir = EXPERTS_BUNDLE
        .get_dir(&skill_dir)
        .ok_or_else(|| ExpertsError::NotFound(expert_id.to_string()))?;
    let mut files: Vec<(&str, &[u8])> = Vec::new();
    collect_bundle_files(dir, &mut files);
    files.sort_by_key(|(path, _)| *path);
    let mut hasher = Sha256::new();
    for (path, contents) in files {
        hasher.update(path.as_bytes());
        hasher.update(b"\0");
        hasher.update(contents);
        hasher.update(b"\0");
    }
    Ok(format!("{:x}", hasher.finalize()))
}

fn collect_bundle_files<'a>(dir: &'a Dir<'a>, out: &mut Vec<(&'a str, &'a [u8])>) {
    for entry in dir.entries() {
        match entry {
            DirEntry::File(f) => {
                let rel = f.path().to_str().unwrap_or("");
                out.push((rel, f.contents()));
            }
            DirEntry::Dir(d) => collect_bundle_files(d, out),
        }
    }
}

fn hash_disk_directory(path: &Path) -> Result<String, ExpertsError> {
    let mut files: Vec<(String, Vec<u8>)> = Vec::new();
    collect_disk_files(path, path, &mut files)?;
    files.sort_by(|a, b| a.0.cmp(&b.0));
    let mut hasher = Sha256::new();
    for (rel_path, contents) in files {
        // Mirror the bundled hash format: relative path includes the
        // leading `skills/<id>/` prefix from bundled view.
        let logical = format!(
            "skills/{}/{}",
            path.file_name()
                .and_then(|s| s.to_str())
                .unwrap_or_default(),
            rel_path
        );
        hasher.update(logical.as_bytes());
        hasher.update(b"\0");
        hasher.update(&contents);
        hasher.update(b"\0");
    }
    Ok(format!("{:x}", hasher.finalize()))
}

fn collect_disk_files(
    base: &Path,
    current: &Path,
    out: &mut Vec<(String, Vec<u8>)>,
) -> Result<(), ExpertsError> {
    if !current.exists() {
        return Ok(());
    }
    for entry in fs::read_dir(current)? {
        let entry = entry?;
        let file_type = entry.file_type()?;
        let child = entry.path();
        if file_type.is_dir() {
            collect_disk_files(base, &child, out)?;
        } else if file_type.is_file() {
            let rel = child
                .strip_prefix(base)
                .map_err(|e| ExpertsError::Io(e.to_string()))?
                .to_string_lossy()
                .replace('\\', "/");
            let contents = fs::read(&child)?;
            out.push((rel, contents));
        }
    }
    Ok(())
}

// ─── Manifest I/O ───────────────────────────────────────────────────────

fn load_manifest() -> Manifest {
    let path = manifest_path();
    match fs::read_to_string(&path) {
        Ok(content) => serde_json::from_str::<Manifest>(&content).unwrap_or_default(),
        Err(_) => Manifest::default(),
    }
}

fn save_manifest(manifest: &Manifest) -> Result<(), ExpertsError> {
    let path = manifest_path();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let serialized = serde_json::to_string_pretty(manifest)
        .map_err(|e| ExpertsError::Metadata(format!("failed to serialize manifest: {e}")))?;
    fs::write(&path, serialized)?;
    Ok(())
}

// ─── Link operations ────────────────────────────────────────────────────

#[cfg(unix)]
fn create_link_raw(src: &Path, dst: &Path) -> io::Result<bool> {
    std::os::unix::fs::symlink(src, dst).map(|_| false)
}

#[cfg(windows)]
fn create_link_raw(src: &Path, dst: &Path) -> io::Result<bool> {
    match junction::create(src, dst) {
        Ok(_) => Ok(false),
        Err(junction_err) => {
            // Fall back to recursive copy when junction creation is not
            // possible (e.g. cross-volume, permission denied). Mark the
            // returned status with copy_mode = true so the UI can warn
            // the user that upgrades won't propagate automatically.
            copy_dir_recursive(src, dst).map_err(|copy_err| {
                io::Error::new(
                    io::ErrorKind::Other,
                    format!(
                        "junction failed ({junction_err}); copy fallback failed ({copy_err})"
                    ),
                )
            })?;
            Ok(true)
        }
    }
}

#[cfg(windows)]
fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
    fs::create_dir_all(dst)?;
    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let ft = entry.file_type()?;
        let from = entry.path();
        let to = dst.join(entry.file_name());
        if ft.is_dir() {
            copy_dir_recursive(&from, &to)?;
        } else if ft.is_file() {
            fs::copy(&from, &to)?;
        }
    }
    Ok(())
}

fn read_link_target(path: &Path) -> Option<PathBuf> {
    fs::read_link(path).ok()
}

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

/// On Windows a junction is *not* a symlink — it is a directory reparse
/// point. `symlink_metadata` reports it as a directory. So we also need to
/// ask the OS whether the directory is a reparse point.
#[cfg(windows)]
fn path_is_reparse_point(path: &Path) -> bool {
    use std::os::windows::fs::MetadataExt;
    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
    fs::symlink_metadata(path)
        .map(|m| m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0)
        .unwrap_or(false)
}

#[cfg(not(windows))]
fn path_is_reparse_point(_path: &Path) -> bool {
    false
}

fn classify_link(link_path: &Path, expected_target: &Path) -> ExpertLinkState {
    if !link_path.exists() && !path_is_symlink(link_path) {
        return ExpertLinkState::NotLinked;
    }
    // Broken symlink detection: `exists()` returns false if the link
    // dangles; but we already checked that. Re-check the metadata.
    match fs::symlink_metadata(link_path) {
        Ok(meta) => {
            let is_link_like = meta.file_type().is_symlink() || path_is_reparse_point(link_path);
            if !is_link_like {
                return ExpertLinkState::BlockedByRealDirectory;
            }
            // Verify the target.
            match read_link_target(link_path) {
                Some(target) => {
                    // Normalize both sides by canonicalizing the expected
                    // target. The link target may be relative.
                    let target = if target.is_absolute() {
                        target
                    } else if let Some(parent) = link_path.parent() {
                        parent.join(target)
                    } else {
                        target
                    };
                    let canonical_target = fs::canonicalize(&target).ok();
                    let canonical_expected = fs::canonicalize(expected_target).ok();
                    match (canonical_target.as_ref(), canonical_expected.as_ref()) {
                        (Some(t), Some(e)) if t == e => ExpertLinkState::LinkedToCodeg,
                        (Some(_), Some(_)) => ExpertLinkState::LinkedElsewhere,
                        (None, _) => ExpertLinkState::Broken,
                        (_, None) => ExpertLinkState::LinkedElsewhere,
                    }
                }
                None => {
                    // On Windows junctions, `read_link` may fail but the
                    // directory still resolves. Fall back to canonical
                    // comparison via the path itself.
                    let canonical_link = fs::canonicalize(link_path).ok();
                    let canonical_expected = fs::canonicalize(expected_target).ok();
                    match (canonical_link, canonical_expected) {
                        (Some(t), Some(e)) if t == e => ExpertLinkState::LinkedToCodeg,
                        _ => ExpertLinkState::LinkedElsewhere,
                    }
                }
            }
        }
        Err(_) => ExpertLinkState::NotLinked,
    }
}

// ─── Central store installation ────────────────────────────────────────

pub async fn ensure_central_experts_installed() -> InstallReport {
    let _guard = mutation_lock().lock().await;
    tokio::task::spawn_blocking(ensure_central_experts_installed_blocking)
        .await
        .unwrap_or_else(|e| {
            let mut r = InstallReport::default();
            r.errors.push(format!("join error: {e}"));
            r
        })
}

fn ensure_central_experts_installed_blocking() -> InstallReport {
    let mut report = InstallReport::default();

    let central = central_experts_dir();
    if let Err(e) = fs::create_dir_all(&central) {
        report
            .errors
            .push(format!("failed to create central dir: {e}"));
        return report;
    }

    let mut manifest = load_manifest();
    let meta_list = bundled_metadata();

    for meta in meta_list {
        match install_or_refresh_expert(meta, &mut manifest) {
            Ok(InstallAction::Skipped) => {}
            Ok(InstallAction::Installed) => {
                report.installed_count += 1;
            }
            Ok(InstallAction::Updated) => {
                report.updated_count += 1;
            }
            Ok(InstallAction::BackedUp) => {
                report.updated_count += 1;
                report.pending_user_review.push(meta.id.clone());
            }
            Err(e) => {
                report
                    .errors
                    .push(format!("{}: {}", meta.id, e));
            }
        }
    }

    manifest.codeg_version = env!("CARGO_PKG_VERSION").to_string();
    manifest.installed_at = Utc::now().to_rfc3339();
    if let Err(e) = save_manifest(&manifest) {
        report.errors.push(format!("save manifest: {e}"));
    }

    report
}

enum InstallAction {
    Skipped,
    Installed,
    Updated,
    BackedUp,
}

fn install_or_refresh_expert(
    meta: &ExpertMetadata,
    manifest: &mut Manifest,
) -> Result<InstallAction, ExpertsError> {
    let central_path = expert_central_path(&meta.id);
    let bundled_hash = &meta.bundled_hash;
    let manifest_entry = manifest.experts.get(&meta.id).cloned().unwrap_or_default();

    if central_path.exists() {
        let on_disk_hash = hash_disk_directory(&central_path).unwrap_or_default();
        if &on_disk_hash == bundled_hash {
            // Up-to-date and pristine. Ensure manifest matches.
            if manifest_entry.hash != *bundled_hash {
                manifest.experts.insert(
                    meta.id.clone(),
                    ManifestEntry {
                        hash: bundled_hash.clone(),
                        installed_at: Utc::now().to_rfc3339(),
                        pending_user_review: false,
                    },
                );
            }
            return Ok(InstallAction::Skipped);
        }

        // Content differs. Was the user the one who changed it, or is
        // the bundle itself newer?
        let user_modified = manifest_entry.hash.is_empty() || on_disk_hash != manifest_entry.hash;
        if user_modified {
            // Preserve user work: move aside, install fresh.
            let backup_name = format!(
                "{}.user-backup-{}",
                meta.id,
                Utc::now().format("%Y%m%d-%H%M%S")
            );
            let backup_path = central_experts_dir().join(backup_name);
            fs::rename(&central_path, &backup_path)?;
            extract_expert_to_disk(meta, &central_path)?;
            manifest.experts.insert(
                meta.id.clone(),
                ManifestEntry {
                    hash: bundled_hash.clone(),
                    installed_at: Utc::now().to_rfc3339(),
                    pending_user_review: true,
                },
            );
            return Ok(InstallAction::BackedUp);
        }

        // Pristine but outdated → overwrite.
        remove_skill_entry(&central_path)
            .map_err(|e| ExpertsError::Io(format!("remove stale expert: {e}")))?;
        extract_expert_to_disk(meta, &central_path)?;
        manifest.experts.insert(
            meta.id.clone(),
            ManifestEntry {
                hash: bundled_hash.clone(),
                installed_at: Utc::now().to_rfc3339(),
                pending_user_review: false,
            },
        );
        Ok(InstallAction::Updated)
    } else {
        extract_expert_to_disk(meta, &central_path)?;
        manifest.experts.insert(
            meta.id.clone(),
            ManifestEntry {
                hash: bundled_hash.clone(),
                installed_at: Utc::now().to_rfc3339(),
                pending_user_review: false,
            },
        );
        Ok(InstallAction::Installed)
    }
}

fn extract_expert_to_disk(meta: &ExpertMetadata, target: &Path) -> Result<(), ExpertsError> {
    let skill_rel = format!("skills/{}", meta.id);
    let dir = EXPERTS_BUNDLE
        .get_dir(&skill_rel)
        .ok_or_else(|| ExpertsError::NotFound(meta.id.clone()))?;
    fs::create_dir_all(target)?;
    extract_bundle_dir(dir, &skill_rel, target)?;
    Ok(())
}

fn extract_bundle_dir(
    dir: &Dir<'_>,
    bundle_prefix: &str,
    target: &Path,
) -> Result<(), ExpertsError> {
    for entry in dir.entries() {
        match entry {
            DirEntry::File(f) => {
                let rel = f
                    .path()
                    .to_str()
                    .ok_or_else(|| ExpertsError::Io("non-utf8 path in bundle".into()))?;
                let rel_within =
                    rel.strip_prefix(bundle_prefix)
                        .and_then(|s| s.strip_prefix('/'))
                        .unwrap_or(rel);
                let out_path = target.join(rel_within);
                if let Some(parent) = out_path.parent() {
                    fs::create_dir_all(parent)?;
                }
                fs::write(&out_path, f.contents())?;
            }
            DirEntry::Dir(d) => {
                extract_bundle_dir(d, bundle_prefix, target)?;
            }
        }
    }
    Ok(())
}

// ─── Commands: list / status ────────────────────────────────────────────

#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_list() -> Result<Vec<ExpertListItem>, ExpertsError> {
    let meta_list = bundled_metadata().to_vec();
    let manifest = load_manifest();
    let mut out = Vec::with_capacity(meta_list.len());
    for meta in meta_list {
        let central_path = expert_central_path(&meta.id);
        let installed_centrally = central_path.exists();
        let user_modified = manifest
            .experts
            .get(&meta.id)
            .map(|e| e.pending_user_review)
            .unwrap_or(false);
        out.push(ExpertListItem {
            metadata: meta,
            installed_centrally,
            user_modified,
            central_path: central_path.to_string_lossy().to_string(),
        });
    }
    Ok(out)
}

#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_list_for_agent(
    agent_type: AgentType,
) -> Result<Vec<ExpertListItem>, ExpertsError> {
    let _ = skill_storage_spec(agent_type)
        .ok_or(ExpertsError::UnsupportedAgent(agent_type))?;

    let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None)
        .map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?;

    let meta_list = bundled_metadata().to_vec();
    let manifest = load_manifest();
    let mut out = Vec::new();

    for meta in meta_list {
        let central_path = expert_central_path(&meta.id);
        let is_linked = dirs.iter().any(|dir| {
            let candidate = dir.join(&meta.id);
            classify_link(&candidate, &central_path) == ExpertLinkState::LinkedToCodeg
        });
        if !is_linked {
            continue;
        }

        let installed_centrally = central_path.exists();
        let user_modified = manifest
            .experts
            .get(&meta.id)
            .map(|e| e.pending_user_review)
            .unwrap_or(false);

        out.push(ExpertListItem {
            metadata: meta,
            installed_centrally,
            user_modified,
            central_path: central_path.to_string_lossy().to_string(),
        });
    }
    Ok(out)
}

#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_get_install_status(
    expert_id: String,
) -> Result<Vec<ExpertInstallStatus>, ExpertsError> {
    let expert_id = validate_skill_id(&expert_id)
        .map_err(|e| ExpertsError::Metadata(e.to_string()))?;
    let _ = find_metadata(&expert_id)?; // ensure it exists in the bundle
    let expected = expert_central_path(&expert_id);
    let agents = supported_agents();

    let mut out = Vec::with_capacity(agents.len());
    for agent in agents {
        let link_path = match agent_link_path(agent, &expert_id) {
            Ok(p) => p,
            Err(_) => continue,
        };
        let state = classify_link(&link_path, &expected);
        let target_path = read_link_target(&link_path).map(|p| p.to_string_lossy().to_string());
        out.push(ExpertInstallStatus {
            expert_id: expert_id.clone(),
            agent_type: agent,
            state,
            link_path: link_path.to_string_lossy().to_string(),
            target_path,
            expected_target_path: expected.to_string_lossy().to_string(),
            copy_mode: false,
        });
    }
    Ok(out)
}

fn supported_agents() -> Vec<AgentType> {
    const ALL: &[AgentType] = &[
        AgentType::ClaudeCode,
        AgentType::Codex,
        AgentType::OpenCode,
        AgentType::Gemini,
        AgentType::OpenClaw,
        AgentType::Cline,
    ];
    ALL.iter()
        .filter(|a| skill_storage_spec(**a).is_some())
        .copied()
        .collect()
}

// ─── Commands: link / unlink ────────────────────────────────────────────

#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_link_to_agent(
    expert_id: String,
    agent_type: AgentType,
) -> Result<ExpertInstallStatus, ExpertsError> {
    let expert_id = validate_skill_id(&expert_id)
        .map_err(|e| ExpertsError::Metadata(e.to_string()))?;
    let _ = find_metadata(&expert_id)?;
    let central = expert_central_path(&expert_id);
    if !central.exists() {
        return Err(ExpertsError::CentralUnavailable(format!(
            "expert '{expert_id}' is not installed in central store"
        )));
    }

    let _guard = mutation_lock().lock().await;
    let link_path = agent_link_path(agent_type, &expert_id)?;
    if let Some(parent) = link_path.parent() {
        fs::create_dir_all(parent)?;
    }

    let mut copy_mode = false;
    match create_link_raw(&central, &link_path) {
        Ok(is_copy) => {
            copy_mode = is_copy;
        }
        Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
            // Already exists — figure out what kind.
            match classify_link(&link_path, &central) {
                ExpertLinkState::LinkedToCodeg => {
                    // Idempotent success.
                }
                ExpertLinkState::BlockedByRealDirectory => {
                    return Err(ExpertsError::NameCollision {
                        path: link_path.to_string_lossy().to_string(),
                    });
                }
                ExpertLinkState::LinkedElsewhere | ExpertLinkState::Broken => {
                    let found = read_link_target(&link_path)
                        .map(|p| p.to_string_lossy().to_string())
                        .unwrap_or_else(|| "<unknown>".into());
                    return Err(ExpertsError::ForeignLink {
                        path: link_path.to_string_lossy().to_string(),
                        found,
                    });
                }
                ExpertLinkState::NotLinked => {
                    // Shouldn't happen after AlreadyExists, but retry once.
                    create_link_raw(&central, &link_path).map_err(|e| ExpertsError::Io(format!(
                        "retry link failed: {e}"
                    )))?;
                }
            }
        }
        Err(err) => return Err(ExpertsError::Io(err.to_string())),
    }

    let state = classify_link(&link_path, &central);
    let target_path = read_link_target(&link_path).map(|p| p.to_string_lossy().to_string());
    Ok(ExpertInstallStatus {
        expert_id: expert_id.clone(),
        agent_type,
        state,
        link_path: link_path.to_string_lossy().to_string(),
        target_path,
        expected_target_path: central.to_string_lossy().to_string(),
        copy_mode,
    })
}

#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_unlink_from_agent(
    expert_id: String,
    agent_type: AgentType,
) -> Result<(), ExpertsError> {
    let expert_id = validate_skill_id(&expert_id)
        .map_err(|e| ExpertsError::Metadata(e.to_string()))?;

    let _guard = mutation_lock().lock().await;

    // Scan ALL global dirs for this agent to handle shared-dir agents
    // (Codex, Gemini and Cline all also point at `~/.agents/skills/`).
    // Remove the link wherever it is found.
    let dirs = scoped_skill_dirs(agent_type, AgentSkillScope::Global, None)
        .map_err(|_| ExpertsError::UnsupportedAgent(agent_type))?;

    let central = expert_central_path(&expert_id);
    let mut removed = false;
    for dir in dirs {
        let candidate = dir.join(&expert_id);
        if !candidate.exists() && !path_is_symlink(&candidate) {
            continue;
        }
        let state = classify_link(&candidate, &central);
        if matches!(state, ExpertLinkState::LinkedToCodeg | ExpertLinkState::Broken) {
            // Safe to remove a link to our central store or a broken link.
            remove_skill_entry(&candidate)
                .map_err(|e| ExpertsError::Io(format!("remove link {}: {e}", candidate.display())))?;
            removed = true;
        } else if state == ExpertLinkState::LinkedElsewhere {
            return Err(ExpertsError::ForeignLink {
                path: candidate.to_string_lossy().to_string(),
                found: read_link_target(&candidate)
                    .map(|p| p.to_string_lossy().to_string())
                    .unwrap_or_else(|| "<unknown>".into()),
            });
        } else if state == ExpertLinkState::BlockedByRealDirectory {
            // Not ours; leave alone.
            continue;
        }
    }

    if !removed {
        // It was already unlinked — treat as idempotent success.
    }
    Ok(())
}

// ─── Commands: read / open ──────────────────────────────────────────────

#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_read_content(expert_id: String) -> Result<String, ExpertsError> {
    let expert_id = validate_skill_id(&expert_id)
        .map_err(|e| ExpertsError::Metadata(e.to_string()))?;
    let _ = find_metadata(&expert_id)?;
    let path = expert_central_path(&expert_id).join("SKILL.md");
    if !path.exists() {
        // Fall back to bundled copy when central store isn't populated.
        let bundled_rel = format!("skills/{expert_id}/SKILL.md");
        if let Some(f) = EXPERTS_BUNDLE.get_file(&bundled_rel) {
            if let Some(text) = f.contents_utf8() {
                return Ok(text.to_string());
            }
        }
        return Err(ExpertsError::CentralUnavailable(format!(
            "expert '{expert_id}' has no SKILL.md on disk"
        )));
    }
    let content = fs::read_to_string(&path)?;
    Ok(content)
}

#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn experts_open_central_dir() -> Result<String, ExpertsError> {
    let dir = central_experts_dir();
    fs::create_dir_all(&dir)?;
    Ok(dir.to_string_lossy().to_string())
}
