"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding."""

import json

import qa


CORPUS = {"Skript.txt": (
    "Kapitel 1: Vertex Cover — Definition, Approximation und Beweis der Guete.\n\n"
    "Kapitel 2: Matching in Graphen — perfektes Matching und Augmentationswege.")}
BLOCKS = [
    {"title": "Vertex Cover", "description": "Knotenüberdeckung", "sources": ["Skript.txt"]},
    {"title": "Matching", "description": "Paarung in Graphen", "sources": ["Skript.txt"]},
]
SUBS = {"vertex cover": ["Approximation der Guete"], "matching": ["Matching in Graphen", "Augmentationswege"]}


def test_baseline_clean(monkeypatch):
    """Sauberes Soll-Inventar → alle Detektoren still (jeder Absatz ein Abschnitt)."""
    monkeypatch.setattr(qa, "SECTION_CHARS", 20)
    assert qa.dubletten(BLOCKS, emb_on=False) == []
    assert qa.luecken(BLOCKS, SUBS, CORPUS) == []
    assert qa.fremd(BLOCKS, CORPUS) == []
    assert qa.hygiene(BLOCKS) == []


def test_injected_duplicate_found():
    b = BLOCKS + [{"title": "Vertex-Cover-Problem", "description": "", "sources": []}]
    pairs = qa.dubletten(b, emb_on=False)
    assert any({p["a"], p["b"]} == {"Vertex Cover", "Vertex-Cover-Problem"} for p in pairs)


def test_acronym_signal():
    b = BLOCKS + [{"title": "VC (Vertex Cover)", "description": "", "sources": []}]
    pairs = qa.dubletten(b, emb_on=False)
    hit = next(p for p in pairs if "VC (Vertex Cover)" in (p["a"], p["b"]) and "Vertex Cover" in (p["a"], p["b"]))
    assert hit["signale"].get("akronym") is True


def test_relation_operand_not_suspicious():
    """Relation vs. Operand ist per Design getrennt — kein Verdachtspaar."""
    b = BLOCKS + [{"title": "3-SAT ≤ Vertex Cover", "description": "", "sources": []}]
    pairs = qa.dubletten(b, emb_on=False)
    assert not any("≤" in p["a"] + p["b"] for p in pairs)


def test_removed_block_creates_gap(monkeypatch):
    monkeypatch.setattr(qa, "SECTION_CHARS", 20)
    only_vc = [BLOCKS[0]]
    gaps = qa.luecken(only_vc, {"vertex cover": SUBS["vertex cover"]}, CORPUS)
    assert len(gaps) == 1 and "Matching" in gaps[0]["vorschau"]


def test_foreign_block_flagged():
    b = BLOCKS + [{"title": "Quantencomputer Grundlagen", "description": "", "sources": []}]
    assert qa.fremd(b, CORPUS) == ["Quantencomputer Grundlagen"]


def test_beleg_flags_unbacked_sub():
    rows = [{"block": "Matching", "sub_title": "Erfunden", "mentions": 0, "status": "consensus"},
            {"block": "Matching", "sub_title": "Belegt", "mentions": 3, "status": "consensus"}]
    r = qa.beleg([{"title": "Matching", "sources": []}], rows)
    assert r["subs_ohne_beleg"] == ["Matching · Erfunden"]
    assert r["bloecke_ohne_quelle"] == ["Matching"]


def test_hygiene_flags():
    b = [{"title": "**Fett**", "description": "", "sources": []},
         {"title": "Block (2)", "description": "ok", "sources": []}]
    h = {x["titel"]: x["probleme"] for x in qa.hygiene(b)}
    assert "markdown" in h["**Fett**"] and "leere-beschreibung" in h["**Fett**"]
    assert h["Block (2)"] == ["kollisions-suffix"]


def test_sections_split_on_paragraphs():
    secs = qa._sections("a\n\nb\n\nc", goal=3)
    assert len(secs) >= 2 and "".join(secs).replace("\n", "") == "abc"


def test_note_deterministic_and_monotonic():
    """Saubere Quoten → 10; jede zusätzliche Quote drückt die Note."""
    sauber = {k: 0 for k in qa.NOTE_GEWICHTE}
    assert qa.note(sauber) == 10.0
    schlechter = dict(sauber, luecken=0.05)
    noch_schlechter = dict(schlechter, fremd=0.05)
    assert 10.0 > qa.note(schlechter) > qa.note(noch_schlechter) >= 0.0
    assert qa.note({k: 1 for k in qa.NOTE_GEWICHTE}) == 0.0


def test_note_kalibrierung():
    """Gewicht = Punktabzug bei 100 %: 5 % Fremd × 2.5 → −1.25 → 8.8 gerundet."""
    assert qa.note({"fremd": 0.05}) == 8.8
    assert qa.note({"fremd": 1.0}) == 0.0  # komplett fremdes Inventar = 0, nicht 7.7


def test_note_dubletten_verdacht_zaehlt():
    """dubletten_verdacht bildet das Dopplungs-Nutzerproblem ab → zählt jetzt (Gewicht 1.0),
    bleibt aber das leichteste Gewicht. 36 % Verdacht → −3.6 → 6.4; die Liste kann >1 sein → geklemmt."""
    assert qa.note({"dubletten_verdacht": 0.0}) == 10.0
    assert qa.note({"dubletten_verdacht": 0.36}) == 6.4
    assert qa.note({"dubletten_verdacht": 2.0}) == 0.0  # >1 wird auf 1.0 geklemmt

    # sub_dubletten_verdacht bleibt gewichtslos (Artefakt-Verdacht, kein Urteil)
    assert qa.note({"sub_dubletten_verdacht": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 10.0


def test_note_konzept_luecken_zaehlt():
    """Fehlende benannte Kernresultate drücken die Note (Gewicht 1.5): 20 % → −3.0 → 7.0."""
    assert qa.note({"konzept_luecken": 0.0}) == 10.0
    assert qa.note({"konzept_luecken": 0.2}) == 7.0


def test_named_results_only_numbered(monkeypatch):
    """Benannte Ergebnisse: nur NUMMERIERTE Katalog-Referenzen mit Namen zählen — Prosa ohne
    Nummer ('Satz von Goethe') und nackte Nummern ('Satz 7.18') liefern nichts (generisch)."""
    corpus = {"skript.txt":
              "Satz 7.13 (Christofides) liefert eine 3/2-Approximation.\n"
              "Satz 6.24: Satz von Cook und Levin.\n"
              "Nach Satz 7.18 folgt daraus die Schranke.\n"
              "Ein Satz von Goethe steht hier."}
    named = qa._named_results(corpus)
    assert "Christofides" in named
    assert any("Cook" in n and "Levin" in n for n in named)
    assert not any("Goethe" in n for n in named)      # keine Nummer → kein False Positive
    assert all(n.strip() for n in named)


def test_konzept_luecken_flags_missing():
    """Benanntes Kernresultat ohne Baustein = Lücke; ein gedecktes Resultat nicht."""
    corpus = {"s.txt": "Satz 7.13 (Christofides). Satz 6.16 (Kriterium für P=NP)."}
    named = qa._named_results(corpus)
    blocks = [{"title": "Christofides-Algorithmus", "description": "3/2-Approximation für TSP"}]
    gaps = qa.konzept_luecken(blocks, named)
    assert "Christofides" not in gaps
    assert any("P=NP" in g or "Kriterium" in g for g in gaps)


def test_konzept_luecken_declension_tolerant():
    """Andere Flexion im Baustein deckt das Resultat trotzdem — kein falscher Lücken-Alarm."""
    named = qa._named_results({"s.txt": "Satz 7.6: Kriterium für Eulerschen Kreis."})
    blocks = [{"title": "Kriterium für Eulerscher Kreis", "description": "Grad aller Knoten gerade"}]
    assert qa.konzept_luecken(blocks, named) == []


def test_note_artefakte_getrennt():
    """Subs/Artefakte haben eigene Gewichte — zur Gate-Zeit existieren sie noch nicht
    und dürfen die Inventar-Note weder schönen noch drücken."""
    assert "subs_ohne_beleg" not in qa.NOTE_GEWICHTE
    assert qa.note({"subs_ohne_beleg": 0.0, "verwaiste": 0.1}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 9.0
    assert qa.note({"subs_ohne_beleg": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 0.0


def test_artefakte_coverage_and_orphans():
    subs = [{"block_norm": "b", "sub_norm": "s1: lange beschreibung", "status": "consensus"},
            {"block_norm": "b", "sub_norm": "s2", "status": "consensus"},
            {"block_norm": "b", "sub_norm": "alt", "status": "variant"},
            {"block_norm": "b", "sub_norm": "weg", "status": "discarded"}]
    arts = [{"block_norm": "b", "sub_norm": "s1", "type": "flashcard"},   # Präfix-Treffer
            {"block_norm": "b", "sub_norm": "alt", "type": "flashcard"},  # variant → lebt, keine Waise
            {"block_norm": "b", "sub_norm": "weg", "type": "flashcard"},  # verworfen → Waise
            {"block_norm": "b", "sub_norm": "tot", "type": "flashcard"}]  # fehlt → Waise
    fragen = [{"block_norm": "b", "sub_norm": "s1: lange beschreibung"},
              {"block_norm": "b", "sub_norm": "s2"}]
    r = qa.artefakte(subs, arts, fragen)
    assert r["frage_abdeckung"] == 1.0
    assert r["flashcard_abdeckung"] == 0.5  # nur s1 der beiden consensus-Subs
    assert r["verwaiste"] == ["flashcard: b · tot", "flashcard: b · weg"]


def test_artefakte_prefix_family_resolves_to_consensus():
    """Kurz-Key trifft consensus-Sub PLUS gefaltete Varianten mit gleichem Präfix —
    das ist keine Waise, das Ziel ist der consensus-Sub."""
    subs = [{"block_norm": "b", "sub_norm": "auto: echte fassung", "status": "consensus"},
            {"block_norm": "b", "sub_norm": "auto: variante eins", "status": "variant"},
            {"block_norm": "b", "sub_norm": "auto: variante zwei", "status": "variant"}]
    arts = [{"block_norm": "b", "sub_norm": "auto", "type": "example"}]
    r = qa.artefakte(subs, arts, [])
    assert r["verwaiste"] == []
    assert r["beispiel_abdeckung"] == 1.0


def test_artefakte_not_generated():
    assert qa.artefakte([{"block_norm": "b", "sub_norm": "s", "status": "consensus"}], [], []) == {"status": "nicht generiert"}


def test_fremd_glued_prefix_not_whitewashed():
    """'αÜbergang' darf nicht über den Substring 'bergang'⊂'Übergang' als belegt gelten;
    Symbol-Varianten (Δ/∆) bleiben über die ASCII-Form gedeckt."""
    corpus = {"S.txt": "Der Übergang ist wichtig.\n\nDer ∆TSP1 Algorithmus folgt."}
    b = [{"title": "αÜbergang", "description": "", "sources": []},
         {"title": "ΔTSP1-Algorithmus", "description": "", "sources": []}]
    assert qa.fremd(b, corpus) == ["αÜbergang"]


def test_note_ignores_unmeasured_quotes():
    """unechte_bloecke zählt nur, wenn gemessen (--llm) — sonst weder Schaden noch Schönung."""
    ohne = {k: 0.02 for k in qa.NOTE_GEWICHTE if k != "unechte_bloecke"}
    mit_null = dict(ohne, unechte_bloecke=0.0)
    mit_schaden = dict(ohne, unechte_bloecke=0.5)
    assert qa.note(mit_null) == qa.note(ohne)
    assert qa.note(mit_schaden) < qa.note(ohne)


class _FakeEmb:
    """Gleicher Text → gleicher Einheitsvektor, sonst orthogonal (cos 1.0 / 0.0)."""

    @staticmethod
    def available():
        return True

    @staticmethod
    def embed(texts):
        import numpy as np
        uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))}
        arr = np.zeros((len(texts), max(len(uniq), 1)))
        for r, t in enumerate(texts):
            arr[r, uniq[t]] = 1.0
        return arr


def test_sub_dubletten_detector(monkeypatch):
    """Kandidaten in-block UND cross-block; nur consensus-Subs zählen."""
    monkeypatch.setattr(qa, "embedding", _FakeEmb)
    rows = [{"block": "Alpha", "block_norm": "alpha", "sub_title": "Gleiche Aussage", "status": "consensus"},
            {"block": "Beta", "block_norm": "beta", "sub_title": "Gleiche Aussage", "status": "consensus"},
            {"block": "Beta", "block_norm": "beta", "sub_title": "Andere Aussage", "status": "consensus"},
            {"block": "Beta", "block_norm": "beta", "sub_title": "Gleiche Aussage", "status": "variant"}]
    pairs = qa.sub_dubletten(rows)
    assert len(pairs) == 1
    assert pairs[0]["cross"] is True and pairs[0]["cos"] == 1.0
    assert qa.sub_dubletten(rows, emb_on=False) == []


def test_note_sub_dubletten():
    """Bestätigte Sub-Dubletten drücken die Artefakt-Note; der bloße Verdacht nicht."""
    assert qa.note({"sub_dubletten": 0.1}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 9.0
    assert qa.note({"sub_dubletten_verdacht": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 10.0


def test_zaehlbare_luecken():
    """Mit LLM zählen widerlegte Lücken nicht; unbeurteilte ('?'/ohne Key) konservativ schon."""
    lk = [{"llm": "ja"}, {"llm": "nein"}, {"llm": "?"}, {}]
    assert len(qa._zaehlbare_luecken(lk, llm=True)) == 3
    assert len(qa._zaehlbare_luecken(lk, llm=False)) == 4


def test_description_anchors_cover(monkeypatch):
    """Am Gate existieren keine Subs — Beschreibungs-Tokens müssen Abschnitte decken."""
    monkeypatch.setattr(qa, "SECTION_CHARS", 20)
    corpus = {"S.txt": "Kapitel 9: Augmentationswege und perfektes Matching."}
    block = [{"title": "Paarungen", "description": "perfektes Matching mit Augmentationswege", "sources": []}]
    assert qa.luecken(block, {}, corpus) == []
    ohne = [{"title": "Paarungen", "description": "", "sources": []}]
    assert len(qa.luecken(ohne, {}, corpus)) == 1


def test_fremd_digit_suffix_tolerant():
    """'ΔTSP1' matcht Korpus-'∆TSP' (tokenisiert zu 'tsp') via Ziffern-Suffix-Fallback."""
    corpus = {"S.txt": "Der ∆TSP Algorithmus verdoppelt Kanten im Graphen."}
    b = [{"title": "ΔTSP1-Algorithmus", "description": "", "sources": []},
         {"title": "Quantencomputer", "description": "", "sources": []}]
    assert qa.fremd(b, corpus) == ["Quantencomputer"]


async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
    """Echtheits-Urteil zählt nur nach Bestätiger-Pass: der Einzel-Judge flaggte pro Lauf
    andere Blöcke und pendelte die Note (aak: 9.3↔10.0 bei identischem Bestand)."""
    db = testdb
    for cid, titel in (("b1", "Wackelkandidat"), ("b2", "Zufallstreffer"), ("b3", "Solide")):
        await db.kanban_upsert_card("t", "inventory", cid, "block", "done_block",
                                    {"title": titel, "description": "d"})
    monkeypatch.setattr(qa, "QA_DIR", tmp_path)

    async def fake_wave(template, topic, key, slot, items, **kw):
        if template != "QA-Bausteine":
            return {}
        if key.startswith("bausteine-b2"):  # Bestätiger sieht nur die Geflaggten
            assert len(items) == 2
            return {1: "nein", 2: "ja"}  # nur der erste wird bestätigt
        return {1: "nein", 2: "nein", 3: "ja"}  # Pass 1 flaggt zwei

    monkeypatch.setattr(qa, "judge_wave", fake_wave)
    report = await qa.qa_report("t", llm=True)
    assert report["unecht"] == ["Wackelkandidat"]


async def test_write_report_spiegelt_note_als_event(testdb, tmp_path, monkeypatch):
    """Report-JSONs liegen nur auf der Lauf-Maschine — write_report spiegelt Note/Quoten
    als kind='qa'-Event in die DB, damit ein DB-Pull für die Run-Analyse reicht."""
    db = testdb
    monkeypatch.setattr(qa, "QA_DIR", tmp_path)
    report = {"topic": "t", "run_id": "20260704-1452-b223", "note": 9.3, "note_artefakte": 8.0,
              "quoten": {"luecken": 0.1}, "quoten_artefakte": {"verwaiste": 0.0}}
    path = await qa.write_report(report)
    assert path.stem == "20260704-1452-b223"
    conn = await db.get_db()
    row = await (await conn.execute(
        "SELECT key, meta, run_id FROM events WHERE topic='t' AND kind='qa'")).fetchone()
    assert row and row[0] == "20260704-1452-b223"
    meta = json.loads(row[1])
    assert meta["note"] == 9.3 and meta["note_artefakte"] == 8.0
    assert meta["quoten"] == {"luecken": 0.1} and meta["quoten_artefakte"] == {"verwaiste": 0.0}
    assert row[2] == ""  # manuelle QA ohne Lauf → leeres run_id ist korrekt


async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch):
    """DELETE /topics räumt auch storage/qa/<topic>/ — Reports gehören zum Topic."""
    import routes
    monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t)
    monkeypatch.setattr(qa, "QA_DIR", tmp_path / "qa")
    qdir = tmp_path / "qa" / "t"
    qdir.mkdir(parents=True)
    (qdir / "alt.json").write_text("{}", encoding="utf-8")
    await routes.remove_topic("t")
    assert not qdir.exists()
