#!/usr/bin/env python3
"""Scan for duplicate skills across the neoskills bank, agent targets, and GitHub repos.

Usage:
    python3 dedup_scan.py [--bank ~/.neoskills] [--targets claude opencode] [--repos neolaf2/mySkills]

Outputs a JSON report of duplicate groups with checksums, locations, and
recommended actions.
"""

import argparse
import hashlib
import json
import re
import subprocess
import sys
import tempfile
from collections import defaultdict
from difflib import SequenceMatcher
from pathlib import Path


def sha256(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


# Files generated by neoskills or build tools — not intrinsic skill content
_SKIP_NAMES = {"metadata.yaml", "provenance.yaml", ".gitkeep", ".DS_Store", "__pycache__"}
_SKIP_SUFFIXES = {".pyc", ".pyo"}


def _is_intrinsic(rel: Path) -> bool:
    """Return True if this relative path is intrinsic skill content."""
    if rel.name in _SKIP_NAMES or rel.suffix in _SKIP_SUFFIXES:
        return False
    return not any(part in ("__pycache__", ".git") for part in rel.parts)


def sha256_dir(dirpath: Path) -> str:
    """SHA256 of intrinsic skill files in a directory (sorted by relative path).

    Skips neoskills metadata, build artifacts, and VCS files so that
    bank and target copies with the same content produce the same hash.
    """
    h = hashlib.sha256()
    for f in sorted(dirpath.rglob("*")):
        if f.is_file():
            rel = f.relative_to(dirpath)
            if _is_intrinsic(rel):
                h.update(str(rel).encode("utf-8"))
                h.update(f.read_bytes())
    return h.hexdigest()


def parse_frontmatter(content: str) -> tuple[dict, str]:
    content = content.strip()
    if not content.startswith("---"):
        return {}, content
    end = content.find("---", 3)
    if end == -1:
        return {}, content
    import yaml
    try:
        meta = yaml.safe_load(content[3:end]) or {}
    except Exception:
        meta = {}
    return meta, content[end + 3:].strip()


def find_skills(base: Path) -> list[dict]:
    """Find all skills under a directory. Returns list of skill info dicts."""
    skills = []
    if not base.exists():
        return skills
    for item in sorted(base.iterdir()):
        if item.name.startswith("."):
            continue
        skill_file = None
        if item.is_dir():
            candidate = item / "SKILL.md"
            if candidate.exists():
                skill_file = candidate
        elif item.is_file() and item.suffix == ".md":
            skill_file = item
        if skill_file:
            content = skill_file.read_text(encoding="utf-8")
            meta, body = parse_frontmatter(content)
            # Checksum the full directory (scripts, references, assets)
            if item.is_dir():
                dir_cksum = sha256_dir(item)
                file_count = sum(1 for f in item.rglob("*")
                                 if f.is_file() and _is_intrinsic(f.relative_to(item)))
            else:
                dir_cksum = sha256(content)
                file_count = 1
            skills.append({
                "id": item.stem if item.is_file() else item.name,
                "name": meta.get("name", item.name),
                "description": meta.get("description", ""),
                "path": str(skill_file),
                "checksum": dir_cksum,
                "content_checksum": sha256(content),
                "content_length": len(content),
                "file_count": file_count,
                "is_dir": item.is_dir(),
            })
    return skills


def find_bank_skills(bank_root: Path) -> list[dict]:
    """Find skills in the neoskills bank (canonical copies)."""
    skills_dir = bank_root / "LTM" / "bank" / "skills"
    skills = []
    if not skills_dir.exists():
        return skills
    for skill_dir in sorted(skills_dir.iterdir()):
        if not skill_dir.is_dir():
            continue
        canonical_dir = skill_dir / "canonical"
        canonical = canonical_dir / "SKILL.md"
        if canonical.exists():
            content = canonical.read_text(encoding="utf-8")
            meta, body = parse_frontmatter(content)
            # Checksum the full canonical directory (includes scripts/, references/, assets/)
            dir_cksum = sha256_dir(canonical_dir)
            file_count = sum(1 for f in canonical_dir.rglob("*")
                             if f.is_file() and _is_intrinsic(f.relative_to(canonical_dir)))
            skills.append({
                "id": skill_dir.name,
                "name": meta.get("name", skill_dir.name),
                "description": meta.get("description", ""),
                "path": str(canonical),
                "checksum": dir_cksum,
                "content_checksum": sha256(content),
                "content_length": len(content),
                "file_count": file_count,
                "source": "bank",
            })
    return skills


def find_repo_skills(repo_slug: str, clone_dir: Path | None = None) -> list[dict]:
    """Clone a GitHub repo and find all skills recursively.

    Handles nested structures like mySkills (custom/<skill>/, downloaded/<skill>/, etc.)
    by walking up to 3 levels deep looking for SKILL.md files.
    """
    skills = []
    if clone_dir is None:
        clone_dir = Path(tempfile.mkdtemp(prefix="dedup_"))

    repo_dir = clone_dir / repo_slug.replace("/", "_")
    if not repo_dir.exists():
        url = f"https://github.com/{repo_slug}.git"
        result = subprocess.run(
            ["git", "clone", "--depth", "1", "--quiet", url, str(repo_dir)],
            capture_output=True, text=True,
        )
        if result.returncode != 0:
            print(f"Warning: failed to clone {repo_slug}: {result.stderr.strip()}", file=sys.stderr)
            return skills

    # Recursively find all SKILL.md files (up to 3 levels deep)
    for skill_md in sorted(repo_dir.rglob("SKILL.md")):
        # The skill directory is the parent of SKILL.md
        skill_dir = skill_md.parent
        # Compute a relative category path for context (e.g. "custom/academic-paper-coach")
        rel = skill_md.relative_to(repo_dir)
        # Skip hidden directories
        if any(part.startswith(".") for part in rel.parts):
            continue

        content = skill_md.read_text(encoding="utf-8")
        meta, body = parse_frontmatter(content)
        skill_id = skill_dir.name
        # Include category in the display path
        category = "/".join(rel.parts[:-2]) if len(rel.parts) > 2 else ""
        display = f"{category}/{skill_id}" if category else skill_id

        dir_cksum = sha256_dir(skill_dir)
        file_count = sum(1 for f in skill_dir.rglob("*")
                         if f.is_file() and _is_intrinsic(f.relative_to(skill_dir)))
        skills.append({
            "id": skill_id,
            "name": meta.get("name", skill_id),
            "description": meta.get("description", ""),
            "path": str(skill_md),
            "checksum": dir_cksum,
            "content_checksum": sha256(content),
            "content_length": len(content),
            "file_count": file_count,
            "source": repo_slug.split("/")[-1],
            "display": display,
        })
    return skills


def name_similarity(a: str, b: str) -> float:
    """Compute similarity between two skill names (0.0 to 1.0)."""
    # Normalize: lowercase, strip common prefixes/suffixes, remove hyphens
    def normalize(s):
        s = s.lower().strip()
        s = re.sub(r"[-_]", " ", s)
        return s
    return SequenceMatcher(None, normalize(a), normalize(b)).ratio()


def find_duplicates(all_skills: list[dict], similarity_threshold: float = 0.75):
    """Find duplicate groups by checksum and name similarity."""
    # Group 1: Exact checksum matches
    by_checksum = defaultdict(list)
    for s in all_skills:
        by_checksum[s["checksum"]].append(s)
    exact_dupes = {k: v for k, v in by_checksum.items() if len(v) > 1}

    # Group 2: Name-similar skills (not already in exact dupes)
    exact_ids = set()
    for group in exact_dupes.values():
        for s in group:
            exact_ids.add((s["id"], s.get("source", s.get("path", ""))))

    name_groups = []
    seen = set()
    remaining = [s for s in all_skills]
    for i, a in enumerate(remaining):
        if a["id"] in seen:
            continue
        group = [a]
        for j, b in enumerate(remaining):
            if i == j or b["id"] in seen:
                continue
            if a["checksum"] == b["checksum"]:
                continue  # already in exact dupes
            sim = name_similarity(a["id"], b["id"])
            if sim >= similarity_threshold:
                group.append(b)
                seen.add(b["id"])
        if len(group) > 1:
            seen.add(a["id"])
            name_groups.append(group)

    return exact_dupes, name_groups


def recommend_action(group: list[dict]) -> str:
    """Recommend an action for a duplicate group."""
    sources = set(s.get("source", "target") for s in group)
    checksums = set(s["checksum"] for s in group)
    if len(checksums) == 1:
        return "EXACT_DUPLICATE: keep one, remove others"
    # Different content - need manual review
    lengths = [s["content_length"] for s in group]
    if max(lengths) > min(lengths) * 1.5:
        return "DIVERGED: versions differ significantly, review and merge"
    return "SIMILAR: minor differences, review and choose canonical version"


def main():
    parser = argparse.ArgumentParser(description="Scan for duplicate skills")
    parser.add_argument("--bank", default=str(Path.home() / ".neoskills"),
                        help="Path to neoskills workspace")
    parser.add_argument("--targets", nargs="*", default=["claude", "opencode"],
                        help="Targets to scan")
    parser.add_argument("--repos", nargs="*", default=[],
                        help="GitHub repos to scan (e.g. neolaf2/mySkills)")
    parser.add_argument("--threshold", type=float, default=0.75,
                        help="Name similarity threshold (0.0-1.0)")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    args = parser.parse_args()

    bank_root = Path(args.bank)

    # Collect all skills
    all_skills = []
    sources_label = []

    # Bank skills
    bank_skills = find_bank_skills(bank_root)
    for s in bank_skills:
        s["source"] = "bank"
    all_skills.extend(bank_skills)

    # Target skills
    target_paths = {
        "claude": Path.home() / ".claude" / "skills",
        "opencode": Path.home() / ".config" / "opencode" / "skills",
    }
    for target_name in args.targets:
        path = target_paths.get(target_name)
        if path:
            target_skills = find_skills(path)
            for s in target_skills:
                s["source"] = target_name
            all_skills.extend(target_skills)
    sources_label.extend(args.targets)

    # GitHub repo skills
    clone_dir = Path(tempfile.mkdtemp(prefix="dedup_")) if args.repos else None
    for repo_slug in args.repos:
        repo_skills = find_repo_skills(repo_slug, clone_dir)
        all_skills.extend(repo_skills)
        sources_label.append(repo_slug)

    # Find duplicates
    exact_dupes, name_groups = find_duplicates(all_skills, args.threshold)

    if args.json:
        report = {
            "total_skills": len(all_skills),
            "exact_duplicates": [
                {"checksum": k[:16], "count": len(v), "action": recommend_action(v),
                 "skills": [{"id": s["id"], "source": s.get("source", "unknown")} for s in v]}
                for k, v in exact_dupes.items()
            ],
            "name_similar": [
                {"action": recommend_action(g),
                 "skills": [{"id": s["id"], "source": s.get("source", "unknown"),
                             "checksum": s["checksum"][:16]} for s in g]}
                for g in name_groups
            ],
        }
        print(json.dumps(report, indent=2))
    else:
        print(f"Scanned {len(all_skills)} skills across bank + {', '.join(sources_label)}\n")

        if exact_dupes:
            print(f"=== EXACT DUPLICATES ({len(exact_dupes)} groups) ===\n")
            for checksum, group in exact_dupes.items():
                action = recommend_action(group)
                print(f"  Checksum: {checksum[:16]}...")
                for s in group:
                    fc = s.get('file_count', '?')
                    print(f"    - {s['id']} [{s.get('source', '?')}] ({fc} files)")
                print(f"    Action: {action}\n")
        else:
            print("No exact duplicates found.\n")

        if name_groups:
            print(f"=== NAME-SIMILAR GROUPS ({len(name_groups)} groups) ===\n")
            for group in name_groups:
                action = recommend_action(group)
                for s in group:
                    fc = s.get('file_count', '?')
                    print(f"    - {s['id']} [{s.get('source', '?')}] ({fc} files, {s['checksum'][:16]}...)")
                print(f"    Action: {action}\n")
        else:
            print("No name-similar groups found.\n")


if __name__ == "__main__":
    main()
