"""Tests for scanner/ai_analyzer.py — hash-gated AI content analysis."""
import copy
from unittest.mock import patch

import pytest

import time

from scanner.ai_analyzer import (
    run_ai_analysis,
    score_ai_findings,
    _text_fingerprint,
    _models_for,
    _openrouter_chat,
    ModelRouter,
)
from scanner.baseline import BaselineManager
from scanner.config import DEFAULT_CONFIG


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _cfg(**ai_overrides) -> dict:
    cfg = copy.deepcopy(DEFAULT_CONFIG)
    cfg["ai_analysis"]["enabled"] = True
    cfg["ai_analysis"]["site_context"] = "Heimat- und Vereinswebsite über Bergbau"
    cfg["ai_analysis"]["image"]["enabled"] = False  # Text-Tests: Bild aus
    cfg["ai_analysis"]["refresh_models"] = False     # Tests: kein echter /models-Netzcall
    cfg["ai_analysis"].update(ai_overrides)
    return cfg


def _snap(text: str, url: str = "https://x.de/", img: list | None = None) -> dict:
    return {
        "pages": {
            url: {
                "url": url,
                "status": 200,
                "text": text,
                "links": {"img": img or []},
            }
        }
    }


_LONG = "Lorem ipsum dolor sit amet. " * 20  # > 200 Zeichen


def _verdict(category="clean", severity="none", confidence=0.9, explanation="ok"):
    return {"category": category, "severity": severity,
            "confidence": confidence, "explanation": explanation}


# ---------------------------------------------------------------------------
# Gating: disabled / no key / graceful degradation
# ---------------------------------------------------------------------------

class TestGating:
    def test_disabled_skips(self, tmp_path):
        cfg = _cfg()
        cfg["ai_analysis"]["enabled"] = False
        bm = BaselineManager(tmp_path)
        with patch("scanner.ai_analyzer._openrouter_chat") as m:
            res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
        assert res["skipped"] == "deaktiviert"
        m.assert_not_called()

    def test_no_api_key_skips(self, tmp_path, monkeypatch):
        monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
        bm = BaselineManager(tmp_path)
        with patch("scanner.ai_analyzer._openrouter_chat") as m:
            res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
        assert "kein API-Key" in res["skipped"]
        m.assert_not_called()

    def test_api_failure_tracks_unchecked_not_silent_clean(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        monkeypatch.setattr("scanner.ai_analyzer.time.sleep", lambda *_: None)
        bm = BaselineManager(tmp_path)
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=None):
            res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
        assert res["findings"] == []
        assert res["api_calls"] == 0
        # Inhalt bleibt UNGEPRÜFT — darf nicht still als clean durchgehen
        assert len(res["unchecked"]) == 1
        assert res["unchecked"][0]["kind"] == "text"


# ---------------------------------------------------------------------------
# Hash-gate: the core cost promise
# ---------------------------------------------------------------------------

class TestHashGate:
    def test_new_fingerprint_calls_api_once(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()) as m:
            res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
        assert res["api_calls"] == 1
        assert res["cache_hits"] == 0
        assert m.call_count == 1
        assert len(bm.load_ai_ledger()["entries"]) == 1

    def test_second_scan_unchanged_zero_api_calls(self, tmp_path, monkeypatch):
        """Kernversprechen: unveränderter Inhalt → 0 API-Calls beim Folge-Scan."""
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        snap = _snap(_LONG)
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
            run_ai_analysis(_cfg(), bm, snap, {})
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()) as m2:
            res2 = run_ai_analysis(_cfg(), bm, snap, {})
        assert res2["api_calls"] == 0
        assert res2["cache_hits"] == 1
        m2.assert_not_called()

    def test_budget_cap_limits_api_calls(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        pages = {f"https://x.de/p{i}": {"url": f"https://x.de/p{i}", "status": 200,
                                        "text": _LONG + str(i), "links": {"img": []}}
                 for i in range(5)}
        snap = {"pages": pages}
        cfg = _cfg()
        cfg["ai_analysis"]["text"]["max_pages_per_scan"] = 2
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
            res = run_ai_analysis(cfg, bm, snap, {})
        assert res["api_calls"] == 2  # gedeckelt


# ---------------------------------------------------------------------------
# Findings filter: category / severity / confidence / dismissed
# ---------------------------------------------------------------------------

class TestFindingsFilter:
    def test_flagged_text_produces_finding(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        v = _verdict("pornography", "high", 0.95, "explizit")
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
            res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
        assert len(res["findings"]) == 1
        f = res["findings"][0]
        assert f["kind"] == "text" and f["category"] == "pornography"

    def test_clean_produces_no_finding(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict("clean", "none", 0.99)):
            res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
        assert res["findings"] == []

    def test_low_confidence_no_finding(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        v = _verdict("propaganda", "high", 0.5)  # < 0.7
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
            res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
        assert res["findings"] == []

    def test_low_severity_no_finding(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        v = _verdict("off_topic_commercial", "low", 0.95)  # severity < medium
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
            res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
        assert res["findings"] == []

    def test_dismissed_entry_no_finding_and_no_recheck(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        snap = _snap(_LONG)
        v = _verdict("hidden_spam", "high", 0.9)
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
            res1 = run_ai_analysis(_cfg(), bm, snap, {})
        assert len(res1["findings"]) == 1
        assert bm.dismiss_ai_entries(None) == 1
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=v) as m2:
            res2 = run_ai_analysis(_cfg(), bm, snap, {})
        assert res2["findings"] == []      # quittiert → kein Fund mehr
        assert res2["api_calls"] == 0       # Cache-Treffer, kein erneuter Call
        m2.assert_not_called()


# ---------------------------------------------------------------------------
# Image path
# ---------------------------------------------------------------------------

class TestImages:
    def _cfg_img(self):
        cfg = _cfg()
        cfg["ai_analysis"]["text"]["enabled"] = False
        cfg["ai_analysis"]["image"]["enabled"] = True
        return cfg

    def test_flagged_image_produces_finding(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        snap = _snap("kurz", img=[{"url": "https://x.de/bild.jpg", "class": "internal"}])
        v = _verdict("pornography", "high", 0.95, "explizites Bild")
        with patch("scanner.ai_analyzer.fetch_asset_hashes",
                   return_value={"https://x.de/bild.jpg": {"sha256": "deadbeef", "size": 1, "error": None}}), \
             patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
            res = run_ai_analysis(self._cfg_img(), bm, snap, {})
        assert len(res["findings"]) == 1
        assert res["findings"][0]["kind"] == "image"
        assert res["findings"][0]["asset_url"] == "https://x.de/bild.jpg"

    def test_image_coverage_builds_over_scans(self, tmp_path, monkeypatch):
        """Bilder jenseits des Budgets dürfen kein blinder Fleck sein:
        jeder Scan deckt weitere, noch nie analysierte Bilder ab."""
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        cfg = self._cfg_img()
        cfg["ai_analysis"]["image"]["max_images_per_scan"] = 2
        # 5 Bilder auf einer Seite, Budget 2 → volle Abdeckung über 3 Scans
        imgs = [{"url": f"https://x.de/img{i}.jpg", "class": "internal"} for i in range(5)]
        snap = _snap("kurz", img=imgs)
        hashes = {f"https://x.de/img{i}.jpg": {"sha256": f"h{i}", "size": 1, "error": None}
                  for i in range(5)}

        analysed = set()
        for _ in range(3):  # drei aufeinanderfolgende Scans
            with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=hashes), \
                 patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
                run_ai_analysis(cfg, bm, snap, {})
            analysed = {e["url"] for e in bm.load_ai_ledger()["entries"].values()
                        if e.get("kind") == "image"}
        assert len(analysed) == 5  # nach 3 Scans sind alle 5 Bilder abgedeckt

    def test_duplicate_image_urls_fetched_once_analyzed_once(self, tmp_path, monkeypatch):
        """Gleiches Bild an mehreren URLs: nur EINE Analyse; danach kein erneuter Fetch
        (Byte-Hash gemerkt und geprüft)."""
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        cfg = self._cfg_img()
        imgs = [{"url": "https://x.de/a.jpg", "class": "internal"},
                {"url": "https://x.de/b.jpg", "class": "internal"}]
        snap = _snap("kurz", img=imgs)
        same = {"https://x.de/a.jpg": {"sha256": "DUP", "size": 1, "error": None},
                "https://x.de/b.jpg": {"sha256": "DUP", "size": 1, "error": None}}
        with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=same), \
             patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
            res1 = run_ai_analysis(cfg, bm, snap, {})
        assert res1["api_calls"] == 1                 # gleiche Bytes → nur EINE Analyse
        led = bm.load_ai_ledger()
        assert set(led["url_hashes"]) == {"https://x.de/a.jpg", "https://x.de/b.jpg"}

        # zweiter Scan: beide URLs bekannt → gar kein Fetch, keine Analyse mehr
        with patch("scanner.ai_analyzer.fetch_asset_hashes") as fetch2, \
             patch("scanner.ai_analyzer._openrouter_chat") as chat2:
            res2 = run_ai_analysis(cfg, bm, snap, {})
        fetch2.assert_not_called()
        chat2.assert_not_called()
        assert res2["api_calls"] == 0

    def test_image_backlog_unlimited_covers_all_in_one_scan(self, tmp_path, monkeypatch):
        """max_images_per_scan=0 → voller Bildbestand in EINEM Scan (Default)."""
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        cfg = self._cfg_img()
        cfg["ai_analysis"]["image"]["max_images_per_scan"] = 0  # unbegrenzt
        imgs = [{"url": f"https://x.de/img{i}.jpg", "class": "internal"} for i in range(7)]
        hashes = {f"https://x.de/img{i}.jpg": {"sha256": f"h{i}", "size": 1, "error": None}
                  for i in range(7)}
        with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=hashes), \
             patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
            run_ai_analysis(cfg, bm, _snap("kurz", img=imgs), {})
        imgcount = sum(1 for e in bm.load_ai_ledger()["entries"].values()
                       if e.get("kind") == "image")
        assert imgcount == 7

    def test_image_cache_hit_second_scan(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        snap = _snap("kurz", img=[{"url": "https://x.de/bild.jpg", "class": "internal"}])
        hashes = {"https://x.de/bild.jpg": {"sha256": "deadbeef", "size": 1, "error": None}}
        with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=hashes), \
             patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
            run_ai_analysis(self._cfg_img(), bm, snap, {})
        with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=hashes), \
             patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()) as m2:
            res2 = run_ai_analysis(self._cfg_img(), bm, snap, {})
        assert res2["api_calls"] == 0
        m2.assert_not_called()


# ---------------------------------------------------------------------------
# check-assets ↔ KI-Kopplung: geänderte Bilder werden neu bewertet
# ---------------------------------------------------------------------------

class TestAssetAiCoupling:
    def _cfg_img(self):
        cfg = _cfg()
        cfg["ai_analysis"]["text"]["enabled"] = False
        cfg["ai_analysis"]["image"]["enabled"] = True
        return cfg

    def test_invalidate_removes_only_given_urls(self, tmp_path):
        bm = BaselineManager(tmp_path)
        bm.save_ai_ledger({"entries": {}, "url_hashes": {"a": "h1", "b": "h2", "c": "h3"}})
        assert bm.invalidate_ai_url_hashes(["b", "x"]) == 1   # x ist nicht vorhanden
        assert set(bm.load_ai_ledger()["url_hashes"]) == {"a", "c"}

    def test_changed_image_reanalyzed_after_invalidation(self, tmp_path, monkeypatch):
        """Byte-Tausch eines bekannten Bildes → invalidieren → KI lädt+bewertet neu."""
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        cfg = self._cfg_img()
        snap = _snap("kurz", img=[{"url": "https://x.de/logo.png", "class": "internal"}])

        with patch("scanner.ai_analyzer.fetch_asset_hashes",
                   return_value={"https://x.de/logo.png": {"sha256": "OLD", "size": 1, "error": None}}), \
             patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
            run_ai_analysis(cfg, bm, snap, {})

        # check-assets erkennt Byte-Änderung → Kopplung invalidiert die URL
        assert bm.invalidate_ai_url_hashes(["https://x.de/logo.png"]) == 1

        new = _verdict("pornography", "high", 0.95)
        with patch("scanner.ai_analyzer.fetch_asset_hashes",
                   return_value={"https://x.de/logo.png": {"sha256": "NEW", "size": 1, "error": None}}) as f2, \
             patch("scanner.ai_analyzer._openrouter_chat", return_value=new) as c2:
            res = run_ai_analysis(cfg, bm, snap, {})
        f2.assert_called()                 # neu geladen (URL war invalidiert)
        c2.assert_called()                 # neue Bytes (NEW) → Cache-Miss → neu analysiert
        assert res["api_calls"] == 1
        assert len(res["findings"]) == 1   # jetzt beanstandet


# ---------------------------------------------------------------------------
# Scoring: cap at yellow
# ---------------------------------------------------------------------------

class TestScoring:
    def test_empty_is_green(self):
        out = score_ai_findings({"findings": []}, _cfg())
        assert out["level"] == "green" and out["score"] == 0

    def test_single_flag_is_yellow(self):
        findings = [{"kind": "text", "url": "u", "category": "propaganda",
                     "severity": "high", "confidence": 0.9}]
        out = score_ai_findings({"findings": findings}, _cfg())
        assert out["level"] == "yellow"

    def test_score_sum_alone_never_red(self):
        # Zwei nicht-gravierende Funde → Summe ≥ rot-Schwelle, aber Punktsumme macht nie Rot.
        findings = [
            {"kind": "text", "url": "u1", "category": "off_topic_commercial", "severity": "high", "confidence": 0.95},
            {"kind": "text", "url": "u2", "category": "off_topic_commercial", "severity": "high", "confidence": 0.95},
        ]
        out = score_ai_findings({"findings": findings}, _cfg())
        assert out["score"] >= 60          # Punktsumme über rot-Schwelle
        assert out["level"] == "yellow"    # trotzdem nur gelb (kein Grave-Override)
        assert out["exit_code"] == 1

    def test_image_gets_at_least_suspicious_image_points(self):
        # off_topic_commercial=30, aber Bild-Untergrenze ai_suspicious_image=40
        findings = [{"kind": "image", "url": "u", "category": "off_topic_commercial",
                     "severity": "medium", "confidence": 0.8}]
        out = score_ai_findings({"findings": findings}, _cfg())
        assert out["score"] == 40

    def test_unchecked_level_yellow_raises_from_green(self):
        cfg = _cfg()
        cfg["ai_analysis"]["unchecked_level"] = "yellow"
        out = score_ai_findings(
            {"findings": [], "unchecked": [{"kind": "text", "url": "u"}]}, cfg)
        assert out["level"] == "yellow"
        assert out["exit_code"] == 1

    def test_unchecked_level_warn_stays_green_but_notes_reason(self):
        cfg = _cfg()  # default "warn"
        out = score_ai_findings(
            {"findings": [], "unchecked": [{"kind": "text", "url": "u"}]}, cfg)
        assert out["level"] == "green"
        assert any("nicht geprüft" in r for r in out["reasons"])


class TestRedGate:
    def _find(self, cat, sev, conf, kind="text"):
        return {"kind": kind, "url": "u", "category": cat, "severity": sev, "confidence": conf}

    def test_pornography_high_conf_is_red(self):
        out = score_ai_findings({"findings": [self._find("pornography", "high", 0.95)]}, _cfg())
        assert out["level"] == "red"
        assert out["exit_code"] == 2

    def test_defamation_illegal_high_conf_is_red(self):
        out = score_ai_findings({"findings": [self._find("defamation_illegal", "high", 0.95)]}, _cfg())
        assert out["level"] == "red"

    def test_pornography_image_is_red(self):
        out = score_ai_findings(
            {"findings": [self._find("pornography", "high", 0.95, kind="image")]}, _cfg())
        assert out["level"] == "red"

    def test_severity_below_high_stays_yellow(self):
        out = score_ai_findings({"findings": [self._find("pornography", "medium", 0.95)]}, _cfg())
        assert out["level"] == "yellow"

    def test_confidence_below_threshold_stays_yellow(self):
        out = score_ai_findings({"findings": [self._find("pornography", "high", 0.8)]}, _cfg())
        assert out["level"] == "yellow"

    def test_non_grave_category_stays_yellow(self):
        out = score_ai_findings({"findings": [self._find("off_topic_commercial", "high", 1.0)]}, _cfg())
        assert out["level"] == "yellow"

    def test_empty_red_categories_restores_old_capping(self):
        cfg = _cfg()
        cfg["ai_analysis"]["red_categories"] = []
        out = score_ai_findings({"findings": [self._find("pornography", "high", 1.0)]}, cfg)
        assert out["level"] == "yellow"


# ---------------------------------------------------------------------------
# Adaptive model router: circuit-breaker + latency ordering + /models pruning
# ---------------------------------------------------------------------------

def _router(catalog_slugs=None):
    cfg = {"breaker_failure_threshold": 2, "breaker_cooldown_seconds": 120, "refresh_models": False}
    state = {"models": {}, "catalog": {"slugs": catalog_slugs or [], "fetched_at": None}}
    return ModelRouter(cfg, state)


class TestModelRouter:
    def test_failure_below_threshold_keeps_model_healthy(self):
        r = _router()
        r.record_failure("m")
        assert r.order(["m", "n"])[0] in ("m", "n")  # nicht hart aussortiert

    def test_breaker_opens_after_threshold_and_sorts_last(self):
        r = _router()
        r.record_failure("bad"); r.record_failure("bad")  # erreicht threshold 2
        assert r.order(["bad", "good"]) == ["good", "bad"]

    def test_success_records_latency_faster_first(self):
        r = _router()
        r.record_success("slow", 2.0)
        r.record_success("fast", 0.3)
        assert r.order(["slow", "fast"]) == ["fast", "slow"]

    def test_success_resets_open_breaker(self):
        r = _router()
        r.record_failure("m"); r.record_failure("m")
        r.record_success("m", 0.5)
        assert r.order(["m", "n"])[0] == "m"  # wieder gesund (Latenz 0.5 < unbekannt? egal: nicht open)

    def test_cooldown_expiry_makes_model_healthy_again(self):
        r = _router()
        r.record_failure("m"); r.record_failure("m")
        r._models["m"]["open_until"] = time.time() - 1  # Cooldown abgelaufen
        assert r.order(["m", "n"])[-1] != "m" or r.order(["m"]) == ["m"]

    def test_prune_drops_dead_slug(self):
        r = _router(catalog_slugs=["alive"])
        assert r.order(["alive", "dead-404"]) == ["alive"]

    def test_prune_failsafe_without_catalog(self):
        r = _router(catalog_slugs=[])  # kein Katalog → nicht filtern
        assert set(r.order(["a", "b"])) == {"a", "b"}

    def test_order_never_empty_even_if_all_pruned(self):
        r = _router(catalog_slugs=["other"])
        assert r.order(["x", "y"])  # keiner im Katalog → trotzdem nicht leer

    def test_refresh_catalog_failsafe_on_network_error(self):
        r = _router()
        with patch("scanner.ai_analyzer.requests.get", side_effect=Exception("net down")):
            r.refresh_catalog()  # darf nicht werfen
        assert r.order(["a"]) == ["a"]


# ---------------------------------------------------------------------------
# Parallel analysis correctness
# ---------------------------------------------------------------------------

class TestParallel:
    def test_all_pages_classified_in_parallel(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        pages = {f"https://x.de/p{i}": {"url": f"https://x.de/p{i}", "status": 200,
                                        "text": _LONG + str(i), "links": {"img": []}}
                 for i in range(6)}
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
            res = run_ai_analysis(_cfg(), bm, {"pages": pages}, {})
        assert res["api_calls"] == 6
        assert len(bm.load_ai_ledger()["entries"]) == 6
        assert res["skipped"] is None

    def test_duplicate_text_classified_once_findings_for_each_url(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        same = _LONG + " identisch"
        pages = {
            "https://x.de/a": {"url": "https://x.de/a", "status": 200, "text": same, "links": {"img": []}},
            "https://x.de/b": {"url": "https://x.de/b", "status": 200, "text": same, "links": {"img": []}},
        }
        v = _verdict("hidden_spam", "high", 0.9)
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
            res = run_ai_analysis(_cfg(), bm, {"pages": pages}, {})
        assert res["api_calls"] == 1                      # identischer Text → nur 1 Call
        assert len(res["findings"]) == 2                  # aber Fund für beide URLs
        assert {f["url"] for f in res["findings"]} == {"https://x.de/a", "https://x.de/b"}

    def test_description_stored_in_ledger(self, tmp_path, monkeypatch):
        """Die neutrale Inhaltsangabe aus dem Verdikt landet im Inventar."""
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        v = {"description": "Eine Seite über die Dorfgeschichte.", "category": "clean",
             "severity": "none", "confidence": 0.9, "explanation": "ok"}
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
            run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
        entry = next(iter(bm.load_ai_ledger()["entries"].values()))
        assert entry["description"] == "Eine Seite über die Dorfgeschichte."

    def test_changed_page_always_analyzed_despite_backlog_limit(self, tmp_path, monkeypatch):
        """Geänderte Seiten werden IMMER geprüft — die Bestands-Drossel gilt nicht für sie."""
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        cfg = _cfg()
        cfg["ai_analysis"]["text"]["max_pages_per_scan"] = 1  # strenge Bestands-Drossel
        pages = {f"https://x.de/p{i}": {"url": f"https://x.de/p{i}", "status": 200,
                                        "text": _LONG + str(i), "links": {"img": []}}
                 for i in range(5)}
        diff = {"new_internal_urls": ["https://x.de/p3"], "page_diffs": []}
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
            run_ai_analysis(cfg, bm, {"pages": pages}, diff)
        urls = {e["url"] for e in bm.load_ai_ledger()["entries"].values()}
        assert "https://x.de/p3" in urls   # geänderte Seite trotz Drossel=1 geprüft

    def test_findings_sorted_deterministically(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        pages = {f"https://x.de/{c}": {"url": f"https://x.de/{c}", "status": 200,
                                       "text": _LONG + c, "links": {"img": []}}
                 for c in "cab"}
        v = _verdict("off_topic_commercial", "high", 0.9)
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
            res = run_ai_analysis(_cfg(), bm, {"pages": pages}, {})
        urls = [f["url"] for f in res["findings"]]
        assert urls == sorted(urls)  # deterministisch nach URL sortiert


# ---------------------------------------------------------------------------
# Robustness: hard wall-clock deadline + resilient ledger save
# ---------------------------------------------------------------------------

class TestRobustness:
    def test_hard_deadline_bounds_a_hanging_call(self):
        """Ein hängender requests.post wird durch den Wanduhr-Deadline gekappt."""
        ai_cfg = {"attempt_timeout": 0.2}

        def slow_post(*a, **k):
            time.sleep(3)  # simuliert ein hängendes/tröpfelndes Modell

        start = time.time()
        with patch("scanner.ai_analyzer.requests.post", side_effect=slow_post):
            out = _openrouter_chat("m", [{"role": "user", "content": "x"}], ai_cfg, "key", timeout=0.2)
        elapsed = time.time() - start
        assert out is None              # graceful → None → Eskalation
        assert elapsed < 2.0            # NICHT die vollen 3s — Deadline hat gegriffen

    def test_text_verdicts_survive_image_crash(self, tmp_path, monkeypatch):
        """Textphase wird zwischengesichert → ein Crash der Bildphase verliert sie nicht."""
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        cfg = _cfg()
        cfg["ai_analysis"]["image"]["enabled"] = True
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()), \
             patch("scanner.ai_analyzer._analyze_images", side_effect=RuntimeError("boom")):
            res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
        # Bildphase krachte, aber das Text-Verdikt liegt persistent im Ledger
        assert res["skipped"] and "interner Fehler" in res["skipped"]
        assert len(bm.load_ai_ledger()["entries"]) == 1


# ---------------------------------------------------------------------------
# Model escalation chain
# ---------------------------------------------------------------------------

class TestEscalation:
    def test_models_for_list(self):
        assert _models_for({"models": ["a", "b", "c"]}) == ["a", "b", "c"]

    def test_models_for_backward_compat_single(self):
        assert _models_for({"model": "x"}) == ["x"]

    def test_models_for_empty(self):
        assert _models_for({}) == []

    def test_escalates_to_second_model_on_failure(self, tmp_path, monkeypatch):
        """Stufe 1 scheitert → Stufe 2 (free) liefert das Verdikt."""
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        cfg = _cfg()
        cfg["ai_analysis"]["text"]["models"] = ["free-1", "free-2", "paid-3"]
        v = _verdict("propaganda", "high", 0.9)
        with patch("scanner.ai_analyzer._openrouter_chat", side_effect=[None, v]) as m:
            res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
        assert res["api_calls"] == 1
        assert len(res["findings"]) == 1
        assert m.call_count == 2  # erste Stufe scheiterte, zweite griff
        # Ledger merkt sich das tatsächlich erfolgreiche Modell
        entry = next(iter(bm.load_ai_ledger()["entries"].values()))
        assert entry["model"] == "free-2"

    def test_escalates_to_paid_third_stage(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        bm = BaselineManager(tmp_path)
        cfg = _cfg()
        cfg["ai_analysis"]["text"]["models"] = ["free-1", "free-2", "paid-3"]
        v = _verdict("clean", "none", 0.9)
        with patch("scanner.ai_analyzer._openrouter_chat", side_effect=[None, None, v]) as m:
            res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
        assert m.call_count == 3
        entry = next(iter(bm.load_ai_ledger()["entries"].values()))
        assert entry["model"] == "paid-3"

    def test_all_stages_fail_tracks_unchecked(self, tmp_path, monkeypatch):
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        monkeypatch.setattr("scanner.ai_analyzer.time.sleep", lambda *_: None)
        bm = BaselineManager(tmp_path)
        cfg = _cfg()
        cfg["ai_analysis"]["text"]["models"] = ["free-1", "free-2", "paid-3"]
        with patch("scanner.ai_analyzer._openrouter_chat", return_value=None):
            res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
        assert res["findings"] == []
        assert res["api_calls"] == 0
        assert len(res["unchecked"]) == 1            # ungeprüft erfasst
        assert bm.load_ai_ledger()["entries"] == {}  # nichts gespeichert

    def test_retry_recovers_after_full_chain_failure(self, tmp_path, monkeypatch):
        """Erster Durchlauf der Kette scheitert komplett → Wiederholung rettet das Verdikt."""
        monkeypatch.setenv("OPENROUTER_API_KEY", "test")
        monkeypatch.setattr("scanner.ai_analyzer.time.sleep", lambda *_: None)
        bm = BaselineManager(tmp_path)
        cfg = _cfg()
        cfg["ai_analysis"]["text"]["models"] = ["m1", "m2"]
        cfg["ai_analysis"]["max_retries"] = 1
        v = _verdict("clean", "none", 0.9)
        # Durchlauf 1: m1,m2 → None,None. Wiederholung: m1 → verdict.
        with patch("scanner.ai_analyzer._openrouter_chat", side_effect=[None, None, v]) as m:
            res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
        assert m.call_count == 3
        assert res["unchecked"] == []
        assert res["api_calls"] == 1


# ---------------------------------------------------------------------------
# Fingerprint stability
# ---------------------------------------------------------------------------

class TestFingerprint:
    def test_ignore_pattern_keeps_fingerprint_stable(self):
        cfg = _cfg()
        cfg["normalization"]["ignore_patterns"] = [r"Kommentar oder Nachricht \* \w+"]
        a = "Hallo Welt. Kommentar oder Nachricht * Comment Ende."
        b = "Hallo Welt. Kommentar oder Nachricht * Name Ende."
        assert _text_fingerprint(a, cfg) == _text_fingerprint(b, cfg)

    def test_different_text_different_fingerprint(self):
        cfg = _cfg()
        assert _text_fingerprint("abc", cfg) != _text_fingerprint("xyz", cfg)
