use std::collections::HashMap;
use std::path::Path;

use console::style;
use skillfile_core::error::SkillfileError;
use skillfile_core::lock::{lock_key, read_lock};
use skillfile_core::models::{short_sha, EntityType, Entry, LockEntry, Manifest, SourceFields};
use skillfile_core::parser::MANIFEST_NAME;
use skillfile_core::patch::{
    apply_patch_pure, dir_patch_path, has_dir_patch, has_patch, read_patch, walkdir,
};
use skillfile_deploy::paths::{installed_dir_file_sets, installed_paths};
use skillfile_sources::strategy::{content_file, is_dir_entry, meta_sha};
use skillfile_sources::sync::vendor_dir_for;

struct DirCheckCtx<'a> {
    entry: &'a Entry,
    vdir: &'a Path,
    installed: &'a HashMap<String, std::path::PathBuf>,
    repo_root: &'a Path,
}

fn is_cache_file_modified(cache_file: &Path, ctx: &DirCheckCtx<'_>) -> Result<bool, ()> {
    let filename = cache_file
        .strip_prefix(ctx.vdir)
        .map_err(|_| ())?
        .to_string_lossy()
        .to_string();
    let inst_path = match ctx.installed.get(&filename) {
        Some(p) if p.exists() => p,
        _ => return Ok(false),
    };
    let cache_text = std::fs::read_to_string(cache_file).map_err(|_| ())?;
    let expected_text = expected_dir_file_text(&filename, &cache_text, ctx)?;
    let installed_text = std::fs::read_to_string(inst_path).map_err(|_| ())?;
    Ok(installed_text != expected_text)
}

fn check_dir_files_modified(
    entry: &Entry,
    manifest: &Manifest,
    repo_root: &Path,
) -> Result<bool, ()> {
    let installed_sets = installed_dir_file_sets(entry, manifest, repo_root).map_err(|_| ())?;
    if installed_sets.iter().all(HashMap::is_empty) {
        return Ok(false);
    }
    let vdir = vendor_dir_for(entry, repo_root);
    if !vdir.is_dir() {
        return Ok(false);
    }
    let cache_files: Vec<_> = walkdir(&vdir)
        .into_iter()
        .filter(|cache_file| cache_file.file_name().is_some_and(|n| n != ".meta"))
        .collect();

    for installed in &installed_sets {
        if installed.is_empty() {
            continue;
        }
        let ctx = DirCheckCtx {
            entry,
            vdir: &vdir,
            installed,
            repo_root,
        };
        if installed_set_modified(&cache_files, &ctx)? {
            return Ok(true);
        }
    }
    Ok(false)
}

fn installed_set_modified(
    cache_files: &[std::path::PathBuf],
    ctx: &DirCheckCtx<'_>,
) -> Result<bool, ()> {
    for cache_file in cache_files {
        if is_cache_file_modified(cache_file, ctx)? {
            return Ok(true);
        }
    }
    Ok(false)
}

fn is_dir_modified_local(entry: &Entry, manifest: &Manifest, repo_root: &Path) -> bool {
    check_dir_files_modified(entry, manifest, repo_root).unwrap_or(false)
}

fn check_single_file_modified(
    entry: &Entry,
    manifest: &Manifest,
    repo_root: &Path,
) -> Result<bool, ()> {
    let installed_paths = installed_paths(entry, manifest, repo_root).map_err(|_| ())?;
    if installed_paths.iter().all(|path| !path.exists()) {
        return Ok(false);
    }
    let vdir = vendor_dir_for(entry, repo_root);
    let cf = content_file(entry);
    if cf.is_empty() {
        return Ok(false);
    }
    let cache_file = vdir.join(&cf);
    if !cache_file.exists() {
        return Ok(false);
    }
    let cache_text = std::fs::read_to_string(&cache_file).map_err(|_| ())?;
    let expected_text = expected_single_file_text(entry, &cache_text, repo_root)?;
    for installed_path in installed_paths {
        if !installed_path.exists() {
            continue;
        }
        let installed_text = std::fs::read_to_string(&installed_path).map_err(|_| ())?;
        if installed_text != expected_text {
            return Ok(true);
        }
    }
    Ok(false)
}

fn expected_single_file_text(
    entry: &Entry,
    cache_text: &str,
    repo_root: &Path,
) -> Result<String, ()> {
    if !has_patch(entry, repo_root) {
        return Ok(cache_text.to_string());
    }
    let patch_text = read_patch(entry, repo_root).map_err(|_| ())?;
    apply_patch_pure(cache_text, &patch_text).map_err(|_| ())
}

fn expected_dir_file_text(
    filename: &str,
    cache_text: &str,
    ctx: &DirCheckCtx<'_>,
) -> Result<String, ()> {
    let patch_path = dir_patch_path(ctx.entry, filename, ctx.repo_root);
    if !patch_path.exists() {
        return Ok(cache_text.to_string());
    }
    let patch_text = std::fs::read_to_string(patch_path).map_err(|_| ())?;
    apply_patch_pure(cache_text, &patch_text).map_err(|_| ())
}

/// Check if an installed file differs from cache (local only, no network).
pub(crate) fn is_modified_local(entry: &Entry, manifest: &Manifest, repo_root: &Path) -> bool {
    if matches!(entry.source, SourceFields::Local { .. }) {
        return false;
    }
    if is_dir_entry(entry) {
        return is_dir_modified_local(entry, manifest, repo_root);
    }
    check_single_file_modified(entry, manifest, repo_root).unwrap_or(false)
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum InstallState {
    Installed,
    PartiallyInstalled,
    NotInstalled,
}

fn install_state(entry: &Entry, manifest: &Manifest, repo_root: &Path) -> InstallState {
    if is_dir_entry(entry) {
        dir_install_state(entry, manifest, repo_root).unwrap_or(InstallState::NotInstalled)
    } else {
        single_file_install_state(entry, manifest, repo_root).unwrap_or(InstallState::NotInstalled)
    }
}

fn single_file_install_state(
    entry: &Entry,
    manifest: &Manifest,
    repo_root: &Path,
) -> Result<InstallState, ()> {
    let paths = installed_paths(entry, manifest, repo_root).map_err(|_| ())?;
    Ok(install_state_from_counts(
        paths.len(),
        paths.iter().filter(|path| path.exists()).count(),
    ))
}

fn dir_install_state(
    entry: &Entry,
    manifest: &Manifest,
    repo_root: &Path,
) -> Result<InstallState, ()> {
    let file_sets = installed_dir_file_sets(entry, manifest, repo_root).map_err(|_| ())?;
    Ok(install_state_from_counts(
        file_sets.len(),
        file_sets.iter().filter(|files| !files.is_empty()).count(),
    ))
}

fn install_state_from_counts(total: usize, present: usize) -> InstallState {
    if total == 0 || present == 0 {
        return InstallState::NotInstalled;
    }
    if present == total {
        InstallState::Installed
    } else {
        InstallState::PartiallyInstalled
    }
}

struct StatusContext<'a> {
    manifest: &'a Manifest,
    repo_root: &'a Path,
    locked: &'a std::collections::BTreeMap<String, LockEntry>,
    check_upstream: bool,
    sha_cache: &'a mut HashMap<(String, String), String>,
    col_w: usize,
}

fn resolve_upstream_sha(
    ctx: &mut StatusContext<'_>,
    owner_repo: &str,
    ref_: &str,
) -> Result<String, SkillfileError> {
    let cache_key = (owner_repo.to_string(), ref_.to_string());
    if let Some(cached) = ctx.sha_cache.get(&cache_key) {
        return Ok(cached.clone());
    }
    let client = skillfile_sources::http::UreqClient::new();
    let resolved = skillfile_sources::resolver::resolve_github_sha(&client, owner_repo, ref_)?;
    ctx.sha_cache.insert(cache_key, resolved.clone());
    Ok(resolved)
}

fn upstream_status_for_github(
    ctx: &mut StatusContext<'_>,
    entry: &Entry,
    sha: &str,
) -> Result<String, SkillfileError> {
    let SourceFields::Github {
        owner_repo, ref_, ..
    } = &entry.source
    else {
        let sha_short = short_sha(sha);
        return Ok(format!(
            "{}  {}",
            style("locked").dim(),
            style(format!("sha={sha_short}")).dim(),
        ));
    };
    let owner_repo = owner_repo.clone();
    let ref_ = ref_.clone();
    let upstream_sha = resolve_upstream_sha(ctx, &owner_repo, &ref_)?;
    let sha_short = short_sha(sha);
    if upstream_sha == sha {
        Ok(format!(
            "{}  {}",
            style("up to date").green(),
            style(format!("sha={sha_short}")).dim(),
        ))
    } else {
        let upstream_short = short_sha(&upstream_sha);
        Ok(format!(
            "{}  locked={}  upstream={}",
            style("outdated").yellow(),
            style(sha_short).dim(),
            style(upstream_short).cyan(),
        ))
    }
}

fn build_annotation(entry: &Entry, ctx: &StatusContext<'_>) -> String {
    let mut parts: Vec<String> = Vec::new();
    match install_state(entry, ctx.manifest, ctx.repo_root) {
        InstallState::Installed => {}
        InstallState::PartiallyInstalled => {
            parts.push(style("[partial install]").yellow().to_string());
        }
        InstallState::NotInstalled => {
            parts.push(style("[not installed]").red().to_string());
        }
    }
    if has_patch(entry, ctx.repo_root) || has_dir_patch(entry, ctx.repo_root) {
        parts.push(style("[pinned]").cyan().to_string());
    }
    if is_modified_local(entry, ctx.manifest, ctx.repo_root) {
        parts.push(style("[modified]").yellow().to_string());
    }
    if parts.is_empty() {
        String::new()
    } else {
        format!("  {}", parts.join("  "))
    }
}

fn format_entry_status(
    entry: &Entry,
    ctx: &mut StatusContext<'_>,
) -> Result<String, SkillfileError> {
    let key = lock_key(entry);
    let name = &entry.name;
    let col_w = ctx.col_w;

    if let SourceFields::Local { path } = &entry.source {
        let status = if ctx.repo_root.join(path).exists() {
            style("local").green().to_string()
        } else {
            format!(
                "{} {} path missing: {path}",
                style("local").green(),
                style("\u{2717}").red(),
            )
        };
        return Ok(format!("{name:<col_w$} {status}"));
    }

    let Some(locked_info) = ctx.locked.get(&key) else {
        return Ok(format!("{name:<col_w$} {}", style("unlocked").yellow()));
    };

    let sha = &locked_info.sha;
    let vdir = vendor_dir_for(entry, ctx.repo_root);
    let meta = meta_sha(&vdir);
    let sha_short = short_sha(sha);

    let base_status = if meta.as_deref() != Some(sha.as_str()) {
        format!(
            "{}  {}",
            style("locked").dim(),
            style(format!("sha={sha_short}  (missing or stale)")).yellow(),
        )
    } else if ctx.check_upstream {
        upstream_status_for_github(ctx, entry, sha)?
    } else {
        format!(
            "{}  {}",
            style("locked").dim(),
            style(format!("sha={sha_short}")).dim(),
        )
    };

    let annotation = build_annotation(entry, ctx);
    Ok(format!("{name:<col_w$} {base_status}{annotation}"))
}

pub fn cmd_status(repo_root: &Path, check_upstream: bool) -> Result<(), SkillfileError> {
    let manifest_path = repo_root.join(MANIFEST_NAME);
    if !manifest_path.exists() {
        return Err(SkillfileError::Manifest(format!(
            "{MANIFEST_NAME} not found in {}. Create one and run `skillfile init`.",
            repo_root.display()
        )));
    }

    let manifest = crate::config::parse_and_resolve(&manifest_path)?;
    let locked = read_lock(repo_root)?;

    let col_w = manifest
        .entries
        .iter()
        .map(|e| e.name.len())
        .max()
        .unwrap_or(10)
        + 2;

    let mut ctx = StatusContext {
        manifest: &manifest,
        repo_root,
        locked: &locked,
        check_upstream,
        sha_cache: &mut HashMap::new(),
        col_w,
    };

    for entry in &manifest.entries {
        let line = format_entry_status(entry, &mut ctx)?;
        println!("{line}");
    }

    if !manifest.entries.is_empty() {
        let summary = format_summary(&manifest, repo_root);
        println!("\n{summary}");
    }

    Ok(())
}

struct StatusCounts {
    skills: usize,
    agents: usize,
    pinned: usize,
    modified: usize,
}

fn count_entries(manifest: &Manifest, repo_root: &Path) -> StatusCounts {
    let mut counts = StatusCounts {
        skills: 0,
        agents: 0,
        pinned: 0,
        modified: 0,
    };

    for entry in &manifest.entries {
        match entry.entity_type {
            EntityType::Skill => counts.skills += 1,
            EntityType::Agent => counts.agents += 1,
        }
        if has_patch(entry, repo_root) || has_dir_patch(entry, repo_root) {
            counts.pinned += 1;
        }
        if is_modified_local(entry, manifest, repo_root) {
            counts.modified += 1;
        }
    }

    counts
}

fn format_summary(manifest: &Manifest, repo_root: &Path) -> String {
    use std::fmt::Write;

    let counts = count_entries(manifest, repo_root);

    let mut parts = Vec::new();
    if counts.skills > 0 {
        parts.push(format!(
            "{} skill{}",
            counts.skills,
            if counts.skills == 1 { "" } else { "s" }
        ));
    }
    if counts.agents > 0 {
        parts.push(format!(
            "{} agent{}",
            counts.agents,
            if counts.agents == 1 { "" } else { "s" }
        ));
    }

    let mut summary = parts.join(", ");

    if counts.pinned > 0 {
        let _ = write!(summary, " · {} pinned", counts.pinned);
    }
    if counts.modified > 0 {
        let _ = write!(summary, " · {} modified", counts.modified);
    }

    let mut lines = format!("  {summary}");

    if !manifest.install_targets.is_empty() {
        let targets: Vec<String> = manifest
            .install_targets
            .iter()
            .map(ToString::to_string)
            .collect();
        let _ = write!(lines, "\n  Install targets: {}", targets.join(", "));
    }

    lines
}

#[cfg(test)]
mod tests {
    use super::*;
    use skillfile_core::models::{EntityType, InstallTarget, Scope, SourceFields};

    fn write_manifest(dir: &Path, content: &str) {
        std::fs::write(dir.join(MANIFEST_NAME), content).unwrap();
    }

    fn write_lock(dir: &Path, data: &serde_json::Value) {
        std::fs::write(
            dir.join("Skillfile.lock"),
            serde_json::to_string_pretty(data).unwrap(),
        )
        .unwrap();
    }

    struct VendorEntry<'a> {
        entity_type: &'a str,
        name: &'a str,
    }

    fn write_meta(dir: &Path, ve: &VendorEntry<'_>, sha: &str) {
        let vdir = dir
            .join(".skillfile/cache")
            .join(format!("{}s", ve.entity_type))
            .join(ve.name);
        std::fs::create_dir_all(&vdir).unwrap();
        std::fs::write(
            vdir.join(".meta"),
            serde_json::json!({"sha": sha}).to_string(),
        )
        .unwrap();
    }

    struct VendorFile<'a> {
        entry: &'a VendorEntry<'a>,
        filename: &'a str,
    }

    fn write_vendor_content(dir: &Path, vf: &VendorFile<'_>, content: &str) {
        let vdir = dir
            .join(".skillfile/cache")
            .join(format!("{}s", vf.entry.entity_type))
            .join(vf.entry.name);
        std::fs::create_dir_all(&vdir).unwrap();
        std::fs::write(vdir.join(vf.filename), content).unwrap();
    }

    const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    const ORIGINAL: &str = "# Agent\n\nUpstream content.\n";
    const MODIFIED: &str = "# Agent\n\nUpstream content.\n\n## Custom Section\n\nAdded by user.\n";
    const VE_AGENT: VendorEntry<'_> = VendorEntry {
        entity_type: "agent",
        name: "my-agent",
    };

    fn agent_locked_map(sha: &str) -> std::collections::BTreeMap<String, LockEntry> {
        std::collections::BTreeMap::from([(
            "github/agent/my-agent".to_string(),
            LockEntry {
                sha: sha.to_string(),
                raw_url: "https://example.com".to_string(),
            },
        )])
    }

    fn local_entry(name: &str, path: &str) -> Entry {
        Entry {
            entity_type: EntityType::Skill,
            name: name.into(),
            source: SourceFields::Local { path: path.into() },
        }
    }

    fn claude_local_target() -> InstallTarget {
        InstallTarget {
            adapter: "claude-code".into(),
            scope: Scope::Local,
        }
    }

    fn agent_manifest() -> Manifest {
        Manifest {
            entries: vec![Entry {
                entity_type: EntityType::Agent,
                name: "my-agent".into(),
                source: SourceFields::Github {
                    owner_repo: "owner/repo".into(),
                    path_in_repo: "agents/agent.md".into(),
                    ref_: "main".into(),
                },
            }],
            install_targets: vec![claude_local_target()],
        }
    }

    fn dir_skill_manifest() -> Manifest {
        Manifest {
            entries: vec![Entry {
                entity_type: EntityType::Skill,
                name: "my-dir".into(),
                source: SourceFields::Github {
                    owner_repo: "owner/repo".into(),
                    path_in_repo: "skills/my-dir".into(),
                    ref_: "main".into(),
                },
            }],
            install_targets: vec![claude_local_target()],
        }
    }

    #[test]
    fn no_manifest() {
        let dir = tempfile::tempdir().unwrap();
        let result = cmd_status(dir.path(), false);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not found"));
    }

    #[test]
    fn local_entry_path_exists_shows_local() {
        let dir = tempfile::tempdir().unwrap();
        let source = dir.path().join("skills/foo.md");
        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
        std::fs::write(&source, "# Foo").unwrap();
        write_manifest(dir.path(), "local  skill  foo  skills/foo.md\n");
        cmd_status(dir.path(), false).unwrap();
    }

    #[test]
    fn local_entry_path_missing_shows_status_without_error() {
        let dir = tempfile::tempdir().unwrap();
        write_manifest(dir.path(), "local  skill  foo  skills/foo.md\n");
        // Missing path should not cause an error — status reports it inline
        cmd_status(dir.path(), false).unwrap();
    }

    #[test]
    fn github_entry_unlocked() {
        let dir = tempfile::tempdir().unwrap();
        write_manifest(
            dir.path(),
            "github  agent  my-agent  owner/repo  agents/agent.md  main\n",
        );
        cmd_status(dir.path(), false).unwrap();
    }

    #[test]
    fn github_entry_locked_vendor_matches() {
        let dir = tempfile::tempdir().unwrap();
        let sha = "87321636a1c666283d8f17398b45c2644395044b";
        write_manifest(
            dir.path(),
            "github  agent  my-agent  owner/repo  agents/agent.md  main\n",
        );
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": sha, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, sha);
        cmd_status(dir.path(), false).unwrap();
    }

    #[test]
    fn github_entry_locked_vendor_missing() {
        let dir = tempfile::tempdir().unwrap();
        let sha = "87321636a1c666283d8f17398b45c2644395044b";
        write_manifest(
            dir.path(),
            "github  agent  my-agent  owner/repo  agents/agent.md  main\n",
        );
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": sha, "raw_url": "https://example.com"}}),
        );
        // No .meta written
        cmd_status(dir.path(), false).unwrap();
    }

    #[test]
    fn modified_shows_for_changed_installed_file() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, SHA);
        write_vendor_content(
            dir.path(),
            &VendorFile {
                entry: &VE_AGENT,
                filename: "agent.md",
            },
            ORIGINAL,
        );
        let installed = dir.path().join(".claude/agents");
        std::fs::create_dir_all(&installed).unwrap();
        std::fs::write(installed.join("my-agent.md"), MODIFIED).unwrap();

        // is_modified_local should return true
        let manifest = agent_manifest();
        let entry = &manifest.entries[0];
        assert!(is_modified_local(entry, &manifest, dir.path()));
    }

    #[test]
    fn modified_not_shown_for_clean_entry() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, SHA);
        write_vendor_content(
            dir.path(),
            &VendorFile {
                entry: &VE_AGENT,
                filename: "agent.md",
            },
            ORIGINAL,
        );
        let installed = dir.path().join(".claude/agents");
        std::fs::create_dir_all(&installed).unwrap();
        std::fs::write(installed.join("my-agent.md"), ORIGINAL).unwrap();

        let manifest = agent_manifest();
        let entry = &manifest.entries[0];
        assert!(!is_modified_local(entry, &manifest, dir.path()));
    }

    #[test]
    fn modified_not_shown_when_not_installed() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, SHA);
        write_vendor_content(
            dir.path(),
            &VendorFile {
                entry: &VE_AGENT,
                filename: "agent.md",
            },
            ORIGINAL,
        );
        // No installed file

        let manifest = agent_manifest();
        let entry = &manifest.entries[0];
        assert!(!is_modified_local(entry, &manifest, dir.path()));
    }

    #[test]
    fn modified_not_shown_without_vendor_cache() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, SHA);
        // No vendor cache content file
        let installed = dir.path().join(".claude/agents");
        std::fs::create_dir_all(&installed).unwrap();
        std::fs::write(installed.join("my-agent.md"), MODIFIED).unwrap();

        let manifest = agent_manifest();
        let entry = &manifest.entries[0];
        assert!(!is_modified_local(entry, &manifest, dir.path()));
    }

    // Dir-entry tests: claude-code skills use Nested dir mode (.claude/skills/<name>/)

    /// Build a manifest with a github skill dir entry (path_in_repo without .md).
    /// claude-code skills are Nested, so installed files live under .claude/skills/<name>/.
    fn setup_dir_entry(dir: &Path, installed_content: Option<&str>, cache_content: &str) {
        write_lock(
            dir,
            &serde_json::json!({"github/skill/my-dir": {"sha": SHA, "raw_url": "https://example.com"}}),
        );

        // Write the cache vendor dir with a file
        let vdir = dir.join(".skillfile/cache").join("skills").join("my-dir");
        std::fs::create_dir_all(&vdir).unwrap();
        std::fs::write(vdir.join("tool.md"), cache_content).unwrap();
        std::fs::write(
            vdir.join(".meta"),
            serde_json::json!({"sha": SHA}).to_string(),
        )
        .unwrap();

        // Write the installed nested dir if content is provided
        if let Some(content) = installed_content {
            let installed_dir = dir.join(".claude/skills/my-dir");
            std::fs::create_dir_all(&installed_dir).unwrap();
            std::fs::write(installed_dir.join("tool.md"), content).unwrap();
        }
    }

    #[test]
    fn dir_entry_modified_shows_modified() {
        let dir = tempfile::tempdir().unwrap();
        setup_dir_entry(dir.path(), Some(MODIFIED), ORIGINAL);

        let manifest = dir_skill_manifest();
        let entry = &manifest.entries[0];
        assert!(
            is_modified_local(entry, &manifest, dir.path()),
            "expected modified=true when installed content differs from cache"
        );
    }

    #[test]
    fn dir_entry_clean_shows_not_modified() {
        let dir = tempfile::tempdir().unwrap();
        setup_dir_entry(dir.path(), Some(ORIGINAL), ORIGINAL);

        let manifest = dir_skill_manifest();
        let entry = &manifest.entries[0];
        assert!(
            !is_modified_local(entry, &manifest, dir.path()),
            "expected modified=false when installed content matches cache"
        );
    }

    #[test]
    fn dir_entry_missing_vendor_dir_not_modified() {
        let dir = tempfile::tempdir().unwrap();
        // Write lock but no vendor cache dir at all
        write_lock(
            dir.path(),
            &serde_json::json!({"github/skill/my-dir": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        // No .skillfile/cache/skills/my-dir/ written

        let manifest = dir_skill_manifest();
        let entry = &manifest.entries[0];
        assert!(
            !is_modified_local(entry, &manifest, dir.path()),
            "expected modified=false when vendor cache dir is absent"
        );
    }

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

        let manifest = Manifest {
            entries: vec![local_entry("foo", "skills/foo.md")],
            ..Manifest::default()
        };
        let entry = &manifest.entries[0];
        assert!(
            !is_modified_local(entry, &manifest, dir.path()),
            "local entries must always report modified=false"
        );
    }

    #[test]
    fn pinned_entry_not_modified() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, SHA);
        write_vendor_content(
            dir.path(),
            &VendorFile {
                entry: &VE_AGENT,
                filename: "agent.md",
            },
            ORIGINAL,
        );
        let installed = dir.path().join(".claude/agents");
        std::fs::create_dir_all(&installed).unwrap();
        std::fs::write(installed.join("my-agent.md"), MODIFIED).unwrap();

        // Write a patch file — entry is pinned
        let patches_dir = dir.path().join(".skillfile/patches/agents");
        std::fs::create_dir_all(&patches_dir).unwrap();
        std::fs::write(
            patches_dir.join("my-agent.patch"),
            "--- a/my-agent.md\n+++ b/my-agent.md\n@@ -1,3 +1,7 @@\n # Agent\n \n Upstream content.\n+\n+## Custom Section\n+\n+Added by user.\n",
        )
        .unwrap();

        let manifest = agent_manifest();
        let entry = &manifest.entries[0];
        assert!(
            !is_modified_local(entry, &manifest, dir.path()),
            "pinned entries must not report as modified"
        );
    }

    #[test]
    fn dir_entry_pinned_not_modified() {
        let dir = tempfile::tempdir().unwrap();
        setup_dir_entry(dir.path(), Some(MODIFIED), ORIGINAL);

        // Write a dir patch — entry is pinned
        let patches_dir = dir.path().join(".skillfile/patches/skills/my-dir");
        std::fs::create_dir_all(&patches_dir).unwrap();
        std::fs::write(
            patches_dir.join("tool.md.patch"),
            "--- a/tool.md\n+++ b/tool.md\n@@ -1,3 +1,7 @@\n # Agent\n \n Upstream content.\n+\n+## Custom Section\n+\n+Added by user.\n",
        )
        .unwrap();

        let manifest = dir_skill_manifest();
        let entry = &manifest.entries[0];
        assert!(
            !is_modified_local(entry, &manifest, dir.path()),
            "pinned dir entries must not report as modified"
        );
    }

    #[test]
    fn pinned_entry_with_extra_edits_reports_modified() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, SHA);
        write_vendor_content(
            dir.path(),
            &VendorFile {
                entry: &VE_AGENT,
                filename: "agent.md",
            },
            ORIGINAL,
        );
        let installed = dir.path().join(".claude/agents");
        std::fs::create_dir_all(&installed).unwrap();
        std::fs::write(
            installed.join("my-agent.md"),
            format!("{MODIFIED}\nExtra drift.\n"),
        )
        .unwrap();

        let patches_dir = dir.path().join(".skillfile/patches/agents");
        std::fs::create_dir_all(&patches_dir).unwrap();
        std::fs::write(
            patches_dir.join("my-agent.patch"),
            "--- a/my-agent.md\n+++ b/my-agent.md\n@@ -1,3 +1,7 @@\n # Agent\n \n Upstream content.\n+\n+## Custom Section\n+\n+Added by user.\n",
        )
        .unwrap();

        let manifest = agent_manifest();
        let entry = &manifest.entries[0];
        assert!(
            is_modified_local(entry, &manifest, dir.path()),
            "pinned entries with extra unsaved edits must report modified"
        );
    }

    #[test]
    fn locked_remote_entry_without_install_shows_not_installed() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, SHA);
        write_vendor_content(
            dir.path(),
            &VendorFile {
                entry: &VE_AGENT,
                filename: "agent.md",
            },
            ORIGINAL,
        );

        let manifest = agent_manifest();
        let mut sha_cache = HashMap::new();
        let mut ctx = StatusContext {
            manifest: &manifest,
            repo_root: dir.path(),
            locked: &agent_locked_map(SHA),
            check_upstream: false,
            sha_cache: &mut sha_cache,
            col_w: 10,
        };

        let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
        assert!(
            line.contains("not installed"),
            "expected missing install annotation, got: {line}"
        );
    }

    // -- format_entry_status: local path drift --

    #[test]
    fn local_entry_existing_path_formats_as_local() {
        let dir = tempfile::tempdir().unwrap();
        let source = dir.path().join("skills/foo.md");
        std::fs::create_dir_all(source.parent().unwrap()).unwrap();
        std::fs::write(&source, "# Foo").unwrap();

        let manifest = Manifest {
            entries: vec![local_entry("foo", "skills/foo.md")],
            ..Manifest::default()
        };
        let locked = std::collections::BTreeMap::new();
        let mut sha_cache = HashMap::new();
        let mut ctx = StatusContext {
            manifest: &manifest,
            repo_root: dir.path(),
            locked: &locked,
            check_upstream: false,
            sha_cache: &mut sha_cache,
            col_w: 12,
        };
        let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
        assert!(
            line.contains("local") && !line.contains("path missing"),
            "existing path should show 'local' without warning, got: {line}"
        );
    }

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

        let manifest = Manifest {
            entries: vec![local_entry("foo", "skills/foo.md")],
            ..Manifest::default()
        };
        let locked = std::collections::BTreeMap::new();
        let mut sha_cache = HashMap::new();
        let mut ctx = StatusContext {
            manifest: &manifest,
            repo_root: dir.path(),
            locked: &locked,
            check_upstream: false,
            sha_cache: &mut sha_cache,
            col_w: 12,
        };
        let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
        assert!(
            line.contains("path missing"),
            "missing path should show warning, got: {line}"
        );
        assert!(
            line.contains("skills/foo.md"),
            "warning should include the path, got: {line}"
        );
    }

    // -- format_summary --

    #[test]
    fn summary_counts_skills_and_agents() {
        let dir = tempfile::tempdir().unwrap();
        let manifest = Manifest {
            entries: vec![
                local_entry("a", "a.md"),
                local_entry("b", "b.md"),
                Entry {
                    entity_type: EntityType::Agent,
                    name: "c".into(),
                    source: SourceFields::Github {
                        owner_repo: "o/r".into(),
                        path_in_repo: "c.md".into(),
                        ref_: "main".into(),
                    },
                },
            ],
            install_targets: vec![],
        };
        let out = format_summary(&manifest, dir.path());
        assert!(
            out.contains("2 skills, 1 agent"),
            "expected skill/agent counts, got: {out}"
        );
    }

    #[test]
    fn summary_shows_install_targets() {
        let dir = tempfile::tempdir().unwrap();
        let manifest = Manifest {
            entries: vec![local_entry("x", "x.md")],
            install_targets: vec![
                claude_local_target(),
                InstallTarget {
                    adapter: "cursor".into(),
                    scope: Scope::Global,
                },
            ],
        };
        let out = format_summary(&manifest, dir.path());
        assert!(
            out.contains("Install targets: claude-code (local), cursor (global)"),
            "expected install targets line, got: {out}"
        );
    }

    #[test]
    fn summary_shows_pinned_and_modified() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, SHA);
        write_vendor_content(
            dir.path(),
            &VendorFile {
                entry: &VE_AGENT,
                filename: "agent.md",
            },
            ORIGINAL,
        );
        let installed = dir.path().join(".claude/agents");
        std::fs::create_dir_all(&installed).unwrap();
        std::fs::write(installed.join("my-agent.md"), MODIFIED).unwrap();

        let manifest = agent_manifest();
        let out = format_summary(&manifest, dir.path());
        assert!(out.contains("1 agent"), "expected agent count, got: {out}");
        assert!(
            out.contains("1 modified"),
            "expected modified flag, got: {out}"
        );
    }

    #[test]
    fn modified_detects_second_install_target_for_single_file_entry() {
        let dir = tempfile::tempdir().unwrap();
        let entry = Entry {
            entity_type: EntityType::Skill,
            name: "my-skill".into(),
            source: SourceFields::Github {
                owner_repo: "owner/repo".into(),
                path_in_repo: "skills/my-skill.md".into(),
                ref_: "main".into(),
            },
        };
        let manifest = Manifest {
            entries: vec![entry.clone()],
            install_targets: vec![
                claude_local_target(),
                InstallTarget {
                    adapter: "copilot".into(),
                    scope: Scope::Local,
                },
            ],
        };

        write_lock(
            dir.path(),
            &serde_json::json!({"github/skill/my-skill": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(
            dir.path(),
            &VendorEntry {
                entity_type: "skill",
                name: "my-skill",
            },
            SHA,
        );
        write_vendor_content(
            dir.path(),
            &VendorFile {
                entry: &VendorEntry {
                    entity_type: "skill",
                    name: "my-skill",
                },
                filename: "my-skill.md",
            },
            ORIGINAL,
        );
        let installed = dir.path().join(".github/skills/my-skill");
        std::fs::create_dir_all(&installed).unwrap();
        std::fs::write(installed.join("SKILL.md"), MODIFIED).unwrap();

        assert!(is_modified_local(&entry, &manifest, dir.path()));
        let out = format_summary(&manifest, dir.path());
        assert!(
            out.contains("1 modified"),
            "expected second-target modification to count, got: {out}"
        );
    }

    #[test]
    fn modified_detects_second_install_target_for_directory_entry() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/skill/my-dir": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        let vdir = dir.path().join(".skillfile/cache/skills/my-dir");
        std::fs::create_dir_all(&vdir).unwrap();
        std::fs::write(vdir.join("tool.md"), ORIGINAL).unwrap();
        std::fs::write(
            vdir.join(".meta"),
            serde_json::json!({"sha": SHA}).to_string(),
        )
        .unwrap();
        let installed = dir.path().join(".github/skills/my-dir");
        std::fs::create_dir_all(&installed).unwrap();
        std::fs::write(installed.join("tool.md"), MODIFIED).unwrap();

        let manifest = Manifest {
            entries: dir_skill_manifest().entries,
            install_targets: vec![
                claude_local_target(),
                InstallTarget {
                    adapter: "copilot".into(),
                    scope: Scope::Local,
                },
            ],
        };
        let entry = &manifest.entries[0];

        assert!(is_modified_local(entry, &manifest, dir.path()));
        let out = format_summary(&manifest, dir.path());
        assert!(
            out.contains("1 modified"),
            "expected second-target directory modification to count, got: {out}"
        );
    }

    #[test]
    fn summary_singular_skill() {
        let dir = tempfile::tempdir().unwrap();
        let manifest = Manifest {
            entries: vec![local_entry("solo", "solo.md")],
            install_targets: vec![],
        };
        let out = format_summary(&manifest, dir.path());
        assert!(
            out.contains("1 skill") && !out.contains("1 skills"),
            "expected singular 'skill', got: {out}"
        );
    }

    #[test]
    fn annotation_shows_pinned_for_patched_entry() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, SHA);
        // Create a patch file to trigger [pinned]
        let patch_dir = dir.path().join(".skillfile/patches/agents");
        std::fs::create_dir_all(&patch_dir).unwrap();
        std::fs::write(patch_dir.join("my-agent.patch"), "--- a\n+++ b\n").unwrap();

        let manifest = agent_manifest();
        let locked = agent_locked_map(SHA);
        let mut sha_cache = HashMap::new();
        let mut ctx = StatusContext {
            manifest: &manifest,
            repo_root: dir.path(),
            locked: &locked,
            check_upstream: false,
            sha_cache: &mut sha_cache,
            col_w: 12,
        };
        let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
        assert!(
            line.contains("pinned"),
            "expected '[pinned]' annotation, got: {line}"
        );
    }

    #[test]
    fn upstream_up_to_date_shows_green() {
        let dir = tempfile::tempdir().unwrap();
        let sha = SHA;
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": sha, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, sha);
        let manifest = agent_manifest();
        let locked = agent_locked_map(sha);
        let mut sha_cache = HashMap::new();
        // Pre-populate cache so no HTTP call is made; same sha = up to date
        sha_cache.insert(
            ("owner/repo".to_string(), "main".to_string()),
            sha.to_string(),
        );
        let mut ctx = StatusContext {
            manifest: &manifest,
            repo_root: dir.path(),
            locked: &locked,
            check_upstream: true,
            sha_cache: &mut sha_cache,
            col_w: 12,
        };
        let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
        assert!(
            line.contains("up to date"),
            "expected 'up to date', got: {line}"
        );
    }

    #[test]
    fn upstream_outdated_shows_yellow() {
        let dir = tempfile::tempdir().unwrap();
        let manifest = agent_manifest();
        let entry = &manifest.entries[0];
        let locked = std::collections::BTreeMap::new();
        let mut sha_cache = HashMap::new();
        sha_cache.insert(
            ("owner/repo".to_string(), "main".to_string()),
            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
        );
        let mut ctx = StatusContext {
            manifest: &manifest,
            repo_root: dir.path(),
            locked: &locked,
            check_upstream: true,
            sha_cache: &mut sha_cache,
            col_w: 12,
        };
        let locked_sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
        let result = upstream_status_for_github(&mut ctx, entry, locked_sha).unwrap();
        assert!(
            result.contains("outdated"),
            "expected 'outdated', got: {result}"
        );
    }

    #[test]
    fn github_entry_unlocked_shows_unlocked() {
        let dir = tempfile::tempdir().unwrap();
        let manifest = agent_manifest();
        let locked = std::collections::BTreeMap::new();
        let mut sha_cache = HashMap::new();
        let mut ctx = StatusContext {
            manifest: &manifest,
            repo_root: dir.path(),
            locked: &locked,
            check_upstream: false,
            sha_cache: &mut sha_cache,
            col_w: 12,
        };
        let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
        assert!(
            line.contains("unlocked"),
            "expected 'unlocked' in output, got: {line}"
        );
    }

    #[test]
    fn github_entry_locked_stale_meta_shows_warning() {
        let dir = tempfile::tempdir().unwrap();
        let sha = SHA;
        write_manifest(
            dir.path(),
            "github  agent  my-agent  owner/repo  agents/agent.md  main\n",
        );
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": sha, "raw_url": "https://example.com"}}),
        );
        // No meta written => stale
        let manifest = agent_manifest();
        let locked = agent_locked_map(sha);
        let mut sha_cache = HashMap::new();
        let mut ctx = StatusContext {
            manifest: &manifest,
            repo_root: dir.path(),
            locked: &locked,
            check_upstream: false,
            sha_cache: &mut sha_cache,
            col_w: 12,
        };
        let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
        assert!(
            line.contains("missing or stale"),
            "expected 'missing or stale' in output, got: {line}"
        );
    }

    #[test]
    fn github_entry_locked_clean_shows_sha() {
        let dir = tempfile::tempdir().unwrap();
        let sha = SHA;
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": sha, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, sha);
        let manifest = agent_manifest();
        let locked = agent_locked_map(sha);
        let mut sha_cache = HashMap::new();
        let mut ctx = StatusContext {
            manifest: &manifest,
            repo_root: dir.path(),
            locked: &locked,
            check_upstream: false,
            sha_cache: &mut sha_cache,
            col_w: 12,
        };
        let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
        assert!(
            line.contains("locked") && line.contains(&sha[..12]),
            "expected 'locked' with sha, got: {line}"
        );
    }

    #[test]
    fn annotation_shows_modified_for_changed_file() {
        let dir = tempfile::tempdir().unwrap();
        write_lock(
            dir.path(),
            &serde_json::json!({"github/agent/my-agent": {"sha": SHA, "raw_url": "https://example.com"}}),
        );
        write_meta(dir.path(), &VE_AGENT, SHA);
        write_vendor_content(
            dir.path(),
            &VendorFile {
                entry: &VE_AGENT,
                filename: "agent.md",
            },
            ORIGINAL,
        );
        let installed = dir.path().join(".claude/agents");
        std::fs::create_dir_all(&installed).unwrap();
        std::fs::write(installed.join("my-agent.md"), MODIFIED).unwrap();

        let manifest = agent_manifest();
        let locked = agent_locked_map(SHA);
        let mut sha_cache = HashMap::new();
        let mut ctx = StatusContext {
            manifest: &manifest,
            repo_root: dir.path(),
            locked: &locked,
            check_upstream: false,
            sha_cache: &mut sha_cache,
            col_w: 12,
        };
        let line = format_entry_status(&manifest.entries[0], &mut ctx).unwrap();
        assert!(
            line.contains("modified"),
            "expected '[modified]' annotation, got: {line}"
        );
    }
}
