"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""

import json
import re

import numpy as np
import pytest

import blocks as blx
from pipeline import GenContext

TOPIC = "t"
_MD_PATH = re.compile(r"(/\S+\.md)")


# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────

def _sims(pairs, n):
    m = np.eye(n)
    for i, j, v in pairs:
        m[i][j] = m[j][i] = v
    return m


def test_variant_clusters_folds_paraphrases():
    titles = ["Harte Umbrüche brauchen Marker", "Harte Umbrüche erfordern explizite Marker!",
              "Tabs werden expandiert"]
    cl = blx._variant_clusters(titles, [1, 1, 1], _sims([(0, 1, 0.95)], 3))
    by_rep = {c["rep"]: c for c in cl}
    assert by_rep[1]["mentions"] == 2 and sorted(by_rep[1]["members"]) == [0, 1]  # longest wins
    assert by_rep[2]["mentions"] == 1


def test_variant_clusters_negation_guard():
    titles = ["Fenced können Absätze unterbrechen", "Fenced können Absätze nicht unterbrechen"]
    cl = blx._variant_clusters(titles, [1, 1], _sims([(0, 1, 0.95)], 2))
    assert len(cl) == 2  # antonyms never merge, no matter the cosine


# ── _subblocks_block integration (fake race + fake embeddings) ──────────────────────

def _fake_sims(texts):
    """Markertoken matrix: same first word → 0.95, else 0."""
    n = len(texts)
    m = np.eye(n)
    key = lambda t: t.split()[0].casefold()
    for i in range(n):
        for j in range(n):
            if i != j and key(texts[i]) == key(texts[j]):
                m[i][j] = 0.95
    return m


def _mk_race(finder_by_agent):
    """Key-routed _race fake. Finder round 1 → scripted per-agent subs; later finder and
    catch-up rounds → nothing; clarify judges echo the consensus lines from their prompt."""
    prompts = []

    async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
                        cancelled=None, *, grace=None, min_runtime=None, max_runtime=None):
        outs = []
        for slot in slots:
            key, prompt = slot["key"], slot["prompt"]
            prompts.append((key, prompt))
            fake_race.slots_seen.append(slot)
            if "-subblock-final-" in key:
                # no-tool judges reply as TEXT; the payload sink writes the j-file itself
                kons = re.search(r"Konsens \(≥2 finders\):\n(.*?)\nUnsicher", prompt, re.S)
                subs = [l[2:] for l in (kons.group(1).splitlines() if kons else [])
                        if l.startswith("- ") and l != "- (keiner)"]
                if subs:
                    text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
                    outs.append(slot["payload"]((0, text, "")))
                continue
            if "-r1-" in key:
                agent = int(key.rsplit("-", 1)[1])
                subs = finder_by_agent.get(agent) or []
                if subs and (m := _MD_PATH.search(prompt)):
                    text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
                    with open(m.group(1), "w", encoding="utf-8") as f:
                        f.write(text)
                    outs.append(slot["payload"](None))
        outs = [o for o in outs if o]
        return outs or None
    fake_race.slots_seen = []
    return fake_race, prompts


@pytest.fixture
def sub_env(testdb, tmp_path, monkeypatch):
    monkeypatch.setattr(blx, "EMBEDDING_AKTIV", True)
    monkeypatch.setattr(blx.embedding, "available", lambda: True)
    monkeypatch.setattr(blx.embedding, "embed_sims", _fake_sims)
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    files = {"arbeit": tmp_path}
    return testdb, ctx, files


async def _run(ctx, files, monkeypatch, finder_by_agent, seeds=None):
    fake, prompts = _mk_race(finder_by_agent)
    monkeypatch.setattr(blx, "_race", fake)
    raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
                                     "", wipe=False, ns="x-", seeds=seeds)
    return raw, prompts


async def test_variant_consensus_end_to_end(sub_env, monkeypatch):
    """3 Einzelfunde in 3 Formulierungen → EIN consensus-Repräsentant; Varianten gehen
    nicht als „Unsicher" ins Panel."""
    db, ctx, files = sub_env
    raw, prompts = await _run(ctx, files, monkeypatch, {
        1: ["Umbruch braucht Marker"],
        2: ["Umbruch erfordert explizite Marker!"],
        3: ["Umbruch verlangt zwei Leerzeichen als Marker"],
    })
    assert raw == {"Alpha": ["Umbruch verlangt zwei Leerzeichen als Marker"]}  # longest = rep
    rows = await db.list_subblocks(TOPIC, "alpha")
    status = sorted(r["status"] for r in rows)
    assert status == ["consensus", "variant", "variant"]
    clarify_prompts = [p for k, p in prompts if "-subblock-final-" in k]
    assert clarify_prompts and "Umbruch braucht Marker" not in clarify_prompts[0]


async def test_seed_promotes_single_find(sub_env, monkeypatch):
    """Seed deckt einen verworfenen Einzelfund lexikalisch → Promotion zu consensus."""
    db, ctx, files = sub_env
    raw, _ = await _run(ctx, files, monkeypatch, {
        1: ["Alpha Grundlagen", "Zeilenumbruch Regeln im Detail"],
        2: ["Alpha Grundlagen"],
    }, seeds=["Zeilenumbruch Regeln"])
    assert "Zeilenumbruch Regeln im Detail" in raw["Alpha"]
    row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
               if r["sub_norm"] == "zeilenumbruch regeln im detail")
    assert row["status"] == "consensus"


async def test_seed_inserted_when_nothing_found(sub_env, monkeypatch):
    """Seed ohne jeden Fund wird als eigener consensus-Sub eingefügt (Facts-Gate prüft später)."""
    db, ctx, files = sub_env
    raw, _ = await _run(ctx, files, monkeypatch, {
        1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"],
    }, seeds=["Fußnoten Syntax"])
    assert "Fußnoten Syntax" in raw["Alpha"]
    row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
               if r["sub_title"] == "Fußnoten Syntax")
    assert row["status"] == "consensus"


async def test_seed_covered_no_duplicate(sub_env, monkeypatch):
    """Seed lexikalisch von einem consensus-Sub abgedeckt → nichts eingefügt."""
    db, ctx, files = sub_env
    raw, _ = await _run(ctx, files, monkeypatch, {
        1: ["Tabs werden zu Leerzeichen expandiert"], 2: ["Tabs werden zu Leerzeichen expandiert"],
    }, seeds=["Tabs"])
    assert raw == {"Alpha": ["Tabs werden zu Leerzeichen expandiert"]}


async def test_wipe_false_is_idempotent(sub_env, monkeypatch):
    """Zweiter Karten-Lauf kumuliert keine Mentions (per-Block-Wipe)."""
    db, ctx, files = sub_env
    await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
    first = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
    await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
    second = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
    assert first == second


async def test_catchup_adds_and_stops(sub_env, monkeypatch, tmp_path):
    """Block unter SUBBLOCK_MIN: Nachfass-Runde findet Neues → eigenes Final-File,
    Konsens wächst; zweite Runde ohne Neues → Ende."""
    db, ctx, files = sub_env
    fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
    base = fake
    hit = {"n": 0}

    async def with_catchup(topic, label, slots, *a, **k):
        if any("-subblock-x" in s["key"] for s in slots):
            hit["n"] += 1
            if hit["n"] == 1:  # first catch-up round: both agents agree on one new sub
                for slot in slots[:2]:
                    m = _MD_PATH.search(slot["prompt"])
                    with open(m.group(1), "w", encoding="utf-8") as f:
                        f.write("<!-- block: Alpha -->\n- Vertiefung der Konzepte")
                return [slot["payload"](None) for slot in slots[:2]]
            return None
        return await base(topic, label, slots, *a, **k)

    monkeypatch.setattr(blx, "_race", with_catchup)
    raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
                                     "", wipe=False, ns="x-")
    assert set(raw["Alpha"]) == {"Alpha Grundlagen", "Vertiefung der Konzepte"}
    assert (tmp_path / "subblock-final-c1-x1.md").exists()
    assert hit["n"] == 2  # round 2 ran, found nothing, loop ended


# ── Outline-Review ───────────────────────────────────────────────────────────────────

def test_outline_review_schema():
    valid = {1, 2, 3, 4, 5, 6}
    ok = blx._outline_review_schema({"moves": {"3": 2}}, valid, 2, 6)
    assert ok == {3: 2}
    assert blx._outline_review_schema({"moves": {}}, valid, 2, 6) == {}
    assert blx._outline_review_schema({"moves": {"9": 1}}, valid, 2, 6) is None  # unknown block
    assert blx._outline_review_schema({"moves": {"1": 5}}, valid, 2, 6) is None  # chapter range
    assert blx._outline_review_schema({"moves": {"1": 2, "2": 2, "3": 2}}, valid, 2, 6) is None  # mass move
    assert blx._outline_review_schema({"chapters": []}, valid, 2, 6) is None


async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
    """Review verschiebt einen fehlplatzierten Block; kaputtes Review lässt den Plan unverändert."""
    entries = {i: f"Block {i} — d" for i in range(1, 7)}
    slots = [tmp_path / f"outline-{i}.json" for i in (1, 2, 3)]
    plan_a = {"chapters": [{"title": "K1", "numbers": [1, 2, 6]}, {"title": "K2", "numbers": [3, 4, 5]}]}
    for p in slots[:2]:
        p.write_text(json.dumps(plan_a), encoding="utf-8")
    files = {"arbeit": tmp_path, "outline": tmp_path / "outline.json", "outline_slots": slots,
             "facts": tmp_path / "facts.json"}

    review_out = {"val": {"moves": {"6": 2}}}

    async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
        out = None
        if key.endswith("outline-prereqs"):
            out = {"prereqs": {}}
        elif key.endswith("outline-judge"):
            out = plan_a
        elif key.endswith("outline-review"):
            out = review_out["val"]
        if out is not None:  # neue Semantik: Antwort als TEXT, der Sink persistiert
            return "ok", payload((0, json.dumps(out), ""))
        return "ok", payload(None)

    monkeypatch.setattr(blx, "run_single_slot", fake_slot)
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    plan = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
    assert plan["chapters"][0]["numbers"] == [1, 2]
    assert plan["chapters"][1]["numbers"] == [3, 4, 5, 6]

    # broken review (mass move) → schema rejects, plan unchanged
    review_out["val"] = {"moves": {"1": 2, "2": 2, "3": 1}}
    (tmp_path / "outline-review.json").unlink()
    (tmp_path / "outline.json").unlink()
    plan2 = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
    assert plan2["chapters"][0]["numbers"] == [1, 2, 6]

async def test_paraphrase_saturation_stops_early(sub_env, monkeypatch):
    """Runde 2 liefert nur eine Paraphrase → zählt nicht als neu, Schleife endet ohne r3.
    Die Paraphrase liegt trotzdem in der DB (Mention fürs Cluster-Voting)."""
    db, ctx, files = sub_env
    base_fake, prompts = _mk_race({1: ["Umbruch braucht Marker"], 2: ["Umbruch braucht Marker"]})

    async def with_r2(topic, label, slots, *a, **k):
        if any("-r2-" in s["key"] for s in slots):
            outs = []
            for slot in slots[:2]:
                m = _MD_PATH.search(slot["prompt"])
                with open(m.group(1), "w", encoding="utf-8") as f:
                    f.write("<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!")
                outs.append(slot["payload"](None))
            return outs
        return await base_fake(topic, label, slots, *a, **k)

    monkeypatch.setattr(blx, "_race", with_r2)
    raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
                                     "", wipe=False, ns="x-")
    assert raw["Alpha"]  # Konsens steht
    assert not any("-r3-" in k for k, _ in prompts)  # Paraphrase hielt die Schleife NICHT am Leben
    rows = await db.list_subblocks(TOPIC, "alpha")
    assert any(r["sub_title"] == "Umbruch erfordert explizite Marker!" for r in rows)


async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
    """Jede Runde ein echt neues Konzept → hartes Cap stoppt bei SUBBLOCK_MAX_ROUNDS."""
    db, ctx, files = sub_env
    _, prompts = _mk_race({})

    async def endless(topic, label, slots, *a, **k):
        if "-subblock-final-" in slots[0]["key"]:
            return None  # panel fails → consensus fallback
        outs = []
        import re as _re
        rn = _re.search(r"-r(\d+)-", slots[0]["key"])
        n = rn.group(1) if rn else "x"
        for slot in slots[:2]:
            m = _MD_PATH.search(slot["prompt"])
            with open(m.group(1), "w", encoding="utf-8") as f:
                f.write(f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig")
            prompts.append((slot["key"], slot["prompt"]))
            outs.append(slot["payload"](None))
        return outs

    monkeypatch.setattr(blx, "_race", endless)
    await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
                               "", wipe=False, ns="x-")
    max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k)
    assert max_round == blx.SUBBLOCK_MAX_ROUNDS


# ── Inline-Evidenz für Judges (Token-Umbau) ──────────────────────────────────────────

def _corpus(tmp_path):
    d = tmp_path / "korpus"
    d.mkdir()
    (d / "Skript.txt").write_text(
        "Kapitel 1\nAlpha Grundlagen: der Kernbegriff.\nMehr Text dazu.\n\n"
        "Kapitel 2\nGamma Randnotiz ohne Bezug.\n", encoding="utf-8")
    (d / "Aufgaben.txt").write_text("Übung 1\nAlpha Vertiefung der Konzepte.\n", encoding="utf-8")
    return d


def test_evidence_pack_selects_matching_sections(tmp_path):
    d = _corpus(tmp_path)
    pack = blx._evidence_pack(d, None, ["Alpha Grundlagen"])
    assert "── Skript.txt" in pack and "Kernbegriff" in pack
    pack2 = blx._evidence_pack(d, ["Aufgaben.txt"], ["Alpha"])  # genannte Quellen engen ein
    assert "Skript.txt" not in pack2 and "Aufgaben.txt" in pack2
    assert blx._evidence_pack(None, None, ["x"]) == ""  # kein Korpus → Selbst-Recherche bleibt


def test_evidence_pack_budget_and_guarantee(tmp_path):
    d = tmp_path / "korpus"
    d.mkdir()
    (d / "A.txt").write_text("Alpha wichtig. " * 50, encoding="utf-8")
    (d / "B.txt").write_text("Beta anderes Thema. " * 50, encoding="utf-8")
    pack = blx._evidence_pack(d, None, ["Alpha"], budget=10)
    assert "Alpha" in pack  # Abdeckungs-Garantie schlägt das Budget
    assert "Beta" not in pack  # Top-up respektiert das Budget


def test_cite_ref_parses_positions(tmp_path):
    d = _corpus(tmp_path)
    files = blx._corpus_files(d, None)
    f, lo, hi = blx._cite_ref("Skript.txt, Übung 6.47, Z.2-3", files)
    assert f.name == "Skript.txt" and (lo, hi) == (2, 3)
    f2, lo2, hi2 = blx._cite_ref("Aufgaben.txt Zeile 2", files)
    assert f2.name == "Aufgaben.txt" and lo2 == hi2 == 2
    assert blx._cite_ref("Skript.txt, Übung 6.47", files) is None  # keine Zeilenangabe
    assert blx._cite_ref("Z.5 irgendwo", files) is None  # keine Datei
    # englische Zitierformen (Quellen sind nicht immer deutsch)
    f3, lo3, hi3 = blx._cite_ref("Skript.txt, line 2", files)
    assert f3.name == "Skript.txt" and lo3 == hi3 == 2
    f4, lo4, hi4 = blx._cite_ref("Aufgaben.txt, lines 1-2", files)
    assert f4.name == "Aufgaben.txt" and (lo4, hi4) == (1, 2)


def test_cited_evidence_lines_and_fallback(tmp_path):
    d = _corpus(tmp_path)
    ev = blx._cited_evidence(d, None, ["Skript.txt, Z.2"], ["Alpha"])
    assert "── Skript.txt · Z." in ev and "Kernbegriff" in ev
    ev2 = blx._cited_evidence(d, None, ["ohne Position"], ["Alpha Grundlagen"])
    assert "Kernbegriff" in ev2  # Keyword-Fallback


def test_sink_json_writes_only_valid(tmp_path):
    p = tmp_path / "level-final-c1.json"
    ok = blx._sink_json((0, 'Vorab {"levels": {"1": "beginner"}} nach', ""), p,
                        lambda d: blx._levels_schema(d, {1}))
    assert ok == {1: "beginner"}
    assert json.loads(p.read_text(encoding="utf-8"))["levels"]["1"] == "beginner"
    bad = blx._sink_json((0, "kein json", ""), tmp_path / "x.json", lambda d: d)
    assert bad is None and not (tmp_path / "x.json").exists()


async def test_clarify_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
    """Mit Korpus: Judges bekommen Auszüge inline und laufen ohne Tools (Text-Antwort);
    die j-Datei schreibt die Engine. Finder bleiben unverändert bei capabilities=files."""
    db, ctx, files = sub_env
    d = _corpus(tmp_path)
    monkeypatch.setattr(blx, "source_folder", lambda t: d)
    monkeypatch.setattr(blx, "load_source", lambda t: {"type": "uni"})
    fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
    monkeypatch.setattr(blx, "_race", fake)
    raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
                                     "", wipe=False, ns="x-", sources=["Skript.txt"])
    assert raw == {"Alpha": ["Alpha Grundlagen"]}
    judges = [s for s in fake.slots_seen if "-subblock-final-" in s["key"]]
    finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
    assert judges and all(s["capabilities"] == "none" for s in judges)
    assert "── Skript.txt" in judges[0]["prompt"]
    assert "ls/find" not in judges[0]["prompt"]  # keine Selbst-Recherche-Anweisung mehr
    assert finders and all(s["capabilities"] == "files" for s in finders)
    assert list(tmp_path.glob("subblock-final-*-j*.md"))  # Engine persistiert die Judge-Antwort


async def test_facts_check_inline_cited_evidence(sub_env, monkeypatch, tmp_path):
    """Facts-Check: zitierte Zeilenbereiche gehen inline mit, Judge läuft ohne Tools,
    die Check-Datei schreibt die Engine aus der Text-Antwort."""
    db, ctx, files = sub_env
    d = _corpus(tmp_path)
    facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
                        "prerequisites": "", "hurdles": "",
                        "cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
                        "example_idea": ""}]}

    sh = blx._subs_hash({"Alpha": ["Sub Eins"]})  # Resume-Dateien tragen den Sub-Satz-Hash

    async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
        if "-facts-erg-" in key:
            return blx.FAILED, None
        (tmp_path / f"facts-{sh}-c0.json").write_text(json.dumps(facts), encoding="utf-8")
        return blx.OK, None

    seen = []

    async def fake_agent(key, prompt, timeout, provider="", role="", capabilities="", scope=None, label="", **kw):
        seen.append((key, capabilities, prompt))
        return (0, '{"ok": true}', "")

    monkeypatch.setattr(blx, "run_single_slot", fake_slot)
    monkeypatch.setattr(blx, "run_agent", fake_agent)
    res = await blx._facts_block(ctx, lambda *a, **k: None, {"arbeit": tmp_path},
                                 {"Alpha": ["Sub Eins"]}, {"type": "uni"}, d, "", ns="x-")
    assert res is not None
    facts_map, discarded = res
    assert "Alpha" in facts_map and not discarded
    assert len(seen) == blx.FACTS_CHECK_PANEL
    key, caps, prompt = seen[0]
    assert caps == "none" and "── Skript.txt · Z." in prompt
    assert (tmp_path / f"facts-check-{sh}-c0-j1.json").exists()  # Engine persistiert die Antwort


def test_sub_key_resolves_short_titles():
    """Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'.
    Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert."""
    import board_artefacts as ba
    existing = {"autolink mit url: erzeugt link", "bilder: bindet bilder ein",
                "doppel: eins", "doppel: zwei", "exakt"}
    assert ba._sub_key(existing, "exakt") == "exakt"
    assert ba._sub_key(existing, "autolink mit url") == "autolink mit url: erzeugt link"
    assert ba._sub_key(existing, "doppel") == "doppel"        # mehrdeutig → unverändert
    assert ba._sub_key(existing, "fehlt") == "fehlt"          # kein Treffer → unverändert
    # Fuzzy: Paraphrase/Kürzung ohne Doppelpunkt-Präfix löst eindeutig auf
    lang = {"der backslash selbst muss mit escaped werden, um literal zu erscheinen"}
    assert ba._sub_key(lang, "der backslash selbst muss mit escaped werden") == next(iter(lang))
    assert ba._sub_key(lang | {"der backslash am zeilenende"}, "der backslash") == "der backslash"  # mehrdeutig


def test_subs_hash_invalidiert_bei_neuem_zuschnitt():
    """Gleicher Sub-Satz → gleicher Hash (Resume greift); geänderter → neuer Hash.
    raw-Form (Strings) und sidecar-Form (dicts) hashen identisch."""
    a = {"Block": ["s1", "s2"]}
    assert blx._subs_hash(a) == blx._subs_hash({"Block": ["s1", "s2"]})
    assert blx._subs_hash(a) != blx._subs_hash({"Block": ["s1", "s3"]})
    assert blx._subs_hash(a) == blx._subs_hash({"Block": [{"title": "s1"}, {"title": "s2"}]})
