# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""sync.py — Spiegelung und Aggregation von .skills/ nach .claude/.

Struktur (Phase-3-Refactor, Task 3.0b):
- mirror_skills:    Skill-Spiegelung mit Drift-Check (heute aktiv)
- aggregate_hooks:  hooks_index → .claude/settings.json + .codex/config.toml (Task 3.2)
- aggregate_mcp:    .skills/<n>/mcp.json → .claude/.mcp.json + .codex/.mcp.json (Task 3.2b)
- mirror_commands:  .commands/ → .claude/commands/ (Stub bis Task 5.5)
- sync:             Orchestrator, schreibt Marker + Log
- main:             argparse, ruft sync()
"""
import argparse
import json
import os
import shutil
import stat
import sys
import time
from datetime import datetime
from pathlib import Path

import yaml


def _force_writable_and_retry(func, path, _exc):
    """rmtree-Fehler-Handler: entfernt das Read-only-Attribut und versucht erneut.

    Windows/OneDrive markiert gespiegelte Dateien häufig read-only. shutil.rmtree
    scheitert dann mit WinError 5 (Zugriff verweigert), während PowerShell
    `Remove-Item -Force` durchläuft, weil -Force das Attribut vorher entfernt.
    Dieser Handler bildet -Force nach: chmod auf schreibbar, dann func erneut.
    Bleibt der Lösch-Versuch danach hängen, propagiert der Fehler bewusst —
    der Aufrufer entscheidet via ignore_errors, ob er ihn schluckt.
    """
    os.chmod(path, stat.S_IWRITE)
    func(path)


def _robust_rmtree(path: Path, ignore_errors: bool = False) -> None:
    """shutil.rmtree mit Read-only-Heilung. Versions-fest für Python 3.11+.

    Ab 3.12 heißt der Callback-Parameter `onexc`, davor `onerror` — beide mit
    derselben (func, path, ...)-Signatur, die unser Handler abdeckt. Der Heil-
    Handler greift in BEIDEN Modi (anders als shutil.rmtree(ignore_errors=True),
    das jeden Handler überspringt und read-only-Reste liegen ließe). Bei
    ignore_errors=True wird ein nach der Heilung verbleibender Fehler geschluckt.
    """
    if not os.path.exists(path):
        return

    def _run() -> None:
        if sys.version_info >= (3, 12):
            shutil.rmtree(path, onexc=_force_writable_and_retry)
        else:
            shutil.rmtree(path, onerror=_force_writable_and_retry)

    if ignore_errors:
        try:
            _run()
        except OSError:
            pass
    else:
        _run()


def _robust_rename(src: Path, dst: Path, attempts: int = 5) -> None:
    """os.rename mit Retry für transiente Windows/OneDrive-Sperren.

    OneDrive hält frisch erzeugte Verzeichnisse kurz offen, wodurch der atomare
    .new->live-Swap mit PermissionError (WinError 5/32) fehlschlagen kann, obwohl
    nichts dauerhaft falsch ist. Kurzes Backoff gibt OneDrive Zeit, das Handle
    freizugeben. Der letzte Versuch propagiert den Fehler bewusst.
    """
    for attempt in range(attempts):
        try:
            src.rename(dst)
            return
        except OSError:
            if attempt == attempts - 1:
                raise
            time.sleep(0.3 * (attempt + 1))


def _is_cache_artifact(path: Path) -> bool:
    """Python-/pytest-Laufzeit-Caches zählen nicht als Quell- oder Spiegel-
    Inhalt — sie entstehen bei jedem lokalen Skript-Lauf mit frischem mtime
    und würden die Drift-Klassifikation kippen (Install-Härtung 2, Posten 6;
    identisches Prädikat in sync_drift_check)."""
    return (
        "__pycache__" in path.parts
        or ".pytest_cache" in path.parts
        or path.suffix == ".pyc"
    )


def _skill_has_drift(src: Path, dst: Path) -> bool:
    """True, wenn der Spiegel dst neuer ist als die Quelle src (manipuliert).

    2.1s Toleranz analog zu mirror_commands und sync_drift_check
    (FAT32/exFAT haben 2s mtime-Granularität). Cache-Artefakte
    (__pycache__/.pyc/.pytest_cache) bleiben außen vor.
    """
    if not dst.exists():
        return False
    src_mtime = max(
        (
            p.stat().st_mtime
            for p in src.rglob("*")
            if p.is_file() and not _is_cache_artifact(p)
        ),
        default=0,
    )
    dst_mtime = max(
        (
            p.stat().st_mtime
            for p in dst.rglob("*")
            if p.is_file() and not _is_cache_artifact(p)
        ),
        default=0,
    )
    return dst_mtime > src_mtime + 2.1


def _atomic_mirror_copy(src: Path, dst: Path) -> None:
    """Atomares Copy via .new-Stage + Rename. Schützt vor SIGKILL- oder
    OneDrive-Sync-Korruption zwischen rmtree und copytree: ohne dieses
    Pattern bliebe ein leeres Skill-Verzeichnis zurück, das trigger_enforcer
    und sync_drift_check still falsch interpretieren würden (Audit K-H2).
    """
    staging = dst.parent / (dst.name + ".new")
    if staging.exists():
        _robust_rmtree(staging)
    # Caches nicht in die Spiegel kopieren — sonst entsteht das mtime-Drift-
    # Problem spiegelseitig erneut (Posten 6, Critic-Korrektur 6.2).
    shutil.copytree(
        src, staging, ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache")
    )
    if dst.exists():
        old_stage = dst.parent / (dst.name + ".old")
        if old_stage.exists():
            _robust_rmtree(old_stage)
        _robust_rename(dst, old_stage)
        try:
            _robust_rename(staging, dst)
        except OSError:
            # Rollback: alten Stand zurück, staging entsorgen
            _robust_rename(old_stage, dst)
            _robust_rmtree(staging, ignore_errors=True)
            raise
        _robust_rmtree(old_stage, ignore_errors=True)
    else:
        _robust_rename(staging, dst)


def mirror_skills(vault: Path, manifest: dict, force: bool = False) -> tuple[list, list]:
    """Spiegelt vault-scope-Skills von .skills/ nach .claude/skills/ UND .agents/skills/.

    .claude/skills/ = Claude-Code-Discovery, .agents/skills/ = Codex-Discovery
    (Codex liest Skills aus .agents/skills/ am Vault-Root, siehe
    .maintainer/docs/CODEX-SKILL-PARITY-SPEC.md). Beide Bäume sind inhaltsgleich
    aus derselben SSoT .skills/.

    - Drift-Check via mtime pro Baum: Quelle neuer → kopieren; Spiegel neuer → BLOCK.
      Ein Skill gilt als blocked, sobald MINDESTENS ein Ziel-Baum Drift zeigt;
      dann wird in KEINEN Baum kopiert (konservativ).
    - force=True: ignoriert Drift, überschreibt beide Spiegel.
    Liefert (synced, blocked) als Listen von Skill-Namen.
    """
    mirror_roots = [vault / ".claude" / "skills", vault / ".agents" / "skills"]
    for root in mirror_roots:
        root.mkdir(parents=True, exist_ok=True)
    synced: list = []
    blocked: list = []

    for name, info in manifest.get("skills", {}).items():
        if info.get("scope") != "vault":
            continue
        src = vault / ".skills" / name
        if not src.exists():
            continue
        if not force and any(
            _skill_has_drift(src, root / name) for root in mirror_roots
        ):
            blocked.append(name)
            continue
        for root in mirror_roots:
            _atomic_mirror_copy(src, root / name)
        synced.append(name)

    for root in mirror_roots:
        (root / "AUTO-GENERATED-DO-NOT-EDIT.md").write_text(
            f"This directory is auto-generated by sync.py at {datetime.utcnow().isoformat()}Z. Do not edit directly.",
            encoding="utf-8",
        )
    return synced, blocked


def _hook_command(vault: Path, hook_name: str, script_rel: str, tool: str = "claude") -> str:
    """Resolved Command-Aufruf für settings.json (claude) bzw. config.toml (codex).

    Hooks haben PEP 723 Header und brauchen `uv run`, weil Deps wie
    python-frontmatter/pyyaml über die Headers gepullt werden.

    claude: Pfad mit `"$CLAUDE_PROJECT_DIR/"`-Präfix (Quotes Pflicht wegen
    Leerzeichen-Pfaden, z.B. OneDrive). Claude Code spawnt Hook-Prozesse
    NICHT zuverlässig mit cwd=Vault-Root — mit relativem Pfad findet die
    Shell das Script dann nicht und der Schutz-Hook feuert still nie
    (Install-Härtung 2 Posten 5, im Produktiv-Vault reproduziert;
    $CLAUDE_PROJECT_DIR ist die von Claude Code beim Hook-Spawn gesetzte
    absolute Projekt-Variable, Muster dort live bewiesen).

    codex: absoluter Vault-Pfad in Quotes — Codex setzt $CLAUDE_PROJECT_DIR
    nicht, und das relative Format hing an derselben unbelegten
    cwd-Annahme wie die Claude-Seite vor Install-Härtung 2. Live bewiesen
    ist für Codex `uv run <abs-pfad>` (AP2-Spec §5.1/§5.4); der absolute
    Pfad ist sync-zeitig bekannt. posix-Form vermeidet TOML-Backslash-
    Escaping, Quotes wegen Leerzeichen-Pfaden (Install-Härtung 3 Posten 2,
    interaktiver Codex-Live-Test siehe INSTALL-HAERTUNG-3-BRIEFS.md).
    Konsequenz: config.toml ist maschinenspezifisch; jede Pfad-Änderung
    löst einmalig den Codex-Hook-Trust-Reprompt aus (`/hooks`).
    """
    script_path = (Path(".skills") / hook_name / script_rel.lstrip("./")).as_posix()
    if tool == "claude":
        return f'uv run "$CLAUDE_PROJECT_DIR/{script_path}"'
    abs_script = (vault.resolve() / script_path).as_posix()
    return f'uv run "{abs_script}"'


def aggregate_hooks(vault: Path, manifest: dict) -> dict:
    """Liest .skills/<hook>/hook.yaml für jeden hooks_index-Eintrag und schreibt
    .claude/settings.json (immer) sowie .codex/config.toml (immer, auch ohne
    codex:true-Hook; Stub-Header bzw. Hook-Einträge + [features] multi_agent = true).

    Erstinstall-fest: fehlende hook.yaml → skip mit stderr-Hinweis, kein Crash.
    Liefert ein dict mit {"claude": [hook_names], "codex": [hook_names]} für Logging.
    """
    by_event_claude: dict[str, list] = {}
    by_event_codex: dict[str, list] = {}
    written = {"claude": [], "codex": [], "skipped": []}

    for hook_name in manifest.get("hooks_index", []):
        hook_yaml = vault / ".skills" / hook_name / "hook.yaml"
        if not hook_yaml.exists():
            sys.stderr.write(
                f"aggregate_hooks: hook.yaml fehlt für {hook_name}, skip\n"
            )
            written["skipped"].append(hook_name)
            continue
        cfg = yaml.safe_load(hook_yaml.read_text(encoding="utf-8")) or {}
        events = cfg.get("events") or []
        matcher = cfg.get("matcher") or ""
        script = cfg.get("script") or f"./scripts/{hook_name}.py"
        support = cfg.get("tool_support") or {}
        entry_claude = {
            "matcher": matcher,
            "hooks": [
                {
                    "type": "command",
                    "command": _hook_command(vault, hook_name, script, tool="claude"),
                }
            ],
        }
        entry_codex = {
            "matcher": matcher,
            "hooks": [
                {
                    "type": "command",
                    "command": _hook_command(vault, hook_name, script, tool="codex"),
                }
            ],
        }
        for event in events:
            if support.get("claude_code", True):
                by_event_claude.setdefault(event, []).append(entry_claude)
                if hook_name not in written["claude"]:
                    written["claude"].append(hook_name)
            if support.get("codex", False):
                by_event_codex.setdefault(event, []).append(entry_codex)
                if hook_name not in written["codex"]:
                    written["codex"].append(hook_name)

    claude_dir = vault / ".claude"
    claude_dir.mkdir(parents=True, exist_ok=True)
    settings_path = claude_dir / "settings.json"
    if settings_path.exists():
        try:
            existing = json.loads(settings_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            existing = {}
    else:
        existing = {}
    # Bewusst destruktiv: der gesamte hooks-Block wird ersetzt. Vault ist Single
    # Source of Truth für Hooks (.skills/<n>/hook.yaml). User-Manuell hinzugefügte
    # Hook-Einträge in settings.json werden bei jedem sync-Lauf gelöscht. Andere
    # Top-Level-Felder (permissions, env, etc.) bleiben erhalten.
    existing["hooks"] = by_event_claude
    settings_path.write_text(
        json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
    )

    # F2: .codex/config.toml IMMER schreiben, symmetrisch zu settings.json oben.
    # BOOTSTRAP.md Schritt 3 verlangt beide Tool-Configs parallel — auch wenn
    # kein Hook tool_support.codex: true hat, soll der Setup-Agent eine
    # Codex-Stub-Datei vorfinden. Inhalt: aggregierte codex-Hooks oder
    # Stub-Header (siehe _format_hooks_toml).
    codex_dir = vault / ".codex"
    codex_dir.mkdir(parents=True, exist_ok=True)
    codex_path = codex_dir / "config.toml"
    codex_path.write_text(_format_hooks_toml(by_event_codex), encoding="utf-8")

    return written


_CODEX_STUB_HEADER = (
    "# .codex/config.toml — generiert von .skills/sync.py\n"
    "#\n"
    "# Aktuell ist kein Hook mit tool_support.codex: true registriert.\n"
    "# Sobald ein Skill diese Flag setzt, schreibt sync.py die Hook-Einträge\n"
    "# unten als [[hooks.<Event>]]-Blöcke. Der [features]-Block darunter wird\n"
    "# unabhängig davon immer emittiert (siehe _CODEX_FEATURES_BLOCK).\n"
)

# HARD-LOCK L2: multi_agent wird deterministisch in JEDER generierten
# config.toml gesetzt. Per echtem `codex exec` verifiziert (V1/V2, 2026-06-08):
# Codex liest [features] aus der projekt-lokalen <vault>/.codex/config.toml und
# multi_agent ist der exakte, in CLI 0.137.0 anerkannte Schlüssel. Der CLI-Default
# ist ohnehin `true`; der explizite Eintrag hält das Verhalten stabil, falls sich
# der Default künftig ändert, und macht die Absicht für den Setup-Agent sichtbar.
_CODEX_FEATURES_BLOCK = (
    "[features]\n"
    "multi_agent = true\n"
)


def _format_hooks_toml(by_event: dict[str, list]) -> str:
    """Sehr flache TOML-Erzeugung — keine externe Lib nötig.

    Format pro Event-Eintrag:
        [[hooks.<EventName>]]
        matcher = "..."
        [[hooks.<EventName>.hooks]]
        type = "command"
        command = "..."

    Jeder Rückgabepfad stellt den [features]-Block (multi_agent = true, L2) voran.
    Als Tabellen-Header steht er valide vor den [[hooks.*]]-Array-Blöcken (mit
    tomllib geprüft).

    F2: Leeres by_event liefert Stub-Header + [features]-Block (statt leerer
    Datei), damit Codex und Setup-Agent die Existenz von .codex/config.toml
    feststellen können und der File einen erkennbaren Ursprung hat.
    """
    if not by_event:
        return _CODEX_STUB_HEADER + "\n" + _CODEX_FEATURES_BLOCK
    lines: list[str] = [_CODEX_FEATURES_BLOCK]
    for event, entries in by_event.items():
        for entry in entries:
            lines.append(f"[[hooks.{event}]]")
            lines.append(f'matcher = "{_toml_escape(entry["matcher"])}"')
            for h in entry["hooks"]:
                lines.append(f"[[hooks.{event}.hooks]]")
                lines.append(f'type = "{_toml_escape(h["type"])}"')
                lines.append(f'command = "{_toml_escape(h["command"])}"')
            lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def _toml_escape(s: str) -> str:
    """Escape für TOML basic strings. Behandelt Backslash, Doppelquote, Newlines,
    Carriage Return — TOML basic strings verbieten rohe Newlines."""
    return (
        s.replace("\\", "\\\\")
        .replace('"', '\\"')
        .replace("\n", "\\n")
        .replace("\r", "\\r")
    )


def resolve_active_skills(manifest: dict, profile: str) -> list[str]:
    """Bestimmt die aktiven Skills für ein Profil und sortiert sie topologisch.

    Schritt 1: Seed-Set — alle Skills mit default_active[profile] == True.
    Schritt 2: Transitiv alle depends_on-Skills hinzufügen (auch wenn inaktiv).
    Schritt 3: Kahn-Toposort — Producer vor Consumer, alphabetisch bei Gleichstand.
    Schritt 4: Zyklus-Erkennung — ValueError wenn Toposort unvollständig.

    Gibt eine leere Liste zurück, wenn kein Skill im Profil aktiv ist.
    Wirft ValueError bei unbekanntem Profil oder Zyklus. Externe Cloud-Deps,
    die nicht im Manifest stehen (z.B. notebooklm als Backend für notebook-
    research), werden mit stderr-Warnung übersprungen — der konsumierende
    Skill bleibt aktiv (F.4.13-Lesson 1, Doku-Test als Eval-Typ).
    """
    profiles = manifest.get("theme_profiles") or {}
    if profile not in profiles:
        raise ValueError(
            f"Profil '{profile}' nicht in theme_profiles gefunden. "
            f"Bekannte Profile: {sorted(profiles.keys())}"
        )

    skills = manifest.get("skills") or {}

    # Schritt 1: Seed-Set
    active: set[str] = set()
    for name, info in skills.items():
        if (info.get("default_active") or {}).get(profile, False):
            active.add(name)

    # Schritt 2: Transitiv Deps hinzufügen (BFS).
    # Externe Cloud-Skills (z.B. notebooklm), die bewusst nicht im Manifest stehen,
    # werden mit stderr-Warnung übersprungen. Der konsumierende Skill bleibt aktiv.
    # Begründung: notebook-research delegiert an notebooklm, das ein Cloud-Skill
    # außerhalb des Bundles ist. Hartes raise würde den gesamten Sync brechen,
    # obwohl der konsumierende Skill in seiner SKILL.md die Degradierung dokumentiert
    # (F.4.13-Lesson 1, Doku-Test als Eval-Typ).
    queue = list(active)
    visited_deps: set[str] = set(active)
    while queue:
        current = queue.pop(0)
        info = skills[current]  # Garantiert vorhanden: nur skills-Keys landen in active.
        for dep in (info.get("depends_on") or []):
            if dep not in skills:
                sys.stderr.write(
                    f"resolve_active_skills: '{current}' depends_on '{dep}', "
                    f"aber '{dep}' ist nicht im Manifest (vermutlich externer "
                    f"Cloud-Skill). Wird übersprungen.\n"
                )
                continue
            if dep not in visited_deps:
                visited_deps.add(dep)
                active.add(dep)
                queue.append(dep)

    if not active:
        return []

    # Schritt 3: Kahn-Toposort (nur über die aktive Menge)
    # Adjazenz: Für jedes Dep wird eine Kante dep → name eingetragen.
    in_degree: dict[str, int] = {name: 0 for name in active}
    dependents: dict[str, list[str]] = {name: [] for name in active}
    for name in sorted(active):  # sorted für Determinismus beim Aufbau
        info = skills[name]
        for dep in (info.get("depends_on") or []):
            if dep in active:
                in_degree[name] += 1
                dependents[dep].append(name)

    # Startknoten: in_degree == 0, alphabetisch sortiert (Tie-Break)
    ready = sorted(n for n in active if in_degree[n] == 0)
    result: list[str] = []
    while ready:
        node = ready.pop(0)
        result.append(node)
        for consumer in sorted(dependents[node]):
            in_degree[consumer] -= 1
            if in_degree[consumer] == 0:
                # Einfügen an der richtigen alphabetischen Stelle
                # (insort-äquivalent für Tie-Break-Determinismus)
                ready.append(consumer)
                ready.sort()

    # Schritt 4: Zyklus-Erkennung
    if len(result) != len(active):
        zyklische = sorted(active - set(result))
        raise ValueError(
            f"Zyklus (cycle) in depends_on erkannt. "
            f"Betroffene Skills: {zyklische}"
        )

    return result


def aggregate_mcp(vault: Path, manifest: dict, active_skills: list[str]) -> dict:
    """Liest .skills/<n>/mcp.json für aktive Skills, schreibt aggregierte
    .claude/.mcp.json + .codex/.mcp.json. Wenn kein Skill mcp.json hat,
    werden die Aggregat-Dateien NICHT angelegt (Designprinzip MCP-sparsam).

    Iteriert über active_skills (topologisch sortiert, von resolve_active_skills).
    Gibt das aggregierte mcpServers-Dict zurück (für Logging/Tests).
    """
    aggregated: dict = {}
    for name in active_skills:
        mcp_file = vault / ".skills" / name / "mcp.json"
        if not mcp_file.exists():
            continue
        try:
            data = json.loads(mcp_file.read_text(encoding="utf-8"))
        except json.JSONDecodeError as e:
            sys.stderr.write(f"aggregate_mcp: {name}/mcp.json invalid JSON: {e}\n")
            continue
        for server_name, server_cfg in (data.get("mcpServers") or {}).items():
            aggregated[server_name] = server_cfg

    if not aggregated:
        return {}

    payload = {"mcpServers": aggregated}
    serialized = json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
    for tool in (".claude", ".codex"):
        d = vault / tool
        d.mkdir(parents=True, exist_ok=True)
        (d / ".mcp.json").write_text(serialized, encoding="utf-8")
    return aggregated


def mirror_commands(vault: Path, force: bool = False) -> tuple[list, list]:
    """Spiegelt `.commands/*.md` nach `.claude/commands/*.md`.

    Drift-Check via mtime analog zu `mirror_skills`:
    - Quelle nicht vorhanden → no-op (kein Crash bei Vaults ohne .commands/).
    - Spiegel fehlt oder Quelle neuer → kopieren.
    - Spiegel neuer (User hat im Spiegel editiert) und kein force → BLOCK.
    - force=True → Spiegel wird unbedingt überschrieben.

    Liefert (synced, blocked) als Listen von Command-Dateinamen (ohne Endung).
    """
    src_root = vault / ".commands"
    dst_root = vault / ".claude" / "commands"
    synced: list = []
    blocked: list = []

    if not src_root.exists():
        return synced, blocked

    dst_root.mkdir(parents=True, exist_ok=True)

    for src in src_root.glob("*.md"):
        dst = dst_root / src.name
        if dst.exists() and not force:
            if dst.stat().st_mtime > src.stat().st_mtime + 2.1:
                blocked.append(src.stem)
                continue
        shutil.copy2(src, dst)
        synced.append(src.stem)

    (dst_root / "AUTO-GENERATED-DO-NOT-EDIT.md").write_text(
        f"This directory is auto-generated by sync.py at {datetime.utcnow().isoformat()}Z. Do not edit directly.",
        encoding="utf-8",
    )
    return synced, blocked


def resolve_template_dir(vault: Path, profile: str) -> str:
    """Löst ein Profil auf seinen Template-Baum unter ``.templates/`` auf.

    Macht das ``theme_profiles.<profil>.templates``-Feld lebendig
    (v0.24.0 Install-Härtung Posten 6 — vorher totes Metadatum, die 5
    Byte-Spiegel-Bäume sind dafür zurückgebaut).

    Stufe 1: ``profile`` in ``theme_profiles`` → ``templates``-Wert
             (Pflicht-String; fehlend/leer → ValueError, fail-loud).
    Stufe 2: ``profile`` nicht im Manifest (oder Manifest fehlt/unlesbar),
             aber ``.templates/<profile>/`` existiert → ``profile`` als
             direkter Baum-Name (Maintainer-/Test-Pfad, z.B. ``default``).
    Stufe 3: ValueError mit bekannten Profilen — analog
             ``resolve_active_skills``.
    """
    manifest_path = vault / ".skills" / "manifest.json"
    profiles: dict = {}
    if manifest_path.is_file():
        try:
            profiles = json.loads(
                manifest_path.read_text(encoding="utf-8")
            ).get("theme_profiles") or {}
        except (OSError, json.JSONDecodeError):
            profiles = {}
    if profile in profiles:
        template_dir = (profiles[profile] or {}).get("templates")
        if not template_dir or not isinstance(template_dir, str):
            raise ValueError(
                f"theme_profiles.{profile}.templates fehlt oder ist leer "
                f"im Manifest — Template-Baum nicht auflösbar."
            )
        return template_dir
    if (vault / ".templates" / profile).is_dir():
        return profile
    raise ValueError(
        f"Profil '{profile}' nicht in theme_profiles und kein Baum "
        f".templates/{profile}/ vorhanden. "
        f"Bekannte Profile: {sorted(profiles.keys())}"
    )


def install_root_files(
    vault: Path,
    template_dir: str,
    answers: dict,
    force: bool = False,
) -> tuple[list, list]:
    """Schreibt AGENTS.md / CLAUDE.md / INDEX.md ins Vault-Root.

    Liest Templates aus ``vault/.templates/<template_dir>/``, substituiert
    ``<USER_NAME>`` und ``<USER_INTRO>`` in AGENTS.md, schreibt alle drei
    Dateien atomar (``.tmp`` + ``replace``) in den Vault-Root.

    ``template_dir`` ist der Baum-Name unter ``.templates/`` — Aufrufer mit
    Profil-Namen lösen vorher über ``resolve_template_dir`` auf (so macht
    es ``main()``).

    force=False (Default): bei existierender Zieldatei BLOCK (Datei wandert
    in ``blocked``-Liste, kein Schreiben). force=True: überschreibt.

    Liefert ``(written, blocked)`` als Listen von Dateinamen.

    Raise ``ValueError`` wenn Template-Verzeichnis, eine Template-Datei,
    ein Pflicht-Marker (``<USER_NAME>`` / ``<USER_INTRO>``) oder ein
    Pflicht-Antwortfeld (``USER_NAME`` / ``USER_INTRO``) fehlt — fail-loud,
    weil ein still gefülltes Vault-Root mit Restmarker schlimmer wäre als
    ein expliziter Abbruch.
    """
    template_root = vault / ".templates" / template_dir
    if not template_root.is_dir():
        raise ValueError(f"Template-Verzeichnis fehlt: {template_root}")

    required_files = ["AGENTS.md", "CLAUDE.md", "INDEX.md"]
    files_with_subst = {"AGENTS.md"}
    required_markers = ["<USER_NAME>", "<USER_INTRO>"]
    required_answers = ["USER_NAME", "USER_INTRO"]

    for key in required_answers:
        if key not in answers:
            raise ValueError(f"Antwort fehlt: {key}")

    written: list = []
    blocked: list = []

    for fname in required_files:
        src = template_root / fname
        if not src.is_file():
            raise ValueError(f"Template fehlt: {src}")
        body = src.read_text(encoding="utf-8")
        if fname in files_with_subst:
            for marker in required_markers:
                if marker not in body:
                    raise ValueError(
                        f"Pflicht-Marker {marker} fehlt im Template {src}"
                    )
            body = body.replace("<USER_NAME>", answers["USER_NAME"])
            body = body.replace("<USER_INTRO>", answers["USER_INTRO"])

        dst = vault / fname
        if dst.exists() and not force:
            blocked.append(fname)
            continue

        # Atomarer Write analog sync-state.json (Z. 456-461): .tmp + replace
        # innerhalb desselben Volumes ist auf Windows wie POSIX atomar.
        # Schützt gegen Truncate-State bei OneDrive-Sync mid-write.
        tmp = dst.with_suffix(dst.suffix + ".tmp")
        tmp.write_text(body, encoding="utf-8")
        tmp.replace(dst)
        written.append(fname)

    return written, blocked


def materialize_profile_tree(
    vault: Path, template_dir: str, force: bool = False
) -> tuple[list, list]:
    """Kopiert den Baum aus ``vault/.templates/<template_dir>/PARA/`` ins Vault-Root.

    ``template_dir`` ist der Baum-Name unter ``.templates/`` — Aufrufer mit
    Profil-Namen lösen vorher über ``resolve_template_dir`` auf (so macht
    es ``main()``).

    Jede Datei landet unter ihrem relativen Pfad ohne das Präfix
    ``.templates/<template_dir>/PARA/``. Ordner werden mit
    ``mkdir(parents=True, exist_ok=True)`` angelegt. Dateien werden atomar
    geschrieben (``.tmp`` + ``replace``), analog zu ``install_root_files``.

    force=False (Default): existierende Zieldatei wandert in ``blocked``-Liste,
    kein Überschreiben. force=True: überschreibt.

    Raise ``ValueError`` wenn ``vault/.templates/<template_dir>/PARA/`` fehlt —
    fail-loud, weil ein still unvollständiges Vault-Root schlimmer wäre als ein
    expliziter Abbruch.

    Liefert ``(written, blocked)`` als Listen relativer POSIX-Pfade.
    """
    para_src = vault / ".templates" / template_dir / "PARA"
    if not para_src.is_dir():
        raise ValueError(f"Template-PARA-Verzeichnis fehlt: {para_src}")

    written: list = []
    blocked: list = []

    for src in sorted(para_src.rglob("*")):
        rel = src.relative_to(para_src)
        dst = vault / rel

        if src.is_dir():
            dst.mkdir(parents=True, exist_ok=True)
            continue

        if dst.exists() and not force:
            blocked.append(rel.as_posix())
            continue

        dst.parent.mkdir(parents=True, exist_ok=True)
        # Temp-Name über name + ".tmp" statt with_suffix: bei Dotfiles wie
        # .gitkeep ist suffix == "", with_suffix(".tmp") erzeugte sonst ".tmp"
        # statt ".gitkeep.tmp" — ein Waisen-Artefakt bei Abbruch, das OneDrive
        # mid-write anfassen könnte. Konsistent zu "<name>.md.tmp" bei Notizen.
        tmp = dst.parent / (dst.name + ".tmp")
        tmp.write_bytes(src.read_bytes())
        tmp.replace(dst)
        written.append(rel.as_posix())

    return written, blocked


def sync(vault: Path, force: bool = False, profile: str | None = None) -> int:
    """Orchestrator. Spiegelt Skills, aggregiert Hooks und MCP-Configs.

    profile: Name des Profils aus theme_profiles. Default: erstes Profil
    (alphabetisch) im Manifest — bei einem Manifest mit nur "allgemein"
    also stets "allgemein".
    """
    manifest = json.loads((vault / ".skills" / "manifest.json").read_text(encoding="utf-8"))
    skipped_non_vault = [
        name
        for name, info in manifest.get("skills", {}).items()
        if info.get("scope") != "vault"
    ]

    # Profil bestimmen: explizit oder erstes Profil alphabetisch
    theme_profiles = manifest.get("theme_profiles") or {}
    if profile is None:
        if not theme_profiles:
            sys.stderr.write("sync: theme_profiles fehlt im Manifest.\n")
            return 1
        profile = sorted(theme_profiles.keys())[0]

    # Aktive Skills für dieses Profil auflösen
    try:
        active_skills = resolve_active_skills(manifest, profile)
    except ValueError as e:
        sys.stderr.write(f"sync: resolve_active_skills gescheitert: {e}\n")
        return 1

    synced, blocked = mirror_skills(vault, manifest, force=force)
    hooks_log = aggregate_hooks(vault, manifest)
    mcp_servers = aggregate_mcp(vault, manifest, active_skills)
    cmd_synced, cmd_blocked = mirror_commands(vault, force=force)

    log = vault / ".skills" / "sync.log"
    with log.open("a", encoding="utf-8") as f:
        f.write(
            f"{datetime.utcnow().isoformat()}Z "
            f"active_profile={profile} active_skills={active_skills} "
            f"synced={synced} blocked={blocked} skipped_non_vault={skipped_non_vault} "
            f"hooks_claude={hooks_log.get('claude', [])} "
            f"hooks_codex={hooks_log.get('codex', [])} "
            f"hooks_skipped={hooks_log.get('skipped', [])} "
            f"mcp_servers={list(mcp_servers.keys())} "
            f"commands_synced={cmd_synced} commands_blocked={cmd_blocked}\n"
        )

    all_blocked = blocked + cmd_blocked
    if all_blocked:
        sys.stderr.write(
            f"BLOCKED: mirror has been manipulated for: {all_blocked}. Use --force to overwrite.\n"
        )
        return 2

    # Erfolgs-Marker für sync_drift_check-Hook setzen. Ohne diese Datei
    # klassifiziert der SessionStart-Hook jeden Vault dauerhaft als "first-
    # install" und führt nie einen Drift-Check aus.
    state_dir = vault / ".docs"
    state_dir.mkdir(parents=True, exist_ok=True)
    state_payload = {
        "last_sync": f"{datetime.utcnow().isoformat()}Z",
        "bundle_version": manifest.get("bundle", {}).get("version", "unknown"),
        "schema_version": manifest.get("bundle", {}).get("schema_version", 1),
        "active_profile": profile,
        "active_skills": active_skills,
    }
    # Atomarer Write: erst .tmp schreiben, dann replace (Path.replace ist auf
    # Windows wie POSIX innerhalb desselben Volumes atomar). Schützt gegen
    # truncate-State bei Prozess-Abbruch oder OneDrive-Sync mid-write — der
    # Hook würde sonst ein json.JSONDecodeError fangen und in den Fallback
    # gehen, was die Profil-Resolution still entwertet.
    state_path = state_dir / "sync-state.json"
    tmp_path = state_dir / "sync-state.json.tmp"
    tmp_path.write_text(
        json.dumps(state_payload, indent=2, ensure_ascii=False), encoding="utf-8"
    )
    tmp_path.replace(state_path)

    # bundle.json nach .docs/ spiegeln — bundle_version_drift_check liest
    # diese Datei als kanonische Bundle-Stand-Quelle. Ohne diese Spiegelung
    # bleibt der Hook nach jedem regulären Setup dauerhaft stumm (Audit F-H2).
    bundle_src = vault / "bundle.json"
    if not bundle_src.is_file():
        # Fallback: bundle.json liegt eine Ebene über dem Vault (Bundle-Root).
        bundle_src = vault.parent / "bundle.json"
    if bundle_src.is_file():
        bundle_dst = state_dir / "bundle.json"
        tmp_bundle = state_dir / "bundle.json.tmp"
        tmp_bundle.write_bytes(bundle_src.read_bytes())
        tmp_bundle.replace(bundle_dst)
    return 0


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--vault", type=Path, default=Path.cwd())
    parser.add_argument("--force", action="store_true")
    parser.add_argument("--profile", type=str, default=None)
    parser.add_argument(
        "--setup-root",
        action="store_true",
        help=(
            "Schreibt AGENTS.md / CLAUDE.md / INDEX.md ins Vault-Root statt "
            "Skill-Sync. Verlangt --profile und --answers."
        ),
    )
    parser.add_argument(
        "--answers",
        type=Path,
        default=None,
        help=(
            "Pfad zu einer JSON-Datei mit Substitutionen (Pflichtfelder "
            "USER_NAME, USER_INTRO). Nur in Kombination mit --setup-root."
        ),
    )
    parser.add_argument(
        "--setup-para",
        action="store_true",
        help=(
            "Kopiert den PARA-Baum aus .templates/<profile>/PARA/ ins Vault-Root. "
            "Verlangt --profile."
        ),
    )
    args = parser.parse_args()

    if args.setup_para:
        if args.profile is None:
            sys.stderr.write("--setup-para verlangt --profile.\n")
            sys.exit(1)
        try:
            template_dir = resolve_template_dir(args.vault, args.profile)
            written, blocked = materialize_profile_tree(
                args.vault, template_dir, force=args.force
            )
        except ValueError as e:
            sys.stderr.write(f"setup-para: {e}\n")
            sys.exit(1)
        if blocked:
            sys.stderr.write(
                f"setup-para: BLOCKED — Dateien existieren bereits: {blocked}. "
                f"Use --force to overwrite.\n"
            )
            sys.exit(2)
        sys.stdout.write(f"setup-para: written={written}\n")
        sys.exit(0)

    if args.setup_root:
        if args.profile is None:
            sys.stderr.write("--setup-root verlangt --profile.\n")
            sys.exit(1)
        if args.answers is None:
            sys.stderr.write("--setup-root verlangt --answers <json>.\n")
            sys.exit(1)
        try:
            answers = json.loads(args.answers.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as e:
            sys.stderr.write(f"--answers konnte nicht gelesen werden: {e}\n")
            sys.exit(1)
        try:
            template_dir = resolve_template_dir(args.vault, args.profile)
            written, blocked = install_root_files(
                args.vault, template_dir, answers, force=args.force
            )
        except ValueError as e:
            sys.stderr.write(f"install_root_files: {e}\n")
            sys.exit(1)
        if blocked:
            sys.stderr.write(
                f"BLOCKED: root files already exist: {blocked}. "
                f"Use --force to overwrite.\n"
            )
            sys.exit(2)
        sys.stdout.write(f"setup-root: written={written}\n")
        sys.exit(0)

    sys.exit(sync(args.vault, args.force, args.profile))


if __name__ == "__main__":
    main()
