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

Blockt Edit/Write auf .md-Dateien, in deren Body kein Wikilink steht.
Wikilinks im Frontmatter zählen nicht — nur der Body.
Typen aus exempt_types (in hook.yaml) werden ausgenommen.
"""
import json
import re
import sys
from pathlib import Path

import frontmatter
import yaml

WIKILINK_RE = re.compile(r"\[\[[^\]]+\]\]")


def _exempt_types() -> list:
    hook_yaml = Path(__file__).resolve().parent.parent / "hook.yaml"
    if not hook_yaml.exists():
        return []
    cfg = yaml.safe_load(hook_yaml.read_text(encoding="utf-8")) or {}
    return cfg.get("exempt_types") or []


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 tool_name == "Write" and "content" in tool_input:
        source = tool_input.get("content") or ""
    elif (
        tool_name == "Edit"
        and path.exists()
        and "old_string" in tool_input
        and "new_string" in tool_input
    ):
        # Post-Edit-Body simulieren: ohne Replace würde der Hook die Datei vor
        # dem Edit prüfen und einen Edit, der den letzten Wikilink entfernt,
        # durchwinken. Das wäre ein False-Negative gegen die Graph-Pflicht.
        # Edit ohne replace_all ersetzt nur das erste Vorkommen — wir bilden
        # das mit `str.replace(..., 1)` ab.
        disk = path.read_text(encoding="utf-8")
        old = tool_input.get("old_string") or ""
        new = tool_input.get("new_string") or ""
        replace_all = bool(tool_input.get("replace_all"))
        # str.replace ist lautlos, wenn `old` nicht im String vorkommt —
        # der Hook würde dann den Pre-Edit-Stand prüfen und einen Edit,
        # der den letzten Wikilink entfernt, fälschlich durchwinken.
        # Sicherheitsorientiert: blocken, wenn die Simulation nicht greift.
        if old and old not in disk:
            return {
                "action": "block",
                "message": (
                    "old_string nicht im Disk-Inhalt gefunden — "
                    "Edit-Simulation fehlgeschlagen, Wikilink-Schutz nicht prüfbar."
                ),
            }
        source = disk.replace(old, new) if replace_all else disk.replace(old, new, 1)
    elif path.exists():
        source = path.read_text(encoding="utf-8")
    else:
        return {"action": "allow"}

    try:
        post = frontmatter.loads(source)
    except Exception:
        # Frontmatter-Parsefehler bleiben Sache des frontmatter_validator.
        return {"action": "allow"}

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

    body = post.content or ""
    if not WIKILINK_RE.search(body):
        return {
            "action": "block",
            "message": (
                "Body enthält keinen Wikilink. Mindestens eine Verknüpfung "
                "[[Ziel]] im Body erforderlich (Graph-Pflicht)."
            ),
        }
    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.
    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)
    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()
