"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und
Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB."""

import json

import numpy as np
import pytest

import blocks
import board_artefacts as ba
from kanban import Flow
from pipeline import FAILED, OK, GenContext

TOPIC = "konsolidierung"


def _ctx():
    return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)


def _fake_slot(antworten):
    """run_single_slot-Fake: pro Judge-Key eine Antwort; schreibt via payload (wie der Engine-Sink)."""
    calls = []

    async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
        calls.append({"key": key, "prompt": prompt})
        j = key.rsplit("-", 1)[-1]  # "j1"/"j2"
        antwort = antworten.get(j)
        if antwort is None:
            return FAILED, None
        return OK, payload((0, json.dumps(antwort), ""))

    fake.calls = calls
    return fake


async def _seed_block(db, bnorm, subs):
    for s in subs:
        await db.put_subblock(TOPIC, bnorm, blocks._norm_title(s), bnorm.title(), s, status="consensus")


# ── In-Block ────────────────────────────────────────────────────────────────────────

async def test_merge_on_unanimity(testdb, tmp_path, monkeypatch):
    """Beide Judges gruppieren 1+2 → Gewinner (mehr key_points) bleibt, facts-Union,
    Verlierer wird DB-variant und fliegt aus raw/facts_map."""
    db = testdb
    subs = ["Durchstreichung: ~~text~~", "Durchstreichung: ~~text~~ streicht Text durch", "Fett: **text**"]
    await _seed_block(db, "betonung", subs)
    raw = {"Betonung": list(subs)}
    facts = {"Betonung": {
        blocks._norm_title(subs[0]): {"key_points": ["kp-a"], "cited_facts": [{"text": "z1"}]},
        blocks._norm_title(subs[1]): {"key_points": ["kp-b", "kp-c"], "cited_facts": [{"text": "z1"}, {"text": "z2"}]},
    }}
    fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": ["Marker-Escaping fehlt"]},
                       "j2": {"gruppen": [[2, 1]], "luecken": ["Escaping von Markern"]}})
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
    assert raw["Betonung"] == [subs[1], "Fett: **text**"]  # Gewinner: 2 key_points > 1
    wf = facts["Betonung"][blocks._norm_title(subs[1])]
    assert wf["key_points"] == ["kp-b", "kp-c", "kp-a"]
    assert wf["cited_facts"] == [{"text": "z1"}, {"text": "z2"}]  # Union ohne Doppel
    assert blocks._norm_title(subs[0]) not in facts["Betonung"]
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "betonung")}
    assert rows[blocks._norm_title(subs[0])] == "variant"
    assert rows[blocks._norm_title(subs[1])] == "consensus"
    journale = list(tmp_path.glob("sub-konsolidierung-*.json"))
    j = json.loads([p for p in journale if "-j" not in p.stem][0].read_text())
    # Lücken-Schnitt: Token-Überlappung beider Judges, Formulierung von j1 gewinnt
    assert j["gruppen"][0]["behalten"] == subs[1] and j["luecken"] == ["Marker-Escaping fehlt"]


async def test_dissent_keeps_everything(testdb, tmp_path, monkeypatch):
    """Nur ein Judge gruppiert → keine Einstimmigkeit → kein Merge."""
    db = testdb
    subs = ["Eintrag eins", "Eintrag zwei"]
    await _seed_block(db, "block", subs)
    raw = {"Block": list(subs)}
    fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
                       "j2": {"gruppen": [], "luecken": []}})
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
    assert raw["Block"] == subs
    assert all(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "block"))


async def test_judge_failure_fail_open(testdb, tmp_path, monkeypatch):
    """Ein Judge UND der Ersatz ohne Ergebnis → fail-open, nichts ändert sich."""
    db = testdb
    subs = ["Eintrag eins", "Eintrag zwei"]
    await _seed_block(db, "block", subs)
    raw = {"Block": list(subs)}
    fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}})  # j2 UND j3 → FAILED
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
    assert raw["Block"] == subs
    assert len(fake.calls) == 3  # j1, j2, Ersatz j3


async def test_ersatzrichter_bei_ausfall(testdb, tmp_path, monkeypatch):
    """j1 fällt aus → Ersatz j3 springt ein; Einstimmigkeit j2+j3 faltet.
    Vorher entwertete EIN Timeout die gute Stimme (13 Links-Dubletten überlebten)."""
    db = testdb
    subs = ["Kurz", "Deutlich längerer Eintrag"]
    await _seed_block(db, "block", subs)
    raw = {"Block": list(subs)}
    fake = _fake_slot({"j2": {"gruppen": [[1, 2]], "luecken": []},
                       "j3": {"gruppen": [[2, 1]], "luecken": []}})  # j1 → FAILED
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
    assert raw["Block"] == ["Deutlich längerer Eintrag"]


async def test_negation_guard_blocks_merge(testdb, tmp_path, monkeypatch):
    """Gegensätzliche Aussagen werden selbst bei einstimmigen Judges nicht gefaltet."""
    db = testdb
    subs = ["Tabs werden expandiert", "Tabs werden nicht expandiert"]
    await _seed_block(db, "tabs", subs)
    raw = {"Tabs": list(subs)}
    fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
                       "j2": {"gruppen": [[1, 2]], "luecken": []}})
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
    assert raw["Tabs"] == subs


async def test_resume_skips_judges(testdb, tmp_path, monkeypatch):
    """Vorhandene j-Dateien → kein neuer Agenten-Call, Ergebnis wird übernommen."""
    db = testdb
    subs = ["Eintrag eins", "Eintrag zwei lang"]
    await _seed_block(db, "block", subs)
    raw = {"Block": list(subs)}
    import hashlib
    h = hashlib.md5("\n".join(subs).encode()).hexdigest()[:8]
    for j in (1, 2):
        (tmp_path / f"sub-konsolidierung-{h}-j{j}.json").write_text(
            json.dumps({"gruppen": [[1, 2]], "luecken": []}), encoding="utf-8")

    async def kein_agent(*a, **kw):
        raise AssertionError("Resume darf keinen Agenten starten")

    monkeypatch.setattr(blocks, "run_single_slot", kein_agent)
    await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
    assert raw["Block"] == ["Eintrag zwei lang"]


def test_schema_accepts_both_group_forms():
    """Alte Listenform [1,4] und neue {haupt, weitere}-Form parsen beide; kataloge/fremd optional."""
    alt = blocks._konsolidierung_schema({"gruppen": [[1, 4]], "luecken": []}, 5)
    assert alt["gruppen"] == [{"haupt": None, "ids": [1, 4]}] and alt["fremd"] == set()
    neu = blocks._konsolidierung_schema(
        {"gruppen": [{"haupt": 4, "weitere": [1]}],
         "kataloge": [{"titel": "Katalog: Symbole", "mitglieder": [2, 3]}],
         "fremd": [5], "luecken": ["x"]}, 5)
    assert neu["gruppen"] == [{"haupt": 4, "ids": [1, 4]}]
    assert neu["kataloge"] == [{"titel": "Katalog: Symbole", "ids": [2, 3]}]
    assert neu["fremd"] == {5} and neu["luecken"] == ["x"]
    assert blocks._konsolidierung_schema({"gruppen": [{"haupt": 9, "weitere": [1]}]}, 5) == \
        {"gruppen": [], "kataloge": [], "fremd": set(), "luecken": []}  # id out of range


async def test_haupt_beats_heuristic(testdb, tmp_path, monkeypatch):
    """Judges nennen den kürzeren Eintrag als haupt → er gewinnt trotz weniger key_points."""
    db = testdb
    subs = ["Basis", "Detailregel mit sehr langem Titel und Facts"]
    await _seed_block(db, "block", subs)
    raw = {"Block": list(subs)}
    facts = {"Block": {blocks._norm_title(subs[1]): {"key_points": ["a", "b", "c"]}}}
    fake = _fake_slot({"j1": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []},
                       "j2": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []}})
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
    assert raw["Block"] == ["Basis"]
    assert facts["Block"][blocks._norm_title("Basis")]["key_points"] == ["a", "b", "c"]  # Union geerbt


async def test_katalog_bundles_to_new_row(testdb, tmp_path, monkeypatch):
    """Einstimmige Katalog-Mitglieder → neue consensus-Zeile mit Facts-Union, Mitglieder variant."""
    db = testdb
    subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f", "Eigene Regel"]
    await _seed_block(db, "mathe", subs)
    raw = {"Mathe": list(subs)}
    facts = {"Mathe": {blocks._norm_title(subs[0]): {"key_points": ["kp1"]},
                       blocks._norm_title(subs[1]): {"key_points": ["kp2"]}}}
    kat = {"titel": "Symbolkatalog: Pfeile und Mengen", "mitglieder": [1, 2]}
    fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [kat], "luecken": []},
                       "j2": {"gruppen": [], "kataloge": [kat], "luecken": []}})
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
    assert raw["Mathe"] == ["Eigene Regel", "Symbolkatalog: Pfeile und Mengen"]
    kn = blocks._norm_title("Symbolkatalog: Pfeile und Mengen")
    assert sorted(facts["Mathe"][kn]["key_points"]) == ["kp1", "kp2"]
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "mathe")}
    assert rows[kn] == "consensus"
    assert rows[blocks._norm_title(subs[0])] == "variant"


async def test_katalog_dissent_keeps_members(testdb, tmp_path, monkeypatch):
    db = testdb
    subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f"]
    await _seed_block(db, "mathe", subs)
    raw = {"Mathe": list(subs)}
    fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [{"titel": "K", "mitglieder": [1, 2]}], "luecken": []},
                       "j2": {"gruppen": [], "kataloge": [], "luecken": []}})
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
    assert raw["Mathe"] == subs


async def test_fremd_unanimous_discards(testdb, tmp_path, monkeypatch):
    """Einstimmig fremd → discarded + raus; einseitig fremd → bleibt."""
    db = testdb
    subs = ["CSS display überschreibt Verhalten", "Echte Markdown-Regel", "Nur einer hält es für fremd"]
    await _seed_block(db, "block", subs)
    raw = {"Block": list(subs)}
    fake = _fake_slot({"j1": {"gruppen": [], "fremd": [1, 3], "luecken": []},
                       "j2": {"gruppen": [], "fremd": [1], "luecken": []}})
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
    assert raw["Block"] == [subs[1], subs[2]]
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
    assert rows[blocks._norm_title(subs[0])] == "discarded"
    assert rows[blocks._norm_title(subs[2])] == "consensus"
    assert luecken == {}


async def test_luecken_nur_bei_einstimmigkeit(testdb, tmp_path, monkeypatch):
    """Nur Lücken mit Token-Überlappung BEIDER Judges überleben; einseitige fallen weg."""
    db = testdb
    subs = ["Eintrag eins", "Eintrag zwei"]
    await _seed_block(db, "block", subs)
    raw = {"Block": list(subs)}
    fake = _fake_slot({"j1": {"gruppen": [], "luecken": ["Inline-HTML fehlt", "Front-Matter"]},
                       "j2": {"gruppen": [], "luecken": ["nichts zu Inline-HTML"]}})
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
    assert luecken == {"Block": ["Inline-HTML fehlt"]}


async def test_kp_deckel_im_judge_prompt(testdb, tmp_path, monkeypatch):
    """Prompt zeigt max. 3 key_points je Sub (Timeout-Schutz); die Union bleibt voll."""
    db = testdb
    subs = ["Eintrag eins", "Eintrag zwei"]
    await _seed_block(db, "block", subs)
    raw = {"Block": list(subs)}
    facts = {"Block": {blocks._norm_title(subs[0]): {"key_points": [f"kp{i}" for i in range(1, 6)]}}}
    fake = _fake_slot({"j1": {"gruppen": [], "luecken": []}, "j2": {"gruppen": [], "luecken": []}})
    monkeypatch.setattr(blocks, "run_single_slot", fake)
    await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
    prompt = fake.calls[0]["prompt"]
    assert "kp3" in prompt and "kp4" not in prompt


def test_luecken_schnitt_cap():
    l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
    assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3]  # Cap 3
    assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []


def test_neg_set_lemmatisiert():
    """kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
    a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
    b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
    assert a == b == frozenset({"kein"})
    assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
    assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")


# ── Lücken-Nachfass ─────────────────────────────────────────────────────────────────

async def _nachfass_env(db, monkeypatch, facts_result):
    subs = ["Eintrag eins"]
    await _seed_block(db, "block", subs)
    raw = {"Block": list(subs)}
    facts_map = {"Block": {}}

    async def fake_race(topic, label, slots, quorum, timeout, provider, cancelled=None, grace=0):
        return [{"Block": ["Eintrag eins", "Neuer Aspekt"]}]

    async def fake_facts(ctx, set_p, files, fraw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
        assert slim is True  # Nachfass nutzt die schlanke Facts-Variante
        assert list(fraw["Block"]) == ["Neuer Aspekt"]  # nur der frische Fund geht ins Gate
        return facts_result

    monkeypatch.setattr(blocks, "_race", fake_race)
    monkeypatch.setattr(blocks, "_facts_block", fake_facts)
    monkeypatch.setattr(blocks, "EMBEDDING_AKTIV", False)
    return raw, facts_map


async def test_nachfass_adopts_backed_find(testdb, tmp_path, monkeypatch):
    db = testdb
    nn = blocks._norm_title("Neuer Aspekt")
    raw, facts_map = await _nachfass_env(db, monkeypatch,
                                         ({"Block": {nn: {"key_points": ["kp"]}}}, {}))
    n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
                                    raw, facts_map, {"type": "thema"}, None)
    assert n == 1 and raw["Block"] == ["Eintrag eins", "Neuer Aspekt"]
    assert facts_map["Block"][nn]["key_points"] == ["kp"]
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
    assert rows[nn] == "consensus"


async def test_nachfass_drops_unbacked_find(testdb, tmp_path, monkeypatch):
    db = testdb
    nn = blocks._norm_title("Neuer Aspekt")
    raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {"Block": {nn}}))
    n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
                                    raw, facts_map, {"type": "thema"}, None)
    assert n == 0 and raw["Block"] == ["Eintrag eins"]
    assert not any(r["sub_norm"] == nn for r in await db.list_subblocks(TOPIC, "block"))


async def test_nachfass_drops_find_without_facts(testdb, tmp_path, monkeypatch):
    """HARTES Gate: kein Facts-Eintrag = kein Beleg = keine Übernahme — nicht nur
    aktiv Verworfenes fliegt (Bilder-Lauf: 13 von 18 kamen ohne Beleg durch)."""
    db = testdb
    raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {}))  # Facts fand NICHTS
    n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
                                    raw, facts_map, {"type": "thema"}, None)
    assert n == 0 and raw["Block"] == ["Eintrag eins"]


async def test_facts_stage_konsolidiert_nachfass_funde_erneut(testdb, tmp_path, monkeypatch):
    """Kreis geschlossen: nach Übernahmen läuft die Konsolidierung ein zweites Mal;
    deren Lücken lösen KEINEN weiteren Nachfass aus."""
    db = testdb
    payload = {"title": "Alpha", "raw": {"Alpha": ["s1"]}}
    await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts", payload)
    calls = {"kons": 0, "nf": 0}

    async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
        return {"Alpha": {}}, {}

    async def fake_kons(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
        calls["kons"] += 1
        return {"Alpha": ["Lücke X"]}  # meldet auch in Runde 2 — darf nicht erneut nachfassen

    async def fake_nf(ctx, files, title, luecken, raw, facts_map, q, folder,
                      instructions="", ns="", lbl="", sources=None):
        calls["nf"] += 1
        return 2

    monkeypatch.setattr(ba, "_facts_block", fake_facts)
    monkeypatch.setattr(ba, "_konsolidiere_subblocks", fake_kons)
    monkeypatch.setattr(ba, "_luecken_runde", fake_nf)
    flow = Flow(TOPIC, work_dir=tmp_path)
    await ba._proc_facts(_ctx(), flow, {"arbeit": tmp_path}, {"type": "thema"}, None, "",
                         [{"card_id": "alpha", "payload": payload}])
    assert calls == {"kons": 2, "nf": 1}
    assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "levels"


async def test_finalize_purges_stale_rows(testdb, tmp_path):
    """Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
    db = testdb
    await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
    await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
    await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
    files = {k: tmp_path / f"{k}.json" for k in
             ("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
    card = {"card_id": "alpha", "payload": {
        "title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
        "sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
        "pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
        "artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
    flow = Flow(TOPIC, work_dir=tmp_path)
    await ba._proc_finalize(_ctx(), flow, files, [card])
    assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
    assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}


# ── Cross-Block ─────────────────────────────────────────────────────────────────────

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

    @staticmethod
    def available():
        return True

    @staticmethod
    def embed_sims(texts):
        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 @ arr.T


async def _cross_env(db, tmp_path):
    flow = Flow(TOPIC, work_dir=tmp_path)
    cards = []
    for bnorm, subs in (("alpha", ["Gleiche Aussage", "Nur in Alpha"]),
                        ("beta", ["Gleiche Aussage", "Nur in Beta"])):
        payload = {"title": bnorm.title(),
                   "raw": {bnorm.title(): list(subs)},
                   "sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]},
                   "facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": [f"kp {s}"]} for s in subs}}}
        await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
        await _seed_block(db, bnorm, subs)
        cards.append({"card_id": bnorm, "payload": payload})
    return flow, cards


async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch):
    """Einstimmig „a" → Beta verliert die geteilte Aussage, Karten wandern zu levels."""
    db = testdb
    flow, cards = await _cross_env(db, tmp_path)
    monkeypatch.setattr(ba, "embedding", _FakeEmb)
    fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
    monkeypatch.setattr(ba, "run_single_slot", fake)
    await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
    assert "Gleiche Aussage" in fake.calls[0]["prompt"]
    beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
    assert beta["stage"] == "question_pattern"
    assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
    assert blocks._norm_title("Gleiche Aussage") not in beta["payload"]["facts"]["Beta"]
    # Barriere liegt jetzt hinter levels/relevance → auch die sidecar muss den Fold tragen
    assert [e["title"] for e in beta["payload"]["sidecar"]["Beta"]] == ["Nur in Beta"]
    alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
    assert alpha["stage"] == "question_pattern"
    assert alpha["payload"]["raw"]["Alpha"] == ["Gleiche Aussage", "Nur in Alpha"]
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
    assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"


async def test_crossblock_tiebreaker_folds(testdb, tmp_path, monkeypatch):
    """j1/j2 uneinig → j3 entscheidet mit Mehrheit; hier „a" → Beta verliert."""
    db = testdb
    flow, cards = await _cross_env(db, tmp_path)
    monkeypatch.setattr(ba, "embedding", _FakeEmb)
    fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "nein"}},
                       "j3": {"pairs": {"1": "a"}}})
    monkeypatch.setattr(ba, "run_single_slot", fake)
    await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
    assert len(fake.calls) == 3
    beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
    assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]


async def test_crossblock_dissent_without_tiebreaker_keeps_both(testdb, tmp_path, monkeypatch):
    """j3 liefert nichts (FAILED) → fail-open, Paar bleibt."""
    db = testdb
    flow, cards = await _cross_env(db, tmp_path)
    monkeypatch.setattr(ba, "embedding", _FakeEmb)
    fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "b"}}})  # j3 fehlt → FAILED
    monkeypatch.setattr(ba, "run_single_slot", fake)
    await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
    beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
    assert beta["stage"] == "question_pattern"
    assert beta["payload"]["raw"]["Beta"] == ["Gleiche Aussage", "Nur in Beta"]


async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch):
    """Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet."""
    db = testdb
    flow, cards = await _cross_env(db, tmp_path)
    monkeypatch.setattr(ba, "embedding", _FakeEmb)
    fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "jE": {"pairs": {"1": "a"}}})  # j2 → FAILED
    monkeypatch.setattr(ba, "run_single_slot", fake)
    await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
    beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
    assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]


async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypatch):
    db = testdb
    flow, cards = await _cross_env(db, tmp_path)

    class _Aus:
        @staticmethod
        def available():
            return False

    async def kein_agent(*a, **kw):
        raise AssertionError("ohne Embedding kein Judge")

    monkeypatch.setattr(ba, "embedding", _Aus)
    monkeypatch.setattr(ba, "run_single_slot", kein_agent)
    await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
    for cid in ("alpha", "beta"):
        assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern"


async def test_crossblock_context_wins(testdb, tmp_path, monkeypatch):
    """Kontext-Sub (Block schon hinter der Barrier) gewinnt auch bei Verdict „b" —
    die Paket-Seite fällt, der Kontext bleibt unangetastet."""
    db = testdb
    flow = Flow(TOPIC, work_dir=tmp_path)
    payload = {"title": "Alpha", "raw": {"Alpha": ["Gleiche Aussage"]}, "facts": {"Alpha": {}}}
    await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "konsolidierung", payload)
    await _seed_block(db, "alpha", ["Gleiche Aussage"])
    cards = [{"card_id": "alpha", "payload": payload}]
    # Kontext-Block "gamma" ist bereits weiter (Stage levels) und hält dieselbe Aussage
    await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "levels",
                                {"title": "Gamma", "raw": {"Gamma": ["Gleiche Aussage"]}, "facts": {}})
    await _seed_block(db, "gamma", ["Gleiche Aussage"])
    monkeypatch.setattr(ba, "embedding", _FakeEmb)
    # Verdict „a": das Paket (A) soll behalten — Kontext faltet trotzdem nie
    fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
    monkeypatch.setattr(ba, "run_single_slot", fake)
    await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
    alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
    assert alpha["payload"]["raw"].get("Alpha", []) == []  # Paket-Seite gefaltet
    gamma_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "gamma")}
    assert gamma_rows[blocks._norm_title("Gleiche Aussage")] == "consensus"  # Kontext unberührt


async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
    """Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
    db = testdb
    await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
    await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
    files = {k: tmp_path / f"{k}.json" for k in
             ("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
    card = {"card_id": "alpha", "payload": {"title": "Alpha", "raw": {}, "facts": {},
                                            "sidecar": {}, "pattern": {}, "artefacts": {}}}
    await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
    row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
    assert row["level"] == "advanced" and row["relevance"] == "relevant"


async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
    """Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
    variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
    db = testdb
    await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
    await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
    await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
    files = {k: tmp_path / f"{k}.json" for k in
             ("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
    card = {"card_id": "alpha", "payload": {
        "title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
        "sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
        "pattern": {}, "artefacts": {}}}
    await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
    assert rows == {"neu": "consensus", "alte-variante": "variant"}


async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
    """Paare werden gechunkt beurteilt (ein Hänger blockiert nur noch seinen Chunk);
    die Verdicts falten global über alle Chunks."""
    db = testdb
    flow = Flow(TOPIC, work_dir=tmp_path)
    cards = []
    for bnorm, subs in (("alpha", ["Gleiche Aussage", "Zweite gleiche Aussage", "Nur in Alpha"]),
                        ("beta", ["Gleiche Aussage", "Zweite gleiche Aussage"])):
        payload = {"title": bnorm.title(),
                   "raw": {bnorm.title(): list(subs)},
                   "sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]},
                   "facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": []} for s in subs}}}
        await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
        await _seed_block(db, bnorm, subs)
        cards.append({"card_id": bnorm, "payload": payload})
    monkeypatch.setattr(ba, "embedding", _FakeEmb)
    monkeypatch.setattr(ba, "CROSS_CHUNK_PAARE", 1)  # 2 Paare → 2 Chunks
    fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
    monkeypatch.setattr(ba, "run_single_slot", fake)
    await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
    assert len(fake.calls) == 4  # 2 Chunks × j1/j2
    beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
    assert beta["payload"]["raw"].get("Beta", []) == []  # beide Dubletten global gefaltet


async def test_facts_nachfass_holt_nur_fehlende(testdb, tmp_path, monkeypatch):
    """Nachfass ruft den slim-Facts-Lauf NUR mit den facts-losen Subs und merged die Funde;
    Vorhandenes bleibt unberührt, Subs werden nie verworfen."""
    gesehen = {}

    async def fake_facts_block(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="",
                               sources=None, slim=False):
        gesehen["raw"] = raw
        gesehen["slim"] = slim
        return ({"Alpha": {"ohne beleg": {"key_points": ["kp neu"]},
                           "mit beleg": {"key_points": ["DARF NICHT GEWINNEN"]}}},
                {"Alpha": {"ohne beleg"}})  # discard-Urteil wird ignoriert

    monkeypatch.setattr(blocks, "_facts_block", fake_facts_block)
    raw = {"Alpha": ["Mit Beleg", "Ohne Beleg"]}
    facts_map = {"Alpha": {"mit beleg": {"key_points": ["kp alt"]}}}
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    n = await blocks._facts_nachfass(ctx, {"arbeit": tmp_path}, raw, facts_map, {}, None)
    assert n == 1
    assert gesehen["slim"] and gesehen["raw"] == {"Alpha": ["Ohne Beleg"]}
    assert facts_map["Alpha"]["ohne beleg"]["key_points"] == ["kp neu"]
    assert facts_map["Alpha"]["mit beleg"]["key_points"] == ["kp alt"]
    assert raw["Alpha"] == ["Mit Beleg", "Ohne Beleg"]  # kein Verwurf


async def test_levels_merge_fuzzy_match(testdb, tmp_path, monkeypatch):
    """Levels-Agent paraphrasiert den Sub-Titel → facts hängen trotzdem am Sidecar-Eintrag
    (eindeutiger Präfix-Match statt stillem Grounding-Verlust)."""
    db = testdb
    payload = {"title": "Alpha", "raw": {"Alpha": ["Marker Regel: Details dazu"]},
               "facts": {"Alpha": {blocks._norm_title("Marker Regel: Details dazu"):
                                   {"key_points": ["kp"]}}}}
    await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels", payload)
    cards = [{"card_id": "alpha", "payload": payload}]

    async def fake_levels_block(ctx, set_p, files, raw, instructions, ns="", lbl=""):
        return {"Alpha": [{"title": "Marker Regel", "level": "beginner"}]}  # gekürzter Titel

    monkeypatch.setattr(ba, "_levels_block", fake_levels_block)
    await ba._proc_levels(_ctx(), Flow(TOPIC, work_dir=tmp_path), {"arbeit": tmp_path}, "", cards)
    card = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
    assert card["payload"]["sidecar"]["Alpha"][0]["facts"] == {"key_points": ["kp"]}
