# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""sync_drift_check.py — SessionStart-Hook.

Prüft beim Session-Start drei Fälle und reagiert:

1. **Erstinstall.** Kein `.docs/sync-state.json` im Vault → Setup-Pipeline ist
   noch nicht durchgelaufen. Hook gibt einen Hinweis auf stderr aus und
   lässt durch (`{"action": "allow"}`). Ohne diesen Guard würde der Hook
   bei jedem Frischinstall blockieren.

2. **Spiegel veraltet.** Mindestens eine Quelle in `.skills/<name>/` ist
   neuer als der zugehörige Spiegel in `.claude/skills/<name>/`. Hook
   ruft `sync.py` auf (Auto-Fix) und lässt durch.

3. **Spiegel manipuliert.** Mindestens ein Spiegel-File ist neuer als die
   Quelle — der User hat im `.claude/skills/`-Spiegel editiert. Hook
   blockiert mit `{"action": "block", "message": "..."}` und einem
   Repair-Hinweis (`uv run .skills/sync.py --force`).

Erstinstall-fest: fehlendes Manifest, fehlende `.skills/`-Quellen oder
nicht gesetztes `cwd` führen zu silent allow, kein Crash.
"""
import json
import subprocess
import sys
from pathlib import Path


def _is_cache_artifact(path: Path) -> bool:
    """Python-/pytest-Laufzeit-Caches zählen nicht als Quell- oder Spiegel-
    Inhalt. Ohne diesen Filter kippt jeder lokale Skript-Lauf (erzeugt
    __pycache__/*.pyc mit frischem mtime) die Klassifikation auf source-newer
    und JEDER Session-Start löst einen sync-Schreiblauf aus (Install-Härtung 2,
    Posten 6 — im Produktiv-Vault am 2026-06-11 real passiert)."""
    return (
        "__pycache__" in path.parts
        or ".pytest_cache" in path.parts
        or path.suffix == ".pyc"
    )


def _max_mtime(root: Path) -> float:
    """Größtes mtime aller Dateien unter root (rekursiv), ohne Cache-Artefakte.
    0.0 wenn leer."""
    return max(
        (
            p.stat().st_mtime
            for p in root.rglob("*")
            if p.is_file() and not _is_cache_artifact(p)
        ),
        default=0.0,
    )


def _classify(vault: Path) -> dict:
    """Klassifiziert den Drift-Stand. Liefert ein Result-Dict mit Schlüsseln:
    - state: "first-install" | "in-sync" | "source-newer" | "mirror-newer"
    - drift: list[str]   (Skills mit Source > Mirror)
    - manipulated: list[str] (Skills mit Mirror > Source)
    - reason: str (kurzer Text für stderr/message)
    """
    state_marker = vault / ".docs" / "sync-state.json"
    if not state_marker.exists():
        return {
            "state": "first-install",
            "drift": [],
            "manipulated": [],
            "reason": (
                "sync_drift_check: .docs/sync-state.json fehlt — Erstinstall,"
                " Drift-Check übersprungen."
            ),
        }

    manifest_path = vault / ".skills" / "manifest.json"
    if not manifest_path.exists():
        return {
            "state": "in-sync",
            "drift": [],
            "manipulated": [],
            "reason": "sync_drift_check: kein Manifest, Check übersprungen.",
        }
    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return {
            "state": "in-sync",
            "drift": [],
            "manipulated": [],
            "reason": "sync_drift_check: Manifest unlesbar, Check übersprungen.",
        }

    drift: list[str] = []
    manipulated: list[str] = []
    for name, info in (manifest.get("skills") or {}).items():
        if info.get("scope") != "vault":
            continue
        src = vault / ".skills" / name
        dst = vault / ".claude" / "skills" / name
        if not src.exists() or not dst.exists():
            continue
        src_mtime = _max_mtime(src)
        dst_mtime = _max_mtime(dst)
        # Toleranz 2.1 Sekunden, da mtime-Granularität FS-abhängig ist:
        # NTFS 100 ns, ext4/APFS 1 ns, FAT32/exFAT 2 s. Knappere Toleranz
        # würde auf FAT32 false-positive source-newer-Erkennungen erzeugen.
        if dst_mtime > src_mtime + 2.1:
            manipulated.append(name)
        elif src_mtime > dst_mtime + 2.1:
            drift.append(name)

    if manipulated:
        return {
            "state": "mirror-newer",
            "drift": drift,
            "manipulated": manipulated,
            "reason": (
                "sync_drift_check: Spiegel manipuliert für "
                f"{manipulated} — der Inhalt unter .claude/skills/ ist neuer als"
                " die Quelle. Reparatur: tippe `/sync-repair` (oder direkt"
                " `uv run .skills/sync.py --force`)."
            ),
        }
    if drift:
        return {
            "state": "source-newer",
            "drift": drift,
            "manipulated": [],
            "reason": (
                f"sync_drift_check: Quelle neuer als Spiegel für {drift}"
                " — rufe sync.py auf."
            ),
        }
    return {
        "state": "in-sync",
        "drift": [],
        "manipulated": [],
        "reason": "sync_drift_check: Spiegel synchron.",
    }


SYNC_TIMEOUT_SECONDS = 60


def _read_active_profile(vault: Path) -> str | None:
    """active_profile aus .docs/sync-state.json. None bei fehlender Datei,
    kaputtem JSON oder fehlendem/leerem Key — dann läuft sync.py wie bisher
    ohne --profile (Fallback aufs alphabetisch erste Profil). Im
    source-newer-Pfad existiert die Datei laut _classify immer; der Fallback
    ist Robustheit gegen Korruption, kein erwarteter Pfad."""
    state_path = vault / ".docs" / "sync-state.json"
    try:
        state = json.loads(state_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    profile = state.get("active_profile")
    if isinstance(profile, str) and profile.strip():
        # strip auch im Rückgabewert: ein Whitespace-behafteter Wert (manueller
        # Edit, Encoding-Unfall) würde sonst als unbekanntes Profil laut blocken.
        return profile.strip()
    return None


def _run_sync(vault: Path) -> int:
    """Ruft sync.py im Auto-Fix-Modus auf. Liefert Exit-Code.

    Das aktive Profil wird aus .docs/sync-state.json durchgereicht — ohne
    --profile fiele sync.py aufs alphabetisch erste Profil zurück und würde
    bei Nicht-allgemein-Vaults das active_profile still umkippen
    (Install-Härtung 3, Posten 1; Befund-Klasse wie Dossier-Befund 3).
    Unbekanntes Profil lässt sync.py mit Exit 1 scheitern → decide() blockt
    laut. Hartes Timeout (60s), damit ein hängender sync.py-Prozess (z.B.
    wegen OneDrive-Sync-Konflikt oder File-Lock) den SessionStart nicht
    unbegrenzt blockiert. Fehlendes uv im PATH wird als Exit 127 gemeldet,
    nicht als Crash — der Hook bleibt erstinstall-fest auch ohne uv.
    """
    sync_script = vault / ".skills" / "sync.py"
    if not sync_script.exists():
        return 0
    cmd = ["uv", "run", str(sync_script), "--vault", str(vault)]
    profile = _read_active_profile(vault)
    if profile:
        cmd += ["--profile", profile]
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=SYNC_TIMEOUT_SECONDS,
        )
    except FileNotFoundError:
        sys.stderr.write(
            "sync_drift_check: uv nicht im PATH — sync.py nicht ausgeführt.\n"
        )
        return 127
    except subprocess.TimeoutExpired:
        sys.stderr.write(
            f"sync_drift_check: sync.py hat nach {SYNC_TIMEOUT_SECONDS}s nicht"
            " geantwortet, Auto-Fix abgebrochen.\n"
        )
        return 124
    if result.returncode != 0:
        sys.stderr.write(
            f"sync_drift_check: sync.py Exit {result.returncode}\n"
            f"  stderr: {result.stderr.strip()}\n"
        )
    return result.returncode


def decide(vault: Path) -> dict:
    """Hook-Entscheidung basierend auf Drift-Klassifikation. Schreibt
    Status-Hinweise auf stderr und ruft im source-newer-Fall sync.py auf."""
    result = _classify(vault)
    state = result["state"]

    if state == "first-install":
        sys.stderr.write(result["reason"] + "\n")
        return {"action": "allow"}

    if state == "in-sync":
        return {"action": "allow"}

    if state == "source-newer":
        sys.stderr.write(result["reason"] + "\n")
        rc = _run_sync(vault)
        if rc != 0:
            return {
                "action": "block",
                "message": (
                    "sync_drift_check: Auto-Fix via sync.py fehlgeschlagen"
                    f" (Exit {rc}). Bitte `uv run .skills/sync.py --force`"
                    " manuell ausführen."
                ),
            }
        return {"action": "allow"}

    if state == "mirror-newer":
        return {"action": "block", "message": result["reason"]}

    return {"action": "allow"}


def main():
    """F11: CC-Hook-Contract = Exit 2 + stderr für block, Exit 0 für allow.
    `decide()` bleibt pure (Side-Effects auf stderr und subprocess sind dort
    bewusst eingekapselt) — `main()` interpretiert nur den Decision-Dict.
    Block-Reason wandert auf stderr. Decide()'s eigene stderr-Hinweise
    (Erstinstall, source-newer) bleiben unverändert auf stderr.
    F14: UTF-8 für stdout/stderr erzwungen (Windows-cp1252-Default).
    """
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")
    try:
        payload = json.loads(sys.stdin.read())
    except json.JSONDecodeError:
        sys.exit(0)
    cwd = payload.get("cwd")
    if not cwd:
        sys.exit(0)
    decision = decide(Path(cwd))
    if decision.get("action") == "block":
        print(decision.get("message", ""), file=sys.stderr)
        sys.exit(2)
    sys.exit(0)


if __name__ == "__main__":
    main()
