#!/usr/bin/env python3
"""Sync the local skill registry into Codex and Hermes runtime directories."""

from __future__ import annotations

import argparse
import os
import platform
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
PUBLIC_REGISTRY = ROOT / "skill/assets/registries/public-skills.tsv"
PRIVATE_REGISTRY = (
    Path.home()
    / "dotfiles-private/agent-skill-manager/assets/registries/private-skills.tsv"
)
LIBRARY_ROOT = Path.home() / ".local/share/agent-skill-manager/skills"
CODEX_ROOT = Path.home() / ".codex/skills"
HERMES_ROOT = Path.home() / ".hermes/skills"
PI_ROOT = Path.home() / ".pi/agent/skills"

DBSKILL_HERMES_CATEGORIES = {
    "dbs": "business-diagnostics",
    "dbs-diagnosis": "business-diagnostics",
    "dbs-benchmark": "business-diagnostics",
    "dbs-action": "business-diagnostics",
    "dbs-slowisfast": "business-diagnostics",
    "dbs-goal": "business-diagnostics",
    "dbs-content": "content-creation",
    "dbs-hook": "content-creation",
    "dbs-xhs-title": "content-creation",
    "dbs-ai-check": "content-creation",
    "dbs-deconstruct": "thinking-tools",
    "dbs-chatroom-austrian": "thinking-tools",
    "dbs-chatroom": "thinking-tools",
    "dbs-good-question": "thinking-tools",
    "dbs-learning": "thinking-tools",
    "dbs-agent-migration": "workflow-infrastructure",
    "dbs-bridge": "workflow-infrastructure",
    "dbs-skill-cleaner": "workflow-infrastructure",
    "dbs-update": "workflow-infrastructure",
    "dbs-save": "state-management",
    "dbs-restore": "state-management",
    "dbs-report": "state-management",
    "dbs-decision": "state-management",
    "dbs-knowledge": "state-management",
    "dbs-content-system": "content-creation",
    "dbs-resonate": "content-creation",
    "dbs-script-flow": "content-creation",
    "dbs-spread": "content-creation",
    "dbs-standard-answer": "content-creation",
    "dbs-wechat-html": "content-creation",
}

CATEGORY_DESCRIPTIONS = {
    "business-diagnostics": "Business diagnosis and strategy skills.",
    "content-creation": "Content creation and publishing skills.",
    "creative": "Creative generation and design skills.",
    "local": "mainliufeng local/private workflow skills.",
    "meta": "Meta-skills for managing the agent environment itself.",
    "research": "Research and source-gathering skills.",
    "social-media": "Social media publishing and operations skills.",
    "state-management": "State save, restore, and reporting skills.",
    "thinking-tools": "Thinking, deconstruction, and dialogue tools.",
    "workflow-infrastructure": "Workflow infrastructure and migration skills.",
}


@dataclass(frozen=True)
class Entry:
    id: str
    adapter: str
    source_path: Path
    codex: str
    hermes: str
    pi: str
    hermes_category: str
    platforms: str
    clone_url: str
    notes: str
    registry: Path


class Runner:
    def __init__(self, dry_run: bool) -> None:
        self.dry_run = dry_run
        self.changed = 0
        self.warnings: list[str] = []

    def log(self, message: str) -> None:
        print(message)

    def warn(self, message: str) -> None:
        self.warnings.append(message)
        print(f"[warn] {message}")

    def run(self, *cmd: str) -> None:
        if self.dry_run:
            self.log("[dry-run] " + " ".join(shell_quote(c) for c in cmd))
            return
        subprocess.run(cmd, check=True)

    def mkdir(self, path: Path) -> None:
        if path.exists():
            return
        self.changed += 1
        if self.dry_run:
            self.log(f"[dry-run] mkdir -p {path}")
        else:
            path.mkdir(parents=True, exist_ok=True)

    def unlink_or_rmtree(self, path: Path) -> None:
        if not path.exists() and not path.is_symlink():
            return
        self.changed += 1
        if self.dry_run:
            self.log(f"[dry-run] remove {path}")
            return
        if path.is_symlink() or path.is_file():
            path.unlink()
        else:
            shutil.rmtree(path)

    def symlink(self, src: Path, dest: Path) -> None:
        if dest.is_symlink() and Path(os.readlink(dest)) == src:
            return
        if dest.exists() or dest.is_symlink():
            self.unlink_or_rmtree(dest)
        self.mkdir(dest.parent)
        self.changed += 1
        if self.dry_run:
            self.log(f"[dry-run] ln -sfn {src} {dest}")
        else:
            dest.symlink_to(src)
        self.log(f"[link] {dest} -> {src}")


def shell_quote(value: str) -> str:
    if not value:
        return "''"
    if all(ch.isalnum() or ch in "/._-:=+" for ch in value):
        return value
    return "'" + value.replace("'", "'\"'\"'") + "'"


def expand(path: str) -> Path:
    return Path(os.path.expandvars(os.path.expanduser(path))).resolve()


def current_platform() -> str:
    if platform.system() == "Darwin":
        return "macos"
    os_release = Path("/etc/os-release")
    if os_release.exists() and "arch" in os_release.read_text(errors="ignore").lower():
        return "archlinux"
    return "linux"


def platform_allows(entry: Entry, actual: str) -> bool:
    return entry.platforms == "all" or entry.platforms == actual


def read_registry(path: Path) -> list[Entry]:
    if not path.exists():
        return []
    lines = path.read_text().splitlines()
    rows: list[Entry] = []
    header_seen = False
    for line in lines:
        if not line.strip() or line.startswith("#"):
            continue
        if not header_seen:
            header_seen = True
            continue
        cols = line.split("\t")
        if len(cols) != 10:
            raise SystemExit(f"Invalid registry row in {path}: {line}")
        rows.append(
            Entry(
                id=cols[0],
                adapter=cols[1],
                source_path=expand(cols[2]),
                codex=cols[3],
                hermes=cols[4],
                pi=cols[5],
                hermes_category=cols[6],
                platforms=cols[7],
                clone_url=cols[8],
                notes=cols[9],
                registry=path,
            )
        )
    return rows


def all_entries() -> list[Entry]:
    entries = read_registry(PUBLIC_REGISTRY) + read_registry(PRIVATE_REGISTRY)
    seen: set[str] = set()
    duplicates = [entry.id for entry in entries if entry.id in seen or seen.add(entry.id)]
    if duplicates:
        raise SystemExit(f"Duplicate registry entries: {', '.join(sorted(set(duplicates)))}")
    return entries


def clone_if_missing(entry: Entry, runner: Runner) -> None:
    if entry.source_path.exists() or not entry.clone_url:
        return
    runner.mkdir(entry.source_path.parent)
    runner.run("git", "clone", entry.clone_url, str(entry.source_path))


def skill_children(entry: Entry) -> list[tuple[str, Path]]:
    if entry.adapter != "bundle_children":
        return [(entry.id, entry.source_path)]
    skills_dir = entry.source_path / "skills"
    if not skills_dir.exists():
        return []
    return sorted(
        (path.name, path)
        for path in skills_dir.iterdir()
        if path.is_dir() and (path / "SKILL.md").exists()
    )


def ensure_library(entry: Entry, runner: Runner) -> list[tuple[str, Path]]:
    clone_if_missing(entry, runner)
    if not entry.source_path.exists():
        if runner.dry_run and entry.clone_url:
            runner.mkdir(LIBRARY_ROOT)
            runner.symlink(entry.source_path, LIBRARY_ROOT / entry.id)
            return []
        runner.warn(f"{entry.id}: source path missing: {entry.source_path}")
        return []

    runner.mkdir(LIBRARY_ROOT)
    runner.symlink(entry.source_path, LIBRARY_ROOT / entry.id)

    children = skill_children(entry)
    if entry.adapter == "bundle_children":
        for name, source in children:
            runner.symlink(source, LIBRARY_ROOT / name)
    return children


def install_codex_skill(name: str, check_source: Path, link_source: Path, runner: Runner) -> None:
    if not (check_source / "SKILL.md").exists():
        runner.warn(f"{name}: missing SKILL.md at {check_source}")
        return
    runner.symlink(link_source, CODEX_ROOT / name)


def ensure_hermes_category(category: str, runner: Runner) -> None:
    category_dir = HERMES_ROOT / category
    runner.mkdir(category_dir)
    desc = category_dir / "DESCRIPTION.md"
    if not desc.exists():
        text = f"# {category}\n\n{CATEGORY_DESCRIPTIONS.get(category, 'Managed skills.')}\n"
        runner.changed += 1
        if runner.dry_run:
            runner.log(f"[dry-run] write {desc}")
        else:
            desc.write_text(text)


def install_hermes_skill(
    name: str, check_source: Path, link_source: Path, category: str, runner: Runner
) -> None:
    if not (check_source / "SKILL.md").exists():
        runner.warn(f"{name}: missing SKILL.md at {check_source}")
        return
    ensure_hermes_category(category, runner)
    dest = HERMES_ROOT / category / name
    if dest.is_symlink() or dest.is_file():
        runner.unlink_or_rmtree(dest)
    runner.mkdir(dest)

    existing = {child.name for child in dest.iterdir()} if dest.exists() else set()
    desired = {child.name for child in check_source.iterdir() if child.name != ".git"}
    for extra in sorted(existing - desired):
        runner.unlink_or_rmtree(dest / extra)
    for child in sorted(check_source.iterdir(), key=lambda p: p.name):
        if child.name == ".git":
            continue
        runner.symlink(link_source / child.name, dest / child.name)


def remove_codex_if_manual(entry: Entry, runner: Runner) -> None:
    # Manual packs are cold-library resources. Only remove a top-level runtime
    # entry with the same id; child cleanup stays explicit to avoid surprises.
    if entry.codex == "manual":
        runner.unlink_or_rmtree(CODEX_ROOT / entry.id)


def install_pi_skill(name: str, check_source: Path, link_source: Path, runner: Runner) -> None:
    if not (check_source / "SKILL.md").exists():
        runner.warn(f"{name}: missing SKILL.md at {check_source}")
        return
    runner.symlink(link_source, PI_ROOT / name)


def remove_pi_if_manual(entry: Entry, runner: Runner) -> None:
    if entry.pi == "manual":
        runner.unlink_or_rmtree(PI_ROOT / entry.id)


def sync(entries: list[Entry], targets: set[str], only: str | None, runner: Runner) -> None:
    actual_platform = current_platform()
    for entry in entries:
        if only and entry.id != only:
            continue
        if not platform_allows(entry, actual_platform):
            continue
        children = ensure_library(entry, runner)

        if "codex" in targets:
            if entry.codex == "active":
                for name, source in children:
                    install_codex_skill(name, source, LIBRARY_ROOT / name, runner)
            elif entry.codex == "manual":
                remove_codex_if_manual(entry, runner)

        if "hermes" in targets and entry.hermes == "active":
            for name, source in children:
                category = entry.hermes_category
                if entry.adapter == "bundle_children":
                    category = DBSKILL_HERMES_CATEGORIES.get(name, "local")
                install_hermes_skill(name, source, LIBRARY_ROOT / name, category, runner)

        if "pi" in targets:
            if entry.pi == "active":
                for name, source in children:
                    install_pi_skill(name, source, LIBRARY_ROOT / name, runner)
            elif entry.pi == "manual":
                remove_pi_if_manual(entry, runner)


def audit(entries: list[Entry], targets: set[str], only: str | None) -> int:
    actual_platform = current_platform()
    problems = 0
    expected_codex: set[str] = set()
    manual_codex: set[str] = set()
    expected_hermes: set[tuple[str, str]] = set()
    expected_pi: set[str] = set()
    manual_pi: set[str] = set()

    for entry in entries:
        if only and entry.id != only:
            continue
        if not platform_allows(entry, actual_platform):
            continue
        if not (LIBRARY_ROOT / entry.id).exists():
            print(f"[missing-library] {entry.id}: {LIBRARY_ROOT / entry.id}")
            problems += 1
        children = skill_children(entry)
        if entry.adapter == "bundle_children":
            for name, _source in children:
                if not (LIBRARY_ROOT / name / "SKILL.md").exists():
                    print(f"[missing-library] {name}: {LIBRARY_ROOT / name}")
                    problems += 1
        if "codex" in targets:
            if entry.codex == "active":
                expected_codex.update(name for name, _source in children)
            elif entry.codex == "manual":
                manual_codex.add(entry.id)
        if "hermes" in targets and entry.hermes == "active":
            for name, _source in children:
                category = entry.hermes_category
                if entry.adapter == "bundle_children":
                    category = DBSKILL_HERMES_CATEGORIES.get(name, "local")
                expected_hermes.add((category, name))

        if "pi" in targets:
            if entry.pi == "active":
                expected_pi.update(name for name, _source in children)
            elif entry.pi == "manual":
                manual_pi.add(entry.id)

    if "codex" in targets:
        for name in sorted(expected_codex):
            path = CODEX_ROOT / name / "SKILL.md"
            if not path.exists():
                print(f"[missing-codex] {name}: {path}")
                problems += 1
        for name in sorted(manual_codex):
            path = CODEX_ROOT / name
            if path.exists() or path.is_symlink():
                print(f"[manual-active] {name}: {path}")
                problems += 1

    if "hermes" in targets:
        for category, name in sorted(expected_hermes):
            path = HERMES_ROOT / category / name
            if path.is_symlink():
                print(f"[hermes-whole-dir-symlink] {category}/{name}: {path}")
                problems += 1
            if not (path / "SKILL.md").exists():
                print(f"[missing-hermes] {category}/{name}: {path / 'SKILL.md'}")
                problems += 1

    if "pi" in targets:
        for name in sorted(expected_pi):
            path = PI_ROOT / name / "SKILL.md"
            if not path.exists():
                print(f"[missing-pi] {name}: {path}")
                problems += 1
        for name in sorted(manual_pi):
            path = PI_ROOT / name
            if path.exists() or path.is_symlink():
                print(f"[manual-active] {name}: {path}")
                problems += 1

    if problems == 0:
        print("audit ok")
    return problems


def parse_targets(value: str) -> set[str]:
    if value == "all":
        return {"codex", "hermes", "pi"}
    return {value}


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    sub = parser.add_subparsers(dest="command", required=True)

    for name in ("sync", "audit"):
        cmd = sub.add_parser(name)
        cmd.add_argument("--target", choices=("codex", "hermes", "pi", "all"), default="all")
        cmd.add_argument("--only", help="Only process one registry id")
        if name == "sync":
            cmd.add_argument("--dry-run", action="store_true")

    args = parser.parse_args()
    entries = all_entries()
    targets = parse_targets(args.target)
    if args.command == "sync":
        runner = Runner(dry_run=args.dry_run)
        sync(entries, targets, args.only, runner)
        if runner.warnings:
            return 2
        return 0
    if args.command == "audit":
        return audit(entries, targets, args.only)
    raise AssertionError(args.command)


if __name__ == "__main__":
    sys.exit(main())
