# /// script
# requires-python = ">=3.11"
# dependencies = ["python-frontmatter", "pyyaml"]
# ///
"""frontmatter_validator.py — PreToolUse-Hook.

Blockt Edit/Write auf .md-Dateien, denen Pflicht-Frontmatter-Felder fehlen.
Pflichtfelder: type, description, agent_ready, schema_version, tags, created.
Typen aus exempt_types (in hook.yaml) werden ausgenommen.
"""
import json
import re
import sys
from pathlib import Path

import frontmatter
import yaml

REQUIRED = ["type", "description", "agent_ready", "schema_version", "tags", "created"]

OPTIONAL_PATTERNS = {
    "bundle_version": re.compile(r"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$"),
}


def _hook_cfg() -> dict:
    hook_yaml = Path(__file__).resolve().parent.parent / "hook.yaml"
    if not hook_yaml.exists():
        return {}
    return yaml.safe_load(hook_yaml.read_text(encoding="utf-8")) or {}


def _exempt_types() -> list:
    return _hook_cfg().get("exempt_types") or []


def _exempt_files() -> list:
    """Dateinamen ohne Frontmatter-Pflicht (Vault-Root-Governance wie
    AGENTS.md/CLAUDE.md/INDEX.md — die haben konstruktionsbedingt kein
    Frontmatter, müssen aber von /skill-new editierbar bleiben)."""
    return _hook_cfg().get("exempt_files") or []


def _apply_edit(disk: str, tool_input: dict) -> str | None:
    """Simuliert einen Edit gegen den Disk-Stand. Liefert den Post-Edit-Content
    oder None, wenn old_string nicht im Disk-Stand vorkommt (CC würde dann
    selbst scheitern — der Hook darf hier durchlassen statt eine zweite
    Fehlermeldung zu produzieren). replace_all wird respektiert."""
    old = tool_input.get("old_string", "")
    new = tool_input.get("new_string", "")
    if not old or old not in disk:
        return None
    if tool_input.get("replace_all"):
        return disk.replace(old, new)
    return disk.replace(old, new, 1)


def _apply_multi_edit(disk: str, tool_input: dict) -> str | None:
    """Simuliert MultiEdit als Sequenz von Einzel-Edits gegen den jeweils
    aktualisierten Zwischenstand. Sobald ein old_string nicht trifft, liefert
    die Funktion None (Allow-Fallback, siehe _apply_edit)."""
    edits = tool_input.get("edits") or []
    state = disk
    for edit in edits:
        old = edit.get("old_string", "")
        new = edit.get("new_string", "")
        if not old:
            continue
        if old not in state:
            return None
        if edit.get("replace_all"):
            state = state.replace(old, new)
        else:
            state = state.replace(old, new, 1)
    return state


def _read_disk(path: Path) -> str | None:
    """TOCTOU-resistenter Disk-Read. `path.exists()` plus separates
    `path.read_text()` ist nicht atomar — die Datei kann zwischen Check und
    Read verschwinden (OneDrive-Sync, paralleler Prozess, Lock auf Windows).
    Statt zwei-Phasen-Check fangen wir den OSError direkt und liefern None,
    was vom Aufrufer als Allow-Fallback interpretiert wird."""
    try:
        return path.read_text(encoding="utf-8")
    except OSError:
        return None


def _decide(payload: dict) -> dict:
    tool_input = payload.get("tool_input") or {}
    tool_name = payload.get("tool_name")
    file_path = tool_input.get("file_path")
    if not file_path:
        return {"action": "allow"}

    path = Path(file_path)
    if path.suffix.lower() != ".md":
        return {"action": "allow"}
    if path.name in _exempt_files():
        return {"action": "allow"}

    # Post-State-Simulation pro Tool. Vor v0.1.2 hat der Hook bei Edit/
    # MultiEdit auf den Disk-Stand zurückgefallen — ein Edit, der das
    # Frontmatter zerstoert, rutschte durch, wenn die Datei vorher valide war.
    # Write erzeugt/überschreibt komplett, daher direkt aus tool_input.content.
    if tool_name == "Write" and "content" in tool_input:
        source = tool_input.get("content") or ""
    elif tool_name == "Edit":
        disk = _read_disk(path)
        if disk is None:
            return {"action": "allow"}
        simulated = _apply_edit(disk, tool_input)
        if simulated is None:
            return {"action": "allow"}
        source = simulated
    elif tool_name == "MultiEdit":
        disk = _read_disk(path)
        if disk is None:
            return {"action": "allow"}
        simulated = _apply_multi_edit(disk, tool_input)
        if simulated is None:
            return {"action": "allow"}
        source = simulated
    else:
        disk = _read_disk(path)
        if disk is None:
            return {"action": "allow"}
        source = disk

    try:
        post = frontmatter.loads(source)
    except Exception as e:
        return {"action": "block", "message": f"frontmatter parse error: {e}"}

    meta = post.metadata or {}
    file_type = meta.get("type")
    if file_type and file_type in _exempt_types():
        return {"action": "allow"}

    missing = [field for field in REQUIRED if field not in meta]
    if missing:
        return {
            "action": "block",
            "message": f"frontmatter fehlt Pflichtfeld(er): {', '.join(missing)}",
        }

    for field, pattern in OPTIONAL_PATTERNS.items():
        if field not in meta:
            continue
        value = meta[field]
        if value is None:
            continue
        if not isinstance(value, str) or not pattern.match(value):
            return {
                "action": "block",
                "message": (
                    f"frontmatter {field}: ungültiges Format {value!r} "
                    f"(erwartet Semver wie '0.1.2' oder null)"
                ),
            }

    return {"action": "allow"}


def main():
    """F11: CC-Hook-Contract = Exit 2 + stderr für block, Exit 0 für allow.
    `_decide()` bleibt pure und liefert das Dict — `main()` interpretiert nur.
    Alternative wären `{"decision": "block", "reason": "..."}` oder
    `hookSpecificOutput`, beide mit Exit 0; binäres Exit-2-Pattern ist
    robuster gegen künftige CC-Schema-Änderungen und passt zum aktuellen
    Bedarf (block/allow ohne ask/warn-Zwischenstufe).
    """
    # F14: stdout/stderr auf UTF-8 zwingen. Default auf Windows ist cp1252,
    # CC erwartet aber UTF-8 — sonst landet 'ä' als 0xE4 statt 0xC3 0xA4.
    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)
    decision = _decide(payload)
    if decision.get("action") == "block":
        print(decision.get("message", ""), file=sys.stderr)
        sys.exit(2)
    sys.exit(0)


if __name__ == "__main__":
    main()
