# /// script
# requires-python = ">=3.11"
# dependencies = ["tzdata"]
# ///
"""guard_notebooklm_delete.py — PreToolUse-Hook.

Fängt `notebooklm source delete`-Befehle ab und blockiert sie, wenn unter
`<KURATOR_STATE_ROOT>/backups/` für den heutigen Tag und die jeweilige
Source-ID keine Backup-Datei vorliegt.

Verteidigt zwei Schichten der Kurator-Sicherheitsregel "Kein Delete ohne
Backup" (siehe SKILL.md Sicherheitsregel 2). Die erste Schicht ist das
Approval-Gate im Skill-Ablauf. Der Hook ist die zweite Schicht — er greift
auch dann, wenn der Skill umgangen wird oder ein Sub-Agent direkt das CLI
ruft.

Path-Containment (Wave-1-Lesson 10, MOCK-API-CONTRACT.md §8):
`KURATOR_STATE_ROOT` wird via `_lib/env_loader.require_path_env_var`
mit Allowlist gegen Path-Traversal gehärtet. Allowed-Roots werden vom
Hook-Aufrufer als Env-Vars `KURATOR_VAULT_ROOT` und `KURATOR_TEMP_ROOT`
übergeben (Wave-1-Lesson 10 Symmetrie zu anderen Bundle-Skills).
Fallback bei fehlender Vault-Root: `tempfile.gettempdir()` als einziger
erlaubter Wurzel (lokale Setups ohne Vault-Bindung).

Hook-Vertrag (Bundle-Standard, analog output_filter):
- Liest JSON-Payload aus stdin.
- Schreibt JSON-Response auf stdout: `{"decision": "deny", "message": "..."}`
  bei Block, `{"decision": "approve"}` (oder kein decision-Feld) bei Pass.
- Stderr für Diagnose-Ausgaben.

Erstinstall-fest: fehlende Env-Var, unlesbare Payload, abwesendes Backup-
Verzeichnis → approve (Hook darf den Skill nicht stiller blockieren, wenn
der Nutzer das State-Root gar nicht konfiguriert hat — die erste Sicherheits-
Schicht im Skill-Ablauf bleibt aktiv).
"""
from __future__ import annotations

import json
import os
import re
import shlex
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

# TZ-Konvergenz mit backup.py (Wave-D Brille-F-F2-3): Backup-Verzeichnisse
# nutzen ZoneInfo("Europe/Berlin"). Hook MUSS dieselbe TZ verwenden, sonst
# scheitert die Backup-Suche bei Maschinen außerhalb Berlin oder über Mitter-
# nacht UTC still.
_BERLIN_TZ = ZoneInfo("Europe/Berlin")


# Repo-Root finden, damit wir _lib importieren können.
# Pfad-Layout: <bundle-root>/.skills/guard_notebooklm_delete/scripts/guard_notebooklm_delete.py
# (Bundle-Hook-Konvention analog output_filter: eigenes Skill-Verzeichnis, Skript unter scripts/)
_SCRIPT_DIR = Path(__file__).resolve().parent
_SKILL_DIR = _SCRIPT_DIR.parent
_SKILLS_ROOT = _SKILL_DIR.parent
if str(_SKILLS_ROOT) not in sys.path:
    sys.path.insert(0, str(_SKILLS_ROOT))

try:
    from _lib.env_loader import (
        require_path_env_var,
        PathEnvVarUnsafeError,
        EnvVarMissingError,
    )
except ImportError:
    require_path_env_var = None  # type: ignore
    PathEnvVarUnsafeError = RuntimeError  # type: ignore
    EnvVarMissingError = RuntimeError  # type: ignore


# Regex erkennt `notebooklm source delete`-Aufrufe in einer Bash-Command-Line.
# Wave-D Brille-F-F1-2: Erkennung muss zwei Varianten abdecken:
#  (a) Command-Token kann auch ein Pfad mit .exe-Suffix sein:
#      `C:/.../notebooklm.exe source delete ...` (MOCK-API-CONTRACT.md §10).
#  (b) Source-ID kann VOR oder NACH `--notebook <UUID>` stehen.
# Strategie: zuerst auf `notebooklm[.exe]? source delete`-Präfix matchen,
# dann den ersten Nicht-Flag- und Nicht-UUID-Token aus den Argumenten als
# Source-ID extrahieren.
_DELETE_PREFIX_RE = re.compile(
    r"\bnotebooklm(?:\.exe)?\s+source\s+delete\b",
    re.IGNORECASE,
)
_UUID_RE = re.compile(
    r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
    re.IGNORECASE,
)


def _extract_source_id(cmd: str) -> str | None:
    """Extrahiert die Source-ID nach `notebooklm source delete`.

    Robust gegen Flag-First-Ordering (`source delete --notebook <UUID> <id>`)
    und gegen `.exe`-Pfad-Aufrufe (Wave-D Brille-F-F1-2).
    """
    match = _DELETE_PREFIX_RE.search(cmd)
    if not match:
        return None
    # Tail nach `delete`: nimm den ersten Token, der weder Flag noch UUID-Wert
    # eines `--notebook`-Flags ist.
    tail = cmd[match.end():].strip().split()
    i = 0
    while i < len(tail):
        token = tail[i]
        if token.startswith("-"):
            # Flag: überspringe sich selbst und (sofern Wert folgt) den Wert.
            # Inline-Form `--notebook=<wert>` hat keinen separaten Wert-Token.
            if "=" in token:
                i += 1
            else:
                i += 2
            continue
        # Erster Nicht-Flag-Token, der nicht selbst eine UUID-aussehende
        # `--notebook`-Argument-Reststelle ist: das ist die Source-ID.
        # (Eine echte Source-ID darf UUID-förmig sein.)
        return token
    return None


def _approve() -> None:
    """Approve-Pfad: Hook lässt den Tool-Call passieren."""
    print(json.dumps({"decision": "approve"}, ensure_ascii=False))


def _deny(message: str) -> None:
    """Deny-Pfad: Hook blockiert den Tool-Call mit klarer Meldung."""
    print(json.dumps({"decision": "deny", "message": message}, ensure_ascii=False))


def _extract_bash_command(payload: dict) -> str | None:
    """Zieht die `command`-Zeile aus einem PreToolUse-Bash-Payload.

    Payload-Form bei Claude Code: `tool_input.command` (str).
    Wenn der Tool-Call kein Bash ist oder keine Command-Zeile hat: None.
    """
    tool_input = payload.get("tool_input") or {}
    if not isinstance(tool_input, dict):
        return None
    cmd = tool_input.get("command")
    if isinstance(cmd, str) and cmd.strip():
        return cmd
    return None


def _resolve_state_root() -> Path | None:
    """Liest KURATOR_STATE_ROOT mit Containment-Check.

    Allowed-Roots:
    - KURATOR_VAULT_ROOT (Env-Var, optional)
    - KURATOR_TEMP_ROOT (Env-Var, optional)
    - Fallback: tempfile.gettempdir() als einziger erlaubter Wurzel.

    Gibt None zurück, wenn die Env-Var nicht gesetzt ist (Hook wird in dem
    Fall auf approve fallen, weil die zweite Schicht ohne State-Root nicht
    sinnvoll wirken kann). Bei Path-Traversal: PathEnvVarUnsafeError nach
    oben.
    """
    if require_path_env_var is None:
        return None
    if not os.environ.get("KURATOR_STATE_ROOT"):
        return None

    allowed_roots: list[Path] = []
    vault_root = os.environ.get("KURATOR_VAULT_ROOT")
    if vault_root:
        allowed_roots.append(Path(vault_root).resolve())
    temp_root = os.environ.get("KURATOR_TEMP_ROOT")
    if temp_root:
        allowed_roots.append(Path(temp_root).resolve())
    if not allowed_roots:
        allowed_roots.append(Path(tempfile.gettempdir()).resolve())

    return require_path_env_var(
        name="KURATOR_STATE_ROOT",
        skill_name="notebooklm-kurator",
        allowed_roots=allowed_roots,
        must_exist=False,
    )


def _has_backup_for(state_root: Path, source_id: str) -> bool:
    """Sucht im heutigen Backup-Tagesordner nach einer Datei zur Source-ID.

    Pfad-Schema (siehe backup.py):
        state_root / backups / <notebook-slug> / <YYYY-MM-DD> / <source_id>__<slug>.md

    Hook kennt den Notebook-Slug nicht — daher rekursiv über alle
    Notebook-Unterordner suchen, aber nur im Tagesordner.
    """
    backups_root = state_root / "backups"
    if not backups_root.exists():
        return False
    today = datetime.now(_BERLIN_TZ).strftime("%Y-%m-%d")
    # Glob über alle Notebook-Slugs / today / <source_id>__*.md
    for nb_dir in backups_root.iterdir():
        if not nb_dir.is_dir():
            continue
        day_dir = nb_dir / today
        if not day_dir.is_dir():
            continue
        for backup_file in day_dir.glob(f"{source_id}*"):
            if backup_file.is_file():
                return True
    return False


def main() -> None:
    """Hook-Einstiegspunkt. Erstinstall-fest: Fehler → approve."""
    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:
        _approve()
        return

    if not isinstance(payload, dict):
        _approve()
        return

    command = _extract_bash_command(payload)
    if not command:
        _approve()
        return

    if not _DELETE_PREFIX_RE.search(command):
        # Kein notebooklm source delete im Command — Hook irrelevant.
        _approve()
        return

    source_id = _extract_source_id(command)
    if not source_id:
        # Pathologisch: delete ohne ID. CLI würde das selbst ablehnen.
        # Wir blockieren defensiv, weil der Skill-Vertrag volle UUIDs verlangt.
        _deny(
            "KURATOR-SICHERHEIT: notebooklm source delete ohne erkennbare "
            "Source-ID. Befehl blockiert."
        )
        return

    # State-Root mit Containment lesen.
    try:
        state_root = _resolve_state_root()
    except PathEnvVarUnsafeError as exc:
        _deny(
            f"KURATOR-SICHERHEIT: KURATOR_STATE_ROOT liegt außerhalb der "
            f"erlaubten Wurzeln. Pfad-Traversal blockiert. Details: {exc}"
        )
        return
    except EnvVarMissingError:
        # Sollte nicht passieren, weil _resolve_state_root() vorher prüft.
        _approve()
        return
    except Exception as exc:
        # Defensive: wenn der env_loader irgendetwas anderes wirft, lassen
        # wir den Aufruf durch (erste Sicherheits-Schicht im Skill greift).
        sys.stderr.write(
            f"guard_notebooklm_delete: env_loader-Fehler, approve: {exc}\n"
        )
        _approve()
        return

    if state_root is None:
        # Env-Var nicht gesetzt → Skill läuft ohne konfiguriertes State-Root.
        # Hook kann nicht prüfen — Skill-Approval-Gate ist die einzige
        # Schutzlinie. Dokumentation in stderr.
        sys.stderr.write(
            "guard_notebooklm_delete: KURATOR_STATE_ROOT nicht gesetzt, "
            "Hook kann Backup nicht prüfen. Skill-Approval-Gate aktiv.\n"
        )
        _approve()
        return

    if _has_backup_for(state_root, source_id):
        _approve()
        return

    _deny(
        f"KURATOR-SICHERHEIT: notebooklm source delete für Source "
        f"`{source_id}` ohne Backup im heutigen Tagesordner. "
        f"Erst Backup via helpers/backup.py schreiben, dann erneut versuchen."
    )


if __name__ == "__main__":
    main()
