"""
Content-Verifikations-Helper für den NotebookLM Kurator.

Zweck:
Prüft nach einem `source add`, ob der tatsächlich hochgeladene Inhalt zu dem
passt, was wir erwartet haben. Hintergrund: NotebookLM akzeptiert auch fehl-
geschlagene Uploads stumm mit einem Pasted-Text-Fallback, der statt des
echten Inhalts eine Fehlermeldung enthält (MOCK-API-CONTRACT.md §3 Quirks
zu `source add`). Ohne Verifikation würde der Kurator glauben, alles sei
gut, obwohl der Inhalt Schrott ist.

Verify-Strategie:
Primäres Gate ist `char_count` aus dem `source fulltext --json`-Response
(MOCK-API-CONTRACT.md §5). Ein zu niedriger char_count deutet auf
Pasted-Text-Fallback oder fehlgeschlagene Extraktion hin. Marker-Strings
und Topic-Keywords werden zusätzlich als Diagnostik mitgezählt, sind aber
kein Gate mehr — YouTube-Titel und Uploader-Namen kommen oft nicht im
Transkript vor, weil das Transkript Voice-to-Text des gesprochenen
Inhalts ist.

Drei Funktionen:
- expected_marker_youtube(url): ruft yt-dlp auf und baut marker_strings,
  topic_keywords, video_id und min_char_count
- expected_marker_md(file_path): liest eine lokale Markdown-Datei und nutzt
  Dateiname und Body-Anfang für marker_strings und topic_keywords
- verify_after_add(notebook_id, source_id, expected, subprocess_runner=None):
  ruft `notebooklm source fulltext --json` auf und prüft primär char_count,
  sekundär als Diagnostik auch Marker und Keywords. Subprocess-DI macht
  das Mock-CLI in Tests dispatchierbar.

CLI-Quirks (verbatim aus MOCK-API-CONTRACT.md §5, §8):
1. PYTHONIOENCODING=utf-8 ist Pflicht bei jedem notebooklm-Aufruf, sonst
   crasht der Wrapper auf Windows mit UnicodeEncodeError (cp1252-Fallback).
2. `source fulltext --json` schreibt eine menschenlesbare Zeile
   "Matched: <uuid> (<titel>)" VOR das eigentliche JSON. Parser muss bis
   zur ersten `{` trimmen.
3. Volle UUIDs verwenden, keine Kürzel.
4. Primäres Feld im Response ist `content` (nicht `fulltext`). Fallback-
   Kette bleibt content → fulltext → text.

Alle Fehlerzustände werden sauber als Rückgabewert gemeldet, nicht als
Exception nach oben geworfen — der Kurator soll bei einzelnen
Verifikationsfehlern weiterlaufen und die betroffene Quelle im Audit-Log
markieren.
"""

import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Callable

# yt-dlp wird als Python-Modul aufgerufen, nicht als Shell-Command.
# Hintergrund: auf Windows-Hosts ist yt-dlp oft nicht im PATH, sondern nur
# via `python -m yt_dlp` erreichbar. `sys.executable` liefert den aktuellen
# Python-Interpreter — das funktioniert unabhängig davon, ob der Nutzer später
# Python neu installiert oder venvs wechselt.
_YTDLP_CMD = [sys.executable, "-m", "yt_dlp"]

# Default-Schwelle für char_count. Unter 500 Zeichen wird ein Upload als
# "zu wenig Inhalt" gewertet (Pasted-Text-Fallback oder fehlgeschlagene
# Extraktion). Kann per expected["min_char_count"] überschrieben werden.
_DEFAULT_MIN_CHAR_COUNT = 500

# Englische Stopwörter für Topic-Keyword-Extraktion. Bewusst klein gehalten
# — wir brauchen nur die häufigsten Funktionswörter, nicht einen vollen
# Stopword-Katalog. Alles lowercase.
_ENGLISH_STOPWORDS = frozenset(
    {
        "a", "an", "the", "and", "or", "of", "to", "for", "in", "on", "at",
        "with", "is", "are", "was", "were", "be", "been", "being",
    }
)


def _default_runner(cmd: list[str], timeout: int) -> tuple[int, str, str]:
    """Default-Subprocess-Runner für notebooklm-CLI-Aufrufe.

    Zwingt `PYTHONIOENCODING=utf-8` im Env (MOCK-API-CONTRACT.md §8 globale
    Pflicht), damit die CLI nicht auf Windows mit UnicodeEncodeError abraucht.
    """
    env = {"PYTHONIOENCODING": "utf-8", **os.environ}
    result = subprocess.run(
        cmd,
        env=env,
        text=True,
        encoding="utf-8",
        capture_output=True,
        timeout=timeout,
    )
    return result.returncode, result.stdout, result.stderr


def _extract_topic_keywords(text: str) -> list[str]:
    """Extrahiert Topic-Keywords aus einem Text.

    Workflow:
    1. Unicode-Satzzeichen strippen (Doppelpunkt, Komma, Punkt, Ausrufe-
       zeichen, Fragezeichen, Anführungszeichen, Klammern, Bindestriche).
    2. Auf Whitespace splitten.
    3. Lowercase.
    4. Stopwörter rausfiltern.
    5. Wörter unter 4 Zeichen rausfiltern.
    6. Duplikate entfernen (Reihenfolge bleibt erhalten).

    Gibt eine Liste von Keyword-Strings zurück. Leer wenn Input leer ist.
    """
    if not isinstance(text, str) or not text.strip():
        return []

    # Unicode-Satzzeichen durch Leerzeichen ersetzen, dann splitten.
    cleaned = re.sub(r"[:,.;!?\"'()\[\]{}<>\-–—/\\|]", " ", text)
    tokens = cleaned.split()

    seen: set[str] = set()
    keywords: list[str] = []
    for token in tokens:
        lower = token.lower().strip()
        if len(lower) < 4:
            continue
        if lower in _ENGLISH_STOPWORDS:
            continue
        if lower in seen:
            continue
        seen.add(lower)
        keywords.append(lower)
    return keywords


def expected_marker_youtube(url: str) -> dict:
    """Ruft yt-dlp auf und extrahiert Titel, Uploader, Dauer, Video-ID.

    Gibt ein Dict zurück mit:
    - marker_strings: list[str] mit den ersten 5 Wörtern des Titels und
      dem Uploader-Namen. Reine Diagnostik, kein Gate mehr.
    - topic_keywords: list[str] aus dem Titel, gefiltert gegen Stopwörter
      und Kurzwörter. Diagnostik, kein Gate.
    - min_char_count: int, Default 500. Wird aus der Dauer heuristisch
      abgeleitet (10 Zeichen pro Sekunde — konservativ, weil reale
      Transkripte etwa 15-17 Zeichen pro Sekunde gesprochenen Inhalts
      liefern), mindestens aber 500.
    - title: voller Titel (str)
    - duration: Dauer in Sekunden (int)
    - uploader: Kanalname (str)
    - video_id: YouTube-Video-ID (str) oder None
    - error: None bei Erfolg, sonst ein String "yt-dlp-fehler: <details>"

    Bei Timeout (30s), Subprocess-Fehler oder JSON-Parse-Fehler wird ein
    Dict mit leeren Werten und gesetztem error zurückgegeben. Der Kurator
    soll in dem Fall die Quelle trotzdem anlegen, die Verifikation aber
    als fehlgeschlagen markieren.
    """
    # PYTHONIOENCODING auch hier setzen (MOCK-API-CONTRACT.md §8 Pflicht-
    # Hygiene gilt für alle externen Tools, nicht nur notebooklm).
    env = {**os.environ, "PYTHONIOENCODING": "utf-8"}

    def _err(msg: str) -> dict:
        return {
            "marker_strings": [],
            "topic_keywords": [],
            "min_char_count": _DEFAULT_MIN_CHAR_COUNT,
            "title": None,
            "duration": None,
            "uploader": None,
            "video_id": None,
            "error": msg,
        }

    try:
        result = subprocess.run(
            # `--` als Argument-Separator schützt gegen yt-dlp-Argument-
            # Smuggling (Wave-D Brille-H H.2): URL beginnend mit `--config-
            # location=...` würde sonst als Option interpretiert.
            _YTDLP_CMD + ["--dump-json", "--no-download", "--no-warnings", "--", url],
            env=env,
            text=True,
            encoding="utf-8",
            capture_output=True,
            timeout=30,
        )
    except subprocess.TimeoutExpired:
        return _err("yt-dlp-fehler: timeout nach 30s")
    except FileNotFoundError as exc:
        return _err(f"yt-dlp-fehler: binary nicht gefunden ({exc})")
    except Exception as exc:
        return _err(f"yt-dlp-fehler: subprocess-exception {exc}")

    if result.returncode != 0:
        stderr_snippet = (result.stderr or "").strip().splitlines()
        tail = stderr_snippet[-1] if stderr_snippet else "kein stderr"
        return _err(f"yt-dlp-fehler: exit {result.returncode} — {tail}")

    try:
        data = json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        return _err(f"yt-dlp-fehler: json-parse {exc}")

    title = data.get("title")
    uploader = data.get("uploader")
    duration = data.get("duration")
    video_id = data.get("id")

    marker_strings: list[str] = []
    if isinstance(title, str) and title.strip():
        first_five = " ".join(title.split()[:5])
        if first_five:
            marker_strings.append(first_five)
    if isinstance(uploader, str) and uploader.strip():
        marker_strings.append(uploader)

    topic_keywords = _extract_topic_keywords(title or "")

    # Heuristik: 10 Zeichen pro Sekunde Video, mindestens 500.
    duration_int = int(duration) if isinstance(duration, (int, float)) else None
    if duration_int and duration_int > 0:
        min_char_count = max(_DEFAULT_MIN_CHAR_COUNT, duration_int * 10)
    else:
        min_char_count = _DEFAULT_MIN_CHAR_COUNT

    return {
        "marker_strings": marker_strings,
        "topic_keywords": topic_keywords,
        "min_char_count": min_char_count,
        "title": title,
        "duration": duration_int,
        "uploader": uploader,
        "video_id": video_id if isinstance(video_id, str) else None,
        "error": None,
    }


def expected_marker_md(file_path: Path) -> dict:
    """Liest eine Markdown-Datei und baut Marker aus Dateiname und Body-Anfang.

    Gibt ein Dict zurück mit:
    - marker_strings: list[str] mit [<dateiname ohne .md>, <erste 200 Zeichen des Body>]
    - topic_keywords: list[str] aus Dateiname plus Body-Anfang, gegen
      Stopwörter und Kurzwörter gefiltert.
    - min_char_count: int, Default 500.
    - title: Dateiname ohne .md-Extension
    - video_id: None (Markdown hat keine)
    - error: None bei Erfolg, sonst String mit Fehlerdetails

    Frontmatter wird minimal erkannt: beginnt die Datei mit "---\\n", suchen
    wir den nächsten "\\n---\\n" und nehmen alles danach als Body. Ohne
    Frontmatter ist der gesamte Inhalt der Body. Kein PyYAML nötig.
    """
    file_path = Path(file_path)
    title = file_path.stem  # Dateiname ohne .md

    try:
        raw = file_path.read_text(encoding="utf-8")
    except FileNotFoundError:
        return {
            "marker_strings": [],
            "topic_keywords": [],
            "min_char_count": _DEFAULT_MIN_CHAR_COUNT,
            "title": title,
            "video_id": None,
            "error": f"datei-nicht-gefunden: {file_path}",
        }
    except Exception as exc:
        return {
            "marker_strings": [],
            "topic_keywords": [],
            "min_char_count": _DEFAULT_MIN_CHAR_COUNT,
            "title": title,
            "video_id": None,
            "error": f"datei-lesefehler: {exc}",
        }

    # Minimaler Frontmatter-Skip
    body = raw
    if raw.startswith("---\n"):
        end_marker = raw.find("\n---\n", 4)
        if end_marker != -1:
            body = raw[end_marker + len("\n---\n"):]

    body_snippet = body[:200]

    marker_strings: list[str] = [title, body_snippet]
    topic_keywords = _extract_topic_keywords(title + " " + body_snippet)

    return {
        "marker_strings": marker_strings,
        "topic_keywords": topic_keywords,
        "min_char_count": _DEFAULT_MIN_CHAR_COUNT,
        "title": title,
        "video_id": None,
        "error": None,
    }


def verify_after_add(
    notebook_id: str,
    source_id: str,
    expected: dict,
    subprocess_runner: Callable | None = None,
) -> tuple[bool, str]:
    """Prüft per `source fulltext --json`, ob der hochgeladene Inhalt
    ausreichend vollständig ist.

    Subprocess-DI (Wave-D-Pflicht-Migration, Spec §5.0 + Critic M-7):
    `subprocess_runner` mit Signatur runner(cmd, timeout) -> (rc, stdout, stderr)
    erlaubt Mock-CLI-Dispatch in Tests. Default `_default_runner` setzt
    PYTHONIOENCODING=utf-8 (MOCK-API-CONTRACT.md §8 globale Pflicht).
    Backwards-kompatibel: bei `None` greift der Default.

    Strategie:
    Primäres Gate ist `char_count` aus dem Response. Marker-Strings und
    Topic-Keywords werden nur noch als Diagnostik gezählt und in die
    Rückgabe-Nachricht geschrieben, sind aber kein Fail-Grund mehr.

    Ablauf:
    1. Wenn expected weder marker_strings noch topic_keywords noch einen
       min_char_count-Wert enthält, ist gar nichts zum Prüfen da → früh
       abbrechen mit (False, "leere-marker-liste").
    2. `notebooklm source fulltext --notebook <nb> <src> --json` via runner
       aufrufen (60s Timeout).
    3. Die Matched-Vorzeile überspringen: JSON ab dem ersten "{" parsen
       (MOCK-API-CONTRACT.md §5 verbatim).
    4. char_count aus dem JSON lesen. Wenn char_count < threshold:
       (False, "char-count-zu-niedrig: N < T").
    5. Fulltext-Inhalt extrahieren — primärer Feldname ist `content`
       (MOCK-API-CONTRACT.md §5-Korrektur 2026-04-09 verbatim), Fallback-
       Kette `content → fulltext → text` für ältere CLI-Stände. Marker-
       und Keyword-Hits zählen (case-insensitive Substring).
    6. Video-ID-Check (nur Diagnostik): falls expected["video_id"]
       gesetzt ist und im Response-url-Feld auftaucht, wird das in der
       Nachricht vermerkt.
    7. Return (True, "ok: char_count=N, marker_hits=m/M, keyword_hits=k/K").

    Rückgabe:
    - (True, "ok: ...") wenn char_count >= threshold
    - (False, "char-count-zu-niedrig: ...") wenn Schwelle unterschritten
    - (False, "leere-marker-liste") wenn gar nichts zum Prüfen da ist
    - (False, "subprocess-fehler: ...") bei Subprocess-Problemen
    - (False, "json-parse-fehler: ...") bei JSON-Problemen
    """
    runner = subprocess_runner or _default_runner

    marker_strings = expected.get("marker_strings") or []
    topic_keywords = expected.get("topic_keywords") or []
    threshold = expected.get("min_char_count")
    if not isinstance(threshold, int) or threshold <= 0:
        threshold = _DEFAULT_MIN_CHAR_COUNT
    expected_video_id = expected.get("video_id")

    # Wenn gar nichts zum Prüfen da ist — keine Marker, keine Keywords,
    # und expected hat auch keinen min_char_count-Schlüssel — bleibt es
    # bei der alten Fehlermeldung. Der Kurator erkennt dann, dass
    # expected gar nicht sinnvoll gebaut wurde (z.B. nach yt-dlp-Fehler).
    has_min_char_count_key = "min_char_count" in expected
    if not marker_strings and not topic_keywords and not has_min_char_count_key:
        return (False, "leere-marker-liste")

    cmd = [
        "notebooklm",
        "source",
        "fulltext",
        "--notebook",
        notebook_id,
        source_id,
        "--json",
    ]

    try:
        rc, stdout, stderr = runner(cmd, 60)
    except subprocess.TimeoutExpired:
        return (False, "subprocess-fehler: timeout nach 60s")
    except FileNotFoundError as exc:
        return (False, f"subprocess-fehler: notebooklm-binary nicht gefunden ({exc})")
    except Exception as exc:
        return (False, f"subprocess-fehler: subprocess-exception {exc}")

    if rc != 0:
        stderr_snippet = (stderr or "").strip().splitlines()
        tail = stderr_snippet[-1] if stderr_snippet else "kein stderr"
        return (False, f"subprocess-fehler: exit {rc} — {tail}")

    stdout = stdout or ""
    json_start = stdout.find("{")
    if json_start == -1:
        return (False, "json-parse-fehler: kein '{' im stdout gefunden")

    try:
        data = json.loads(stdout[json_start:])
    except json.JSONDecodeError as exc:
        return (False, f"json-parse-fehler: {exc}")

    if not isinstance(data, dict):
        return (False, "json-parse-fehler: response ist kein dict")

    # char_count ist primäres Gate. Wenn das Feld fehlt, fallen wir auf
    # die Länge des Content-Felds zurück — so bleibt die Verifikation
    # robust gegenüber alten CLI-Versionen ohne char_count.
    char_count = data.get("char_count")
    if not isinstance(char_count, int):
        # Fallback: aus dem Content-Feld ableiten
        for field in ("content", "fulltext", "text"):
            value = data.get(field)
            if isinstance(value, str):
                char_count = len(value)
                break
        else:
            char_count = 0

    if char_count < threshold:
        return (False, f"char-count-zu-niedrig: {char_count} < {threshold}")

    # Gate ist bestanden. Jetzt noch Diagnostik sammeln.
    # MOCK-API-CONTRACT.md §5: primäres Feld ist `content`, Fallback-Kette
    # `content → fulltext → text` für ältere CLI-Stände.
    fulltext = None
    for field in ("content", "fulltext", "text"):
        value = data.get(field)
        if isinstance(value, str) and value:
            fulltext = value
            break

    fulltext_lower = fulltext.lower() if fulltext else ""

    marker_hits = 0
    for marker in marker_strings:
        if not isinstance(marker, str) or not marker:
            continue
        if marker.lower() in fulltext_lower:
            marker_hits += 1

    keyword_hits = 0
    for keyword in topic_keywords:
        if not isinstance(keyword, str) or not keyword:
            continue
        if keyword.lower() in fulltext_lower:
            keyword_hits += 1

    # Video-ID-Check (nur Diagnostik-Info in der Nachricht)
    video_id_note = ""
    if expected_video_id:
        url_field = data.get("url")
        if isinstance(url_field, str) and expected_video_id in url_field:
            video_id_note = ", video_id=match"
        else:
            video_id_note = ", video_id=miss"

    msg = (
        f"ok: char_count={char_count}, "
        f"marker_hits={marker_hits}/{len(marker_strings)}, "
        f"keyword_hits={keyword_hits}/{len(topic_keywords)}"
        f"{video_id_note}"
    )
    return (True, msg)
