"""
resync.py — Drift-Heilung für NotebookLM-Notebooks.

Liest `source list` und `source fulltext` für ein Notebook und gleicht die
Quellen gegen den lokalen URL-Hash-Index ab. Aktualisiert Index und
Notebook-Cache, schreibt Audit-Log-Einträge.

Eiserne Regel: KEINE produktiven Schreibaktionen gegen NotebookLM. Dieser
Helper ruft ausschließlich lesende CLI-Befehle auf (`source list`,
`source fulltext`). Er fasst `source add`, `source delete`, `notebook update`
oder Ähnliches niemals an. Drift wird lokal dokumentiert, nie per Remote
korrigiert.

`state_root` ist Pflicht-Parameter — kein impliziter Vault-Default.
Der Aufrufer übergibt typischerweise `cfg.paths.kurator_state_root / "state"`.

CLI-Vertrag (verbatim aus MOCK-API-CONTRACT.md §2, §5, §8):
- `source list --notebook <UUID> --json` liefert ein Dict
  `{"sources": [...], "count": N}`, kein bare Array.
- `source fulltext --notebook <UUID> <id> --json` liefert eine Matched-
  Vorzeile VOR dem JSON. Parser muss bis zur ersten `{` trimmen
  (siehe `_parse_fulltext_json`).
- PYTHONIOENCODING=utf-8 ist Pflicht im Subprocess-Env, sonst crasht
  das CLI auf Windows mit UnicodeEncodeError (MOCK-API-CONTRACT.md §8).
"""

import json
import os
import subprocess
import re
import time
from datetime import datetime
from pathlib import Path
from typing import Callable

from audit_log import append as audit_append
from index_io import load_index, save_index
from url_extract import extract_urls, normalize
from url_hash import url_hash as hash_url


# Token-Pattern für stderr-Maskierung (Wave-D Brille-H H.3 Defense-in-Depth,
# OWASP A09 Logging Failures). NotebookLM-CLI exponiert aktuell kein Token
# in stderr — aber zukünftige CLI-Updates oder Wrapper könnten das. Hier
# vorsorgliche Maskierung vor jedem audit_append(message=stderr[:N]).
_SECRET_PATTERNS = re.compile(
    r"(?i)(bearer\s+\S+|token\s*[=:]\s*\S+|api[_-]?key\s*[=:]\s*\S+)"
)


def _mask_secrets(text: str) -> str:
    """Maskiert Token-/Bearer-/API-Key-Pattern in stderr-Slices."""
    return _SECRET_PATTERNS.sub("[REDACTED]", text)


# Timeouts (Sekunden) für CLI-Aufrufe
_LIST_TIMEOUT = 60
_FULLTEXT_TIMEOUT = 120

# Backoff-Stufen bei 429/504 (Sekunden)
_BACKOFF_STEPS = (5, 10, 20)

# Pause zwischen fulltext-Calls, um die API zu schonen
_FULLTEXT_PAUSE = 2.0


def _default_runner(cmd: list[str], timeout: int) -> tuple[int, str, str]:
    """Default-Subprocess-Runner: führt das CLI mit UTF-8 aus.

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


def _parse_fulltext_json(stdout: str) -> dict:
    """Überspringt die Matched-Vorzeile und parst ab dem ersten '{'.

    Der `notebooklm source fulltext --json`-Befehl schreibt vor dem JSON-Blob
    eine `Matched: ...`-Zeile auf stdout (MOCK-API-CONTRACT.md §5 verbatim:
    "Parser muss bis zur ersten `{` trimmen"). Wir überspringen alles bis zum
    ersten Opening-Brace und parsen dann den Rest als JSON.
    """
    idx = stdout.find("{")
    if idx < 0:
        raise ValueError("kein JSON-Objekt im stdout gefunden")
    return json.loads(stdout[idx:])


def _now_iso() -> str:
    """Liefert einen ISO-8601-Zeitstempel in Europe/Berlin mit Offset."""
    try:
        from zoneinfo import ZoneInfo
        now = datetime.now(ZoneInfo("Europe/Berlin"))
    except Exception:
        now = datetime.now().astimezone()
    offset = now.strftime("%z")
    offset_formatted = f"{offset[:3]}:{offset[3:]}" if offset else "+00:00"
    return now.strftime("%Y-%m-%dT%H:%M:%S") + offset_formatted


def _looks_like_rate_limit(returncode: int, stderr: str, stdout: str) -> bool:
    """Heuristik: erkennt 429/504-Hinweise in stdout/stderr oder returncode."""
    if returncode == 0:
        return False
    combined = f"{stderr}\n{stdout}".lower()
    for needle in ("429", "504", "rate limit", "too many requests", "gateway timeout"):
        if needle in combined:
            return True
    return False


def _extract_url_from_source_data(source_data: dict) -> str | None:
    """Zieht eine kanonische URL aus dem Fulltext-JSON einer Quelle.

    Strategie:
    1. Direkte Felder `url`, `source_url`, `original_url` prüfen.
    2. Fallback: erste URL in den ersten 500 Zeichen von `fulltext`, `content`
       oder `text` via `extract_urls`.
    3. Kein Treffer: gibt None zurück.

    Rückgabewert ist bei Fallback die normalisierte URL (aus extract_urls),
    bei direkten Feldern die Roh-URL (wird später noch normalisiert).
    """
    for key in ("url", "source_url", "original_url"):
        value = source_data.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()

    for key in ("fulltext", "content", "text"):
        blob = source_data.get(key)
        if isinstance(blob, str) and blob:
            urls = extract_urls(blob[:500])
            if urls:
                return urls[0]["normalized"]
    return None


def _run_fulltext_with_backoff(
    runner: Callable,
    notebook_id: str,
    source_id: str,
    sleep_fn: Callable[[float], None],
    audit_log_path: Path,
) -> tuple[int, str, str] | None:
    """Ruft `source fulltext` mit exponentiellem Backoff bei 429/504 auf.

    Versucht maximal 1 + len(_BACKOFF_STEPS) = 4 Durchläufe. Gibt bei Erfolg
    (returncode, stdout, stderr) zurück, bei endgültigem Versagen None.
    Schreibt bei Aufgabe einen Audit-Log-Eintrag `resync-rate-limit-giveup`.
    """
    cmd = [
        "notebooklm", "source", "fulltext",
        "--notebook", notebook_id,
        source_id,
        "--json",
    ]

    attempts = [0] + list(_BACKOFF_STEPS)  # 0s beim ersten Versuch
    last_stderr = ""
    for wait in attempts:
        if wait > 0:
            sleep_fn(wait)
        try:
            rc, stdout, stderr = runner(cmd, _FULLTEXT_TIMEOUT)
        except Exception as exc:
            last_stderr = f"runner raised: {exc}"
            continue

        if rc == 0:
            return rc, stdout, stderr

        last_stderr = stderr or stdout
        if not _looks_like_rate_limit(rc, stderr, stdout):
            # Kein Rate-Limit → direkt abbrechen, kein Backoff sinnvoll
            break

    audit_append(
        action="resync-rate-limit-giveup",
        notebook_id=notebook_id,
        source_id=source_id,
        url_hash=None,
        status="error",
        message=(
            "Fulltext-Abruf nach Backoff aufgegeben — "
            f"letzte stderr/stdout: {_mask_secrets(last_stderr[:200])}"
        ),
        audit_log_path=audit_log_path,
    )
    return None


def resync_notebook(
    notebook_id: str,
    *,
    state_root: Path,
    audit_log_path: Path,
    subprocess_runner: Callable | None = None,
    sleep_fn: Callable[[float], None] = time.sleep,
) -> dict:
    """Gleicht ein NotebookLM-Notebook mit dem lokalen URL-Hash-Index ab.

    Liest per `notebooklm source list` und `notebooklm source fulltext` alle
    Quellen des Notebooks, extrahiert die kanonische URL pro Quelle und
    vergleicht mit dem Index. Aktualisiert Index und Notebook-Cache lokal,
    schreibt Audit-Log-Summary. Keine Schreibaktionen gegen NotebookLM.

    Parameter:
        notebook_id: volle UUID des Notebooks.
        state_root: Pflicht-Parameter. Verzeichnis mit url-hashes.json und
            notebook-cache.json.
        audit_log_path: Pflicht-Parameter. Wird an audit_log.append weitergereicht.
        subprocess_runner: Dependency-Injection für Tests. Signatur
            runner(cmd, timeout) -> (returncode, stdout, stderr). Default
            `_default_runner` setzt PYTHONIOENCODING=utf-8 (CLI-Vertrags-Pflicht).
        sleep_fn: Dependency-Injection für time.sleep (Tests).

    Rückgabe:
        dict mit notebook_id, match_count, unknown_in_kurator,
        missing_in_notebook, unknown_origin, total_sources.
    """
    state_root = Path(state_root)
    audit_log_path = Path(audit_log_path)
    runner = subprocess_runner or _default_runner
    index_path = state_root / "url-hashes.json"
    cache_path = state_root / "notebook-cache.json"

    # --- 1. source list ---------------------------------------------------
    list_cmd = [
        "notebooklm", "source", "list",
        "--notebook", notebook_id,
        "--json",
    ]
    try:
        rc, stdout, stderr = runner(list_cmd, _LIST_TIMEOUT)
    except Exception as exc:
        audit_append(
            action="resync-list-failed",
            notebook_id=notebook_id,
            source_id=None,
            url_hash=None,
            status="error",
            message=f"source list Aufruf fehlgeschlagen: {exc}",
            audit_log_path=audit_log_path,
        )
        raise RuntimeError(f"source list Aufruf fehlgeschlagen: {exc}") from exc

    if rc != 0:
        audit_append(
            action="resync-list-failed",
            notebook_id=notebook_id,
            source_id=None,
            url_hash=None,
            status="error",
            message=f"returncode={rc} stderr={_mask_secrets(stderr[:200])}",
            audit_log_path=audit_log_path,
        )
        raise RuntimeError(
            f"source list fehlgeschlagen (rc={rc}): {_mask_secrets(stderr[:200])}"
        )

    try:
        parsed = json.loads(stdout)
    except json.JSONDecodeError:
        # Fallback: falls die CLI auch bei list eine Vorzeile schreibt
        try:
            parsed = _parse_fulltext_json(stdout)
        except Exception as exc:
            audit_append(
                action="resync-list-failed",
                notebook_id=notebook_id,
                source_id=None,
                url_hash=None,
                status="error",
                message=f"source list JSON-Parse fehlgeschlagen: {exc}",
                audit_log_path=audit_log_path,
            )
            raise RuntimeError(
                f"source list JSON-Parse fehlgeschlagen: {exc}"
            ) from exc

    # MOCK-API-CONTRACT.md §2: source list --json liefert
    # {"sources": [...], "count": N}, kein nacktes Array.
    if isinstance(parsed, dict) and "sources" in parsed:
        sources = parsed["sources"]
    elif isinstance(parsed, list):
        sources = parsed
    else:
        raise RuntimeError(
            "source list gab weder {sources: [...]} noch ein Array zurück"
        )

    # --- 2. Index laden ---------------------------------------------------
    entries = load_index(index_path)

    # --- 3. Pro Quelle gegen Index matchen (url-Feld bevorzugt) -----------
    # MOCK-API-CONTRACT.md §2: `url`-Feld ist NICHT durchgängig gesetzt
    # (bei älteren Quellen `null`). Fallback: source fulltext + URL aus
    # Fulltext-Anfang extrahieren.
    unknown_in_kurator: list[str] = []
    unknown_origin: list[str] = []
    match_count = 0
    seen_hashes_in_notebook: set[str] = set()
    cache_source_list: list[dict] = []
    # Für den Resync-Report: Details pro Quelle sammeln
    report_entries: list[dict] = []

    first_fulltext = True
    for source in sources:
        if not isinstance(source, dict):
            continue
        source_id = source.get("id") or source.get("source_id")
        title = source.get("title", "")
        if not source_id:
            continue

        cache_source_list.append({"id": source_id, "title": title})

        # url-Feld aus source list bevorzugt nutzen, Fulltext nur als Fallback
        source_list_url = source.get("url")
        raw_url = None
        url_source = None

        if isinstance(source_list_url, str) and source_list_url.strip():
            raw_url = source_list_url.strip()
            url_source = "source-list-url"
        else:
            # Fallback: Fulltext-Parsing
            if not first_fulltext:
                sleep_fn(_FULLTEXT_PAUSE)
            first_fulltext = False

            result = _run_fulltext_with_backoff(
                runner, notebook_id, source_id, sleep_fn, audit_log_path
            )
            if result is None:
                unknown_origin.append(source_id)
                report_entries.append({"id": source_id, "title": title, "status": "unknown_origin", "reason": "fulltext-abruf fehlgeschlagen"})
                continue

            rc_ft, stdout_ft, stderr_ft = result
            try:
                data = _parse_fulltext_json(stdout_ft)
            except Exception as exc:
                audit_append(
                    action="resync-fulltext-parse-failed",
                    notebook_id=notebook_id,
                    source_id=source_id,
                    url_hash=None,
                    status="error",
                    message=f"Parse-Fehler: {exc}",
                    audit_log_path=audit_log_path,
                )
                unknown_origin.append(source_id)
                report_entries.append({"id": source_id, "title": title, "status": "unknown_origin", "reason": f"parse-fehler: {exc}"})
                continue

            raw_url = _extract_url_from_source_data(data)
            url_source = "fulltext-parsing"

        if not raw_url:
            unknown_origin.append(source_id)
            report_entries.append({"id": source_id, "title": title, "status": "unknown_origin", "reason": "keine URL extrahierbar"})
            continue

        try:
            _kind, normalized_url = normalize(raw_url)
        except Exception:
            unknown_origin.append(source_id)
            report_entries.append({"id": source_id, "title": title, "status": "unknown_origin", "reason": "URL-Normalisierung fehlgeschlagen"})
            continue

        url_h = hash_url(normalized_url)
        seen_hashes_in_notebook.add(url_h)

        if url_h in entries:
            match_count += 1
            report_entries.append({"id": source_id, "title": title, "status": "match", "url_source": url_source})
        else:
            unknown_in_kurator.append(source_id)
            entries[url_h] = {
                "source_id": source_id,
                "notebook_id": notebook_id,
                "normalized_url": normalized_url,
                "added_by": "manual-resync",
                "verify_status": "skipped-resync",
                "added_at": _now_iso(),
            }
            report_entries.append({"id": source_id, "title": title, "status": "unknown_in_kurator", "url": normalized_url, "url_source": url_source})

    # --- 4. missing_in_notebook bestimmen ---------------------------------
    missing_in_notebook: list[str] = []
    for url_h, entry in entries.items():
        if entry.get("notebook_id") != notebook_id:
            continue
        if url_h in seen_hashes_in_notebook:
            continue
        # Eintrag gehört zu diesem Notebook, ist aber nicht mehr da
        missing_in_notebook.append(url_h)
        entry["verify_status"] = "missing-after-resync"
        entry["missing_detected_at"] = _now_iso()
        audit_append(
            action="resync-missing",
            notebook_id=notebook_id,
            source_id=entry.get("source_id"),
            url_hash=url_h,
            status="warn",
            message="Quelle im Index markiert, aber im Notebook nicht mehr vorhanden",
            audit_log_path=audit_log_path,
        )

    # --- 5. Index speichern -----------------------------------------------
    save_index(index_path, entries)

    # --- 6. notebook-cache.json atomar schreiben --------------------------
    _save_notebook_cache(cache_path, notebook_id, cache_source_list)

    # --- 7. Audit-Log Summary ---------------------------------------------
    audit_append(
        action="resync-summary",
        notebook_id=notebook_id,
        source_id=None,
        url_hash=None,
        status="ok",
        message=(
            f"neue={len(unknown_in_kurator)} "
            f"missing={len(missing_in_notebook)} "
            f"match={match_count} "
            f"unknown_origin={len(unknown_origin)}"
        ),
        audit_log_path=audit_log_path,
    )

    # --- 8. Resync-Report als Markdown schreiben ---------------------------
    _write_resync_report(
        state_root=state_root,
        notebook_id=notebook_id,
        report_entries=report_entries,
        missing_in_notebook=missing_in_notebook,
        match_count=match_count,
        total_sources=len(cache_source_list),
        entries=entries,
    )

    return {
        "notebook_id": notebook_id,
        "match_count": match_count,
        "unknown_in_kurator": unknown_in_kurator,
        "missing_in_notebook": missing_in_notebook,
        "unknown_origin": unknown_origin,
        "total_sources": len(cache_source_list),
    }


def _write_resync_report(
    *,
    state_root: Path,
    notebook_id: str,
    report_entries: list[dict],
    missing_in_notebook: list[str],
    match_count: int,
    total_sources: int,
    entries: dict,
) -> Path:
    """Schreibt einen lesbaren Resync-Report als Markdown-Datei.

    Pfad: state_root/resync-reports/YYYY-MM-DD.md
    """
    report_dir = state_root / "resync-reports"
    report_dir.mkdir(parents=True, exist_ok=True)

    today = datetime.now().strftime("%Y-%m-%d")
    report_path = report_dir / f"{today}.md"

    new_sources = [e for e in report_entries if e["status"] == "unknown_in_kurator"]
    unknown = [e for e in report_entries if e["status"] == "unknown_origin"]

    lines = [
        f"# Resync-Report {today}",
        "",
        f"**Notebook:** `{notebook_id}`",
        f"**Zeitpunkt:** {_now_iso()}",
        "",
        "## Statistik",
        "",
        "| Kennzahl | Wert |",
        "|---|---|",
        f"| Quellen gesamt | {total_sources} |",
        f"| Treffer (im Index) | {match_count} |",
        f"| Neu aufgenommen | {len(new_sources)} |",
        f"| Fehlend im Notebook | {len(missing_in_notebook)} |",
        f"| Herkunft unbekannt | {len(unknown)} |",
        "",
    ]

    if new_sources:
        lines.append("## Neu in den Index aufgenommen")
        lines.append("")
        lines.append("| Source-ID | Titel | URL | Quelle |")
        lines.append("|---|---|---|---|")
        for e in new_sources:
            sid = e["id"][:12] + "..."
            title = e.get("title", "").replace("|", "\\|")
            url = e.get("url", "-")
            url_src = e.get("url_source", "-")
            lines.append(f"| `{sid}` | {title} | {url} | {url_src} |")
        lines.append("")

    if missing_in_notebook:
        lines.append("## Im Notebook fehlend (im Index markiert)")
        lines.append("")
        lines.append("| URL-Hash | Source-ID | Status |")
        lines.append("|---|---|---|")
        for url_h in missing_in_notebook:
            entry = entries.get(url_h, {})
            sid = (entry.get("source_id") or "-")[:12] + "..."
            lines.append(f"| `{url_h[:16]}...` | `{sid}` | missing-after-resync |")
        lines.append("")

    if unknown:
        lines.append("## Herkunft unbekannt (keine URL extrahierbar)")
        lines.append("")
        lines.append("| Source-ID | Titel | Grund |")
        lines.append("|---|---|---|")
        for e in unknown:
            sid = e["id"][:12] + "..."
            title = e.get("title", "").replace("|", "\\|")
            reason = e.get("reason", "-")
            lines.append(f"| `{sid}` | {title} | {reason} |")
        lines.append("")

    report_path.write_text("\n".join(lines), encoding="utf-8")
    return report_path


def _save_notebook_cache(
    cache_path: Path,
    notebook_id: str,
    sources: list[dict],
) -> None:
    """Schreibt notebook-cache.json atomar (gleiches Muster wie index_io)."""
    import tempfile

    existing: dict = {"schema_version": 1, "cache": {}}
    if cache_path.exists():
        try:
            raw = cache_path.read_text(encoding="utf-8")
            loaded = json.loads(raw)
            if isinstance(loaded, dict):
                existing = loaded
                if "cache" not in existing or not isinstance(existing["cache"], dict):
                    existing["cache"] = {}
                if "schema_version" not in existing:
                    existing["schema_version"] = 1
        except (json.JSONDecodeError, OSError):
            # Korrupte Cache-Datei → frisch starten, aber nicht abstürzen
            existing = {"schema_version": 1, "cache": {}}

    existing["cache"][notebook_id] = {
        "sources": sources,
        "updated_at": _now_iso(),
    }

    serialized = json.dumps(existing, ensure_ascii=False, indent=2)
    dir_ = cache_path.parent
    dir_.mkdir(parents=True, exist_ok=True)
    fd, tmp_path = tempfile.mkstemp(dir=dir_, suffix=".tmp", prefix=".notebook_cache_")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as fh:
            fh.write(serialized)
        os.replace(tmp_path, cache_path)
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
