"""Board 1 end-to-end through the real engine — agents faked, no LLM, no embedding model."""

import json
import re

import pytest

import board_inventory as bi
import kanban
from pipeline import GenContext

TOPIC = "t"
B = bi.BOARD

_PATH_RE = re.compile(r"(/\S+\.json)")


def _fake_single_slot(tmp_path):
    """Deterministic judge stand-in: writes the expected JSON to the out_path found in the
    prompt and returns it — keyed off the agent-key naming convention."""

    async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
        m = _PATH_RE.search(prompt)
        out = None
        if "-pair-" in key:
            pairs = prompt.count("\nA: ")
            out = {"pairs": {str(i + 1): "ja" for i in range(pairs)}}
        elif "-dedup-" in key:
            pairs = prompt.count("\nA: ")
            out = {"pairs": {str(i + 1): "nein" for i in range(pairs)}}
        elif "-clarify-" in key:
            keep = [line[2:].split(" — ")[0] for line in prompt.splitlines()
                    if line.startswith("- ")]
            out = {"keep": keep, "rest": []}
        elif "-naming-" in key:
            out = {"best": 1}
        elif "-filter-recheck-" in key or "-filter-" in key:
            out = {"fragments": {}, "drop": []}
        elif "-gruppierung-completion-" in key:
            out = {"additions": []}
        elif "-gruppierung-" in key:
            out = {"umbrellas": []}
        elif "-supplement" in key:
            out = {"blocks": [{"title": "Zeta-Konzept", "description": "kanonisch fehlend"}]}
        if m and out is not None:
            with open(m.group(1), "w", encoding="utf-8") as f:
                json.dump(out, f)
        return "ok", payload(None)
    return fake


@pytest.fixture
async def board_env(testdb, tmp_path, monkeypatch):
    import board_artefacts as ba
    from textkit import _norm_title

    monkeypatch.setattr(bi, "run_single_slot", _fake_single_slot(tmp_path))

    # QA-Gate: standardmäßig saubere Fake-Note (kein Embedding-Load in Tests);
    # Gate-Tests überschreiben qa_report gezielt.
    import qa as qa_mod

    async def _fake_qa(topic, llm=False):
        return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {"status": "nicht generiert"}}
    monkeypatch.setattr(qa_mod, "qa_report", _fake_qa)
    monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
    monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)

    async def no_emb(flow):
        return False
    monkeypatch.setattr(bi, "_emb_ok", no_emb)

    async def fake_generate(ctx, files, title, description, instructions="", ns="", lbl="",
                            sources=None, seeds=None, melde=None):
        subs = ["Sub Eins", "Sub Zwei"]
        return {"raw": {title: list(subs)},
                "facts": {title: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
                                  for s in subs}},
                "unsicher": [], "votes": {}}

    async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
                          melde=None, keep_all=False):
        subs = gen["raw"].get(title) or []
        bfacts = gen["facts"].get(title) or {}
        sidecar = [{"title": s, "level": "beginner",
                    "relevance": "relevant" if i == 0 else "peripheral",
                    "facts": bfacts.get(_norm_title(s)) or {}}
                   for i, s in enumerate(subs)]
        return {"raw": {title: list(subs)}, "facts": {title: bfacts}, "sidecar": {title: sidecar}}

    async def fake_artefakte(ctx, files, title, sidecar_subs, instructions="", ns="", lbl="", melde=None):
        if not sidecar_subs:
            return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}}
        first = sidecar_subs[0]["title"]
        return {"pattern": {title: [{"subblock": first, "question": f"Was ist {title}?"}]},
                "artefacts": {"flashcard": [{"block": title, "subblock": first, "front": "F", "back": "B"}],
                              "example": []}}

    async def fake_outline(ctx, set_p, files, entries, instructions):
        return {"chapters": [{"title": "Kapitel 1", "numbers": sorted(entries)}]}

    for name, fn in [("_generate_block", fake_generate), ("_verify_block", fake_verify),
                     ("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]:
        monkeypatch.setattr(ba, name, fn)

    class _EmbOff:  # Cross-Block-Barrier reicht ohne Modell alle Karten durch
        @staticmethod
        def available():
            return False
    monkeypatch.setattr(ba, "embedding", _EmbOff)

    work = tmp_path / "arbeit"
    work.mkdir()
    files = {"arbeit": work, "final": tmp_path / "blocks.md",
             "sub_roh": tmp_path / "sub_roh.json", "sidecar": tmp_path / "subblocks.json",
             "facts": tmp_path / "facts.json", "question_pattern": tmp_path / "question_pattern.json",
             "artefakte": tmp_path / "artefakte.json", "outline": tmp_path / "outline.json",
             "outline_slots": tmp_path / "outline_slots"}
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    return testdb, ctx, files


async def _seed(db):
    # 2 consensus titles (2 readers), 1 single find (kept by panel), 1 artifact (pre-reject)
    await db.kanban_add_title(TOPIC, B, "alpha", "Alpha", "Grundkonzept", "s1", "r1")
    await db.kanban_add_title(TOPIC, B, "alpha", "Alpha", "Grundkonzept", "s2", "r2")
    await db.kanban_add_title(TOPIC, B, "beta", "Beta", "Zweites Konzept", "s1", "r1")
    await db.kanban_add_title(TOPIC, B, "beta", "Beta", "Zweites Konzept", "s2", "r2")
    await db.kanban_add_title(TOPIC, B, "gamma", "Gamma", "Einzelfund", "s1", "r1")
    await db.kanban_add_title(TOPIC, B, "aufgabe 3", "Aufgabe 3", "Übungszettel", "s1", "r1")


async def test_board1_full_flow(board_env):
    db, ctx, files = board_env
    await _seed(db)
    import asyncio
    ok = await asyncio.wait_for(
        bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
        timeout=30)
    assert ok
    done = await db.kanban_cards(TOPIC, board=B, stage="done_block")
    titles = sorted(c["payload"]["title"] for c in done)
    # Alpha/Beta/Gamma survive; the supplement's Zeta flows through the whole board too
    assert titles == ["Alpha", "Beta", "Gamma", "Zeta-Konzept"]
    rejected = await db.kanban_cards(TOPIC, board=B, stage="rejected")
    assert [c["payload"]["title"] for c in rejected] == ["Aufgabe 3"]
    assert rejected[0]["payload"]["reason"] == "pre-reject"
    # legacy mirror carries the survivors as consensus
    legacy = {b["title"] for b in await db.list_blocks(TOPIC, status="consensus")}
    assert legacy == set(titles)
    # blocks.md written in flow order
    lines = files["final"].read_text(encoding="utf-8").strip().splitlines()
    assert len(lines) == 4
    # reader union survived the pipeline (consensus evidence on the block card)
    alpha = next(c for c in done if c["payload"]["title"] == "Alpha")
    assert set(alpha["payload"]["readers"]) == {"r1", "r2"}
    assert alpha["payload"]["n_size"] == 2  # LPT estimate travels with the card
    # board 2: one artefact card per block ran through to done_artefact (+ outline singleton)
    art_done = await db.kanban_cards(TOPIC, board="artefacts", stage="done_artefact")
    assert len(art_done) == 5  # 4 blocks + outline card
    sidecar = json.loads(files["sidecar"].read_text(encoding="utf-8"))
    assert set(sidecar) == set(titles)
    assert sidecar["Alpha"][0]["facts"]["key_points"] == ["Fakt zu Sub Eins"]
    assert sidecar["Alpha"][0]["relevance"] == "relevant"
    assert sidecar["Alpha"][1]["relevance"] == "peripheral"
    # DB mirrors: subblocks, question pattern, artefacts, outline
    subs = await db.list_subblocks(TOPIC, "alpha")
    assert {s["sub_title"] for s in subs} == {"Sub Eins", "Sub Zwei"}
    outline = await db.get_outline(TOPIC)
    assert outline and "Kapitel 1" in outline
    # Lauf-Summary am Flow-Ende: run_id + Zähler (QA diffed dagegen)
    summary = json.loads((files["arbeit"] / "lauf-summary.json").read_text(encoding="utf-8"))
    assert summary["run_id"] and summary["topic"] == TOPIC
    assert summary["boards"].get("inventory", {}).get("done_block") == 4


async def test_abschluss_qa_events_tragen_run_id(board_env, monkeypatch):
    """Abschluss-QA läuft NACH run_flow — ihre Judge-Events müssen trotzdem die run_id
    des Laufs tragen (Lauf 20260704-1452-b223: run_id leer → aus jeder Aggregation gefallen)."""
    import asyncio

    import qa as qa_mod
    db, ctx, files = board_env
    await _seed(db)

    async def qa_mit_judge_event(topic, llm=False):
        # wie die echten LLM-Judges: run_agent schreibt ein agent-Event
        await db.add_event(topic, "agent", key=f"qa-{topic}-bausteine-0", status="ok")
        return {"note": 10.0, "topic": topic, "quoten": {}, "fremd": [],
                "artefakte": {"status": "nicht generiert"}}
    monkeypatch.setattr(qa_mod, "qa_report", qa_mit_judge_event)
    ok = await asyncio.wait_for(
        bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
        timeout=30)
    assert ok
    summary = json.loads((files["arbeit"] / "lauf-summary.json").read_text(encoding="utf-8"))
    conn = await db.get_db()
    rows = await (await conn.execute(
        "SELECT run_id FROM events WHERE topic=? AND key=?",
        (TOPIC, f"qa-{TOPIC}-bausteine-0"))).fetchall()
    assert rows and all(r[0] == summary["run_id"] for r in rows)
    # Registry nach dem Lauf geleert: manuelle QA bleibt korrekt ohne run_id
    await db.add_event(TOPIC, "agent", key="qa-manuell", status="ok")
    row = await (await conn.execute(
        "SELECT run_id FROM events WHERE topic=? AND key='qa-manuell'", (TOPIC,))).fetchone()
    assert row[0] == ""


async def test_filter_judges_run_parallel(board_env, monkeypatch):
    """40 Blöcke → 2 Filter-Chunks: die Judge-Welle muss parallel laufen (Perf-Fix)."""
    import asyncio
    db, ctx, files = board_env
    state = {"cur": 0, "max": 0}
    base = bi.run_single_slot  # instant fake from the fixture

    async def slow(*a, **k):
        state["cur"] += 1
        state["max"] = max(state["max"], state["cur"])
        try:
            await asyncio.sleep(0.05)
            return await base(*a, **k)
        finally:
            state["cur"] -= 1

    monkeypatch.setattr(bi, "run_single_slot", slow)
    for i in range(40):
        await db.kanban_upsert_card(TOPIC, B, f"b-x{i}", "block", "fragment_filter",
                                    {"title": f"Block {i}", "description": "d"})
    ok = await asyncio.wait_for(
        bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
        timeout=30)
    assert ok
    # 40 seeded + 1 supplement candidate (Zeta) flow through to done_block
    assert await db.kanban_count(TOPIC, "done_block", board=B) == 41
    assert state["max"] >= 2  # chunk judges ran as one wave, not sequentially


async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypatch):
    """Legitim leere Subbausteine (leere raw-Liste statt None) → Karte läuft bis done_artefact durch."""
    import asyncio
    import board_artefacts as ba
    import blocks as blx
    db, ctx, files = board_env

    async def empty_gen(ctx, files, title, description, instructions="", ns="", lbl="",
                        sources=None, seeds=None, melde=None):
        return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
    monkeypatch.setattr(ba, "_generate_block", empty_gen)
    await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "generate",
                                {"title": "Leerer Block", "description": "d"})
    ok = await asyncio.wait_for(
        bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
        timeout=30)
    assert ok
    card = await db.kanban_get_card(TOPIC, "artefacts", "leer")
    assert card["stage"] == "done_artefact"
    assert card["retries"] == 0 and not card.get("last_error")
    assert TOPIC not in blx._blocks_errors  # kein globales Banner


async def test_reader_union_folds_exact_dupes(testdb):
    db = testdb
    assert await db.kanban_add_title(TOPIC, B, "x", "X", "d", "s1", "r1") is True
    assert await db.kanban_add_title(TOPIC, B, "x", "X", "d länger", "s2", "r2") is False
    card = await db.kanban_get_card(TOPIC, B, "x")
    assert set(card["payload"]["readers"]) == {"r1", "r2"}
    assert set(card["payload"]["sources"]) == {"s1", "s2"}
    assert card["payload"]["description"] == "d länger"


# ── Fragment-Filter: Zweitmeinung, Containment, Floor, Supplement-Reopen ────────────

def _slot_router(handlers, counter=None):
    """Fully scripted judge: first matching key-substring wins, its JSON lands at out_path."""
    async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
        m = _PATH_RE.search(prompt)
        out = None
        for pat, h in handlers:
            if pat in key:
                if counter is not None:
                    counter[pat] = counter.get(pat, 0) + 1
                out = h(key) if callable(h) else h
                break
        if m and out is not None:
            with open(m.group(1), "w", encoding="utf-8") as f:
                json.dump(out, f)
        return "ok", payload(None)
    return fake


def _mk_flow(tmp_path):
    import asyncio
    from types import SimpleNamespace
    return SimpleNamespace(topic=TOPIC, work_dir=tmp_path, state={}, wake=asyncio.Event())


async def _run_filter(db, ctx, tmp_path, cards):
    """Seed block cards into fragment_filter and run ONE barrier pass over them."""
    for cid, p in cards:
        await db.kanban_upsert_card(TOPIC, B, cid, "block", "fragment_filter", p)
    rows = [{"card_id": cid, "payload": dict(p)} for cid, p in cards]
    await bi._proc_fragment_filter(ctx, _mk_flow(tmp_path), rows)


def _confirm_votes(votes, verdict):
    """Recheck judge j∈votes returns `verdict`, the rest keep everything."""
    return lambda key: verdict if key.rsplit("-j", 1)[1] in votes else {"fragments": {}, "drop": []}


async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch):
    """Judge-Demote ist nur Vorschlag — 2 Panel-Stimmen bestätigen → rejected."""
    db, ctx, files = board_env
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
        ("-filter-", {"fragments": {"1": 2}, "drop": []}),
    ]))
    await _run_filter(db, ctx, tmp_path, [
        ("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
        ("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
    ])
    c1 = await db.kanban_get_card(TOPIC, B, "b-1")
    assert c1["stage"] == "rejected"
    assert c1["payload"]["reason"] == "fragment"
    assert c1["payload"]["parent_norm"] == "codeblock"
    assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "dedup"


async def test_single_recheck_vote_confirms_proposal(board_env, tmp_path, monkeypatch):
    """M3.2: ein pass-1-Vorschlag wird schon von EINER Recheck-Stimme bestätigt → rejected.
    (15/17 mehrheitlich geretteten Karten waren später QA-Dubletten — Overturn nur einstimmig.)"""
    db, ctx, files = board_env
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-filter-recheck-", _confirm_votes({"1"}, {"fragments": {"1": 2}, "drop": []})),
        ("-filter-", {"fragments": {"1": 2}, "drop": []}),
    ]))
    await _run_filter(db, ctx, tmp_path, [
        ("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
        ("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
    ])
    c1 = await db.kanban_get_card(TOPIC, B, "b-1")
    assert c1["stage"] == "rejected"
    assert c1["payload"]["reason"] == "fragment"
    assert c1["payload"]["parent_norm"] == "codeblock"


async def test_unanimous_recheck_keep_overrules_proposal(board_env, tmp_path, monkeypatch):
    """M3.2: nur ein EINSTIMMIGES Recheck-Panel (0 Demote-Stimmen) hebt den pass-1-Vorschlag auf."""
    db, ctx, files = board_env
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-filter-recheck-", _confirm_votes(set(), {"fragments": {"1": 2}, "drop": []})),
        ("-filter-", {"fragments": {"1": 2}, "drop": []}),
    ]))
    await _run_filter(db, ctx, tmp_path, [
        ("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
        ("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
    ])
    assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup"
    journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
    assert journal["ueberstimmt"] == ["Blockzitat"]
    assert journal["degradiert"] == 0


async def test_containment_autoconfirm_skips_panel(board_env, tmp_path, monkeypatch):
    """Proposal mit Namens-Containment wird deterministisch committet — ohne Recheck-Call."""
    db, ctx, files = board_env
    counter = {}
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-filter-recheck-", {"fragments": {}, "drop": []}),
        ("-filter-", {"fragments": {"1": 2}, "drop": []}),
    ], counter))
    await _run_filter(db, ctx, tmp_path, [
        ("b-1", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}),
        ("b-2", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}),
    ])
    c1 = await db.kanban_get_card(TOPIC, B, "b-1")
    assert c1["stage"] == "rejected"
    assert c1["payload"]["parent_norm"] == "aufgabenlisten"
    assert "-filter-recheck-" not in counter  # no panel needed


async def test_floor_vetoes_structureless_demote(board_env, tmp_path, monkeypatch):
    """Orthogonale Titel-Vektoren: bestätigter Judge-Demote ohne Containment wird vetot,
    der Containment-Demote nicht."""
    import numpy as np
    db, ctx, files = board_env

    async def emb_on(flow):
        return True

    async def ortho_vecs(flow, texts):
        uniq = list(dict.fromkeys(texts))
        eye = np.eye(max(2, len(uniq)))
        pos = {t: eye[i] for i, t in enumerate(uniq)}
        return np.vstack([pos[t] for t in texts])

    monkeypatch.setattr(bi, "_emb_ok", emb_on)
    monkeypatch.setattr(bi, "_vec_rows", ortho_vecs)
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
        ("-filter-", {"fragments": {"1": 2, "3": 4}, "drop": []}),
    ]))
    await _run_filter(db, ctx, tmp_path, [
        ("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
        ("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
        ("b-3", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}),
        ("b-4", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}),
    ])
    assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup"  # floor veto
    assert (await db.kanban_get_card(TOPIC, B, "b-3"))["stage"] == "rejected"  # containment holds
    journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
    assert journal["floor_veto"] == ["Blockzitat"]


async def test_filter_resume_no_new_calls(board_env, tmp_path, monkeypatch):
    """Zweiter Lauf über identischem Zustand resumed alle Judge-Dateien: 0 neue Calls."""
    db, ctx, files = board_env
    counter = {}
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
        ("-filter-", {"fragments": {"1": 2}, "drop": []}),
    ], counter))
    cards = [("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
             ("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"})]
    await _run_filter(db, ctx, tmp_path, cards)
    first = dict(counter)
    assert first["-filter-"] == 1 and first["-filter-recheck-"] == 3
    await _run_filter(db, ctx, tmp_path, cards)
    assert counter == first


async def test_supplement_reopens_dead_lineage(board_env, tmp_path, monkeypatch):
    """Vorschlag trifft einen wegdegradierten Titel → Lineage wird wiedereröffnet;
    failed-quorum bleibt dedupt; frische Titel landen normal im ingest."""
    db, ctx, files = board_env
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-supplement", {"blocks": [
            {"title": "Blockzitate", "description": "Zitat-Syntax"},
            {"title": "Leerzeilen", "description": "Trenner"},
            {"title": "Neu-Konzept", "description": "fehlt kanonisch"},
        ]}),
    ]))
    # dead lineage: title → cluster cl-1 → block b-cl-1 demoted as fragment
    await db.kanban_add_title(TOPIC, B, "blockzitate", "Blockzitate", "d", "s1", "r1")
    await db.kanban_advance(TOPIC, B, "blockzitate", "clustered")
    await db.kanban_set_member(TOPIC, "blockzitate", "cl-1")
    await db.kanban_upsert_card(TOPIC, B, "cl-1", "cluster", "done_cluster", {"title": "Blockzitate"})
    await db.kanban_upsert_card(TOPIC, B, "b-cl-1", "block", "rejected",
                                {"title": "Blockzitate (Blockquotes)", "reason": "fragment",
                                 "cluster": "cl-1", "parent_norm": "codeblöcke"})
    # failed-quorum lineage: deliberately rejected as non-block → must stay deduped
    await db.kanban_add_title(TOPIC, B, "leerzeilen", "Leerzeilen", "d", "s1", "r1")
    await db.kanban_advance(TOPIC, B, "leerzeilen", "clustered")
    await db.kanban_set_member(TOPIC, "leerzeilen", "cl-2")
    await db.kanban_upsert_card(TOPIC, B, "cl-2", "cluster", "rejected",
                                {"title": "Leerzeilen", "reason": "failed-quorum"})
    flow = _mk_flow(tmp_path)
    flow.state["instructions"] = ""
    await bi._supplement_producer(ctx, flow, ["Codeblöcke"])
    reopened = await db.kanban_get_card(TOPIC, B, "blockzitate")
    assert reopened["stage"] == "cluster"
    assert reopened["payload"]["supplement"] is True
    assert (await db.kanban_get_card(TOPIC, B, "leerzeilen"))["stage"] == "clustered"
    fresh = await db.kanban_get_card(TOPIC, B, "neu-konzept")
    assert fresh and fresh["stage"] == "ingest" and fresh["payload"]["supplement"] is True


# ── Dedup-Stage: globaler Paar-Abgleich nach dem Naming ─────────────────────────────

def _angle_vecs(mapping):
    """Vector fake with controllable cosine: mapped substring → angle (degrees) in a
    shared 2D plane; unmapped texts get their own orthogonal axis (cos 0 to everything)."""
    import math as m
    import numpy as np

    async def fake(flow, texts):
        dim = 2 + len(texts)
        rows = []
        for i, t in enumerate(texts):
            v = np.zeros(dim)
            for key, deg in mapping.items():
                if key in t:
                    v[0], v[1] = m.cos(m.radians(deg)), m.sin(m.radians(deg))
                    break
            else:
                v[2 + i] = 1.0
            rows.append(v)
        return np.vstack(rows)
    return fake


async def _run_dedup(db, ctx, tmp_path, cards):
    """Seed block cards into dedup and run ONE barrier pass over them."""
    for cid, p in cards:
        await db.kanban_upsert_card(TOPIC, B, cid, "block", "dedup", p)
    rows = [{"card_id": cid, "payload": dict(p)} for cid, p in cards]
    await bi._proc_dedup(ctx, _mk_flow(tmp_path), rows)


@pytest.fixture
def emb_on(monkeypatch):
    async def yes(flow):
        return True
    monkeypatch.setattr(bi, "_emb_ok", yes)


async def test_dedup_merges_confirmed_pair(board_env, tmp_path, monkeypatch, emb_on):
    """Judge-„ja" merged: Verlierer → grouped (reason/merged_into), Champion sammelt reader."""
    # Titel-Cos ~0.77 (unter Auto-0.95) → Kandidat, der Judge entscheidet
    monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
    counter = {}
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-dedup-", {"pairs": {"1": "ja"}}),
    ], counter))
    await _run_dedup(db_ := board_env[0], ctx := board_env[1], tmp_path, [
        ("b-1", {"title": "SAT", "description": "kurz", "readers": ["r1"]}),
        ("b-2", {"title": "SAT (Satisfiability Problem)",
                 "description": "Erfüllbarkeit Boolescher Ausdrücke", "readers": ["r2"]}),
    ])
    loser = await db_.kanban_get_card(TOPIC, B, "b-1")
    champ = await db_.kanban_get_card(TOPIC, B, "b-2")
    assert loser["stage"] == "grouped"
    assert loser["payload"]["reason"] == "merged"
    assert loser["payload"]["merged_into"] == "SAT (Satisfiability Problem)"
    assert champ["stage"] == "grouping"
    assert set(champ["payload"]["readers"]) == {"r1", "r2"}
    journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8"))
    assert journal["merged"] == [{"dublette": "SAT", "in": "SAT (Satisfiability Problem)"}]
    assert journal["paare_detail"][0]["verdict"] == "ja"
    assert counter["-dedup-"] == 2  # Zwei-Judge-Panel


async def test_dedup_judge_nein_keeps_both(board_env, tmp_path, monkeypatch, emb_on):
    monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"modifizierter": 0, "greedy": 40}))
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-dedup-", {"pairs": {"1": "nein"}}),
    ]))
    await _run_dedup(board_env[0], board_env[1], tmp_path, [
        ("b-1", {"title": "Greedy-Algorithmus", "description": "Basisverfahren"}),
        ("b-2", {"title": "Modifizierter Greedy-Algorithmus", "description": "Variante"}),
    ])
    assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
    assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"


async def test_dedup_relation_guard_blocks_identical_tokens(board_env, tmp_path, monkeypatch, emb_on):
    """Gleiche Tokens, andere Richtung: Judge sagt „ja", Titel-Cos 1.0 (Auto-Kante) —
    der Relation-Guard blockt beides, beide Karten überleben."""
    monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"hamiltonian": 0}))
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-dedup-", lambda key: {"pairs": {"1": "ja"}}),
    ]))
    await _run_dedup(board_env[0], board_env[1], tmp_path, [
        ("b-1", {"title": "Hamiltonian Cycle ≤ Hamiltonian Path", "description": "Reduktion"}),
        ("b-2", {"title": "Hamiltonian Path ≤ Hamiltonian Cycle", "description": "Reduktion"}),
    ])
    assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
    assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
    journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8"))
    assert journal["paare_detail"][0]["verdict"] == "guard_veto"


async def test_dedup_panel_disagreement_keeps_both(board_env, tmp_path, monkeypatch, emb_on):
    """Merge braucht Einstimmigkeit: j1 ja + j2 nein → beide überleben (Journal: uneinig)."""
    monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-dedup-", lambda key: {"pairs": {"1": "ja" if "-j1" in key else "nein"}}),
    ]))
    await _run_dedup(board_env[0], board_env[1], tmp_path, [
        ("b-1", {"title": "SAT", "description": "d1"}),
        ("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d2"}),
    ])
    assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
    assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
    journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8"))
    assert journal["paare_detail"][0]["verdict"] == "uneinig"


async def test_dedup_title_only_candidate(board_env, tmp_path, monkeypatch, emb_on):
    """Titel-Cos über dem Floor reicht als Kandidat — auch wenn der Mittelwert
    (verschiedene Beschreibungs-Facetten) darunter liegt."""
    # Beschreibungen fast orthogonal (0° vs 80°), Titel ähnlich (0° vs 40°)
    monkeypatch.setattr(bi, "_vec_rows", _angle_vecs(
        {"völlig": 0, "ganz": 80, "alpha kern": 0, "alpha zentrum": 40}))
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-dedup-", {"pairs": {"1": "ja"}}),
    ]))
    await _run_dedup(board_env[0], board_env[1], tmp_path, [
        ("b-1", {"title": "Alpha Kern", "description": "völlig anderes"}),
        ("b-2", {"title": "Alpha Zentrum", "description": "ganz anders zwei"}),
    ])
    stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"]
                     for c in ("b-1", "b-2")])
    assert stages == ["grouped", "grouping"]  # Paar wurde gejudged und merged


def test_canonical_key_camel_and_catalogue():
    """CamelCase-Split + Katalog-Phrasen-Strip; Varianten-Ziffern bleiben erhalten."""
    from blocks import _canonical_key as k
    assert k("Set Cover") == k("SetCover-Problem") != ""
    assert k("Definition 6.19 (NP)") == "np"
    assert k("SAT") != k("3-SAT")  # Varianten-Ziffer ist Signal, keine Katalognummer
    assert k("ModifiedGreedy") == k("Modified Greedy")
    # englische Katalog-Phrasen gleichwertig (Quellen sind nicht immer deutsch)
    assert k("Corollary 3.2 (VC)") == "vc"
    assert k("Chapter 7: Vertex Cover") == k("Vertex Cover")
    assert k("Section 2.1 Matching") == k("Matching")


def test_canonical_key_glued_problem_suffix():
    """Verklebtes Kompositum-Suffix „…problem" wird abgetrennt, damit „Cliquenproblem" und
    „Clique" denselben Blocking-Key teilen (aak: sonst nie Dedup-Kandidat). Fugen-n/-s + Plural
    inklusive; die generische Suffix-Regel darf keine echten Varianten über-mergen."""
    from blocks import _canonical_key as k
    assert k("Clique") == k("Cliquenproblem") == k("Cliquenprobleme") != ""
    assert k("Set Cover") == k("SetCover-Problem")            # bestehender Hyphen-Pfad bleibt
    assert k("SAT") != k("3-SAT")                             # Varianten-Ziffer bleibt Signal
    assert k("Problem") != k("Clique")                        # bloßes „Problem" wird nicht zum Stamm


def test_reference_strip_and_is_reference():
    """Katalog-Nummern werden gestrippt, der Konzeptname bleibt; reine Nummern → leer + Referenz.
    Reale aak-Schadensfälle (Satz-/Bemerkungs-Titel liefen wörtlich bis done)."""
    from blocks import _reference_strip as strip, _is_reference as isref
    assert strip("Satz 7.13 (Christofides)") == "Christofides"
    assert strip("Satz 7.6: Kriterium für Eulerschen Kreis") == "Kriterium für Eulerschen Kreis"
    assert strip("N P via nicht-deterministische Turingmaschine (Definition 6.19)") \
        == "N P via nicht-deterministische Turingmaschine"
    assert strip("Bemerkung 7.22") == ""
    assert strip("Vertex Cover") == "Vertex Cover"            # kein Katalog-Gerüst → unverändert
    assert isref("Bemerkung 7.22") and isref("Satz 7.18") and isref("Korollar 6.18")
    assert isref("Bedingung (**)")
    assert not isref("Satz 7.13 (Christofides)")              # hat Konzept → keine Referenz
    assert not isref("Satz 7.6: Kriterium für Eulerschen Kreis")
    assert not isref("P⊆NP") and not isref("Σ*")             # kurze Symbole bleiben echt


def test_is_named_statement_construction_suffix():
    """Reduktion mit Konstruktions-Suffix (: / = nach dem Ziel) ist ein Fragment, kein Statement —
    darf nicht mehr vor Demotion geschützt sein (aak: „3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")."""
    from blocks import _is_named_statement as named
    assert not named("3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")
    assert named("3-SAT ≤ Clique")                           # saubere Reduktion bleibt geschützt
    assert named("Clique → Vertex Cover")
    assert not named("X ist NP-vollständig")                 # unäre Aussage bleibt demotable
    assert named("Satz von Cook/Levin: SAT ist NP-vollständig ⇔ …")  # benanntes Ergebnis bleibt


def test_relation_guard_ignores_trailing_scaffolding():
    """Trailing „Reduktion/Transformation" ist kein Operand — sonst blockt der Guard
    den korrekten Merge; Richtungs-Konflikte bleiben erkannt."""
    from blocks import _relation_conflict as c
    assert not c("SetCover ≤ HittingSet", "SetCover ≤ HittingSet Reduktion")
    assert not c("A → B", "A → B Transformation")
    assert c("Hamiltonian Cycle ≤ Hamiltonian Path", "Hamiltonian Path ≤ Hamiltonian Cycle")


def test_relation_guard_english_and_operator_suffix():
    """Englisches „Reduction" ist Scaffolding wie „Reduktion"; ein angehängtes
    p/m am Operator („≤p") gehört zum Operator, nicht zum Operanden.
    Beides waren Fehl-Vetos im aak-Lauf. Varianten-Konflikte bleiben."""
    from blocks import _relation_conflict as c
    assert not c("3-Exact Cover ≤ SubSet Sum", "Reduction 3-EXACT COVER ≤ SUBSET SUM")
    assert not c("k-CLIQUE ≤ k-INDEPENDENT SET", "Reduction k-CLIQUE ≤ k-INDEPENDENT SET")
    assert not c("3-SAT ≤ 3-Färbung", "3-SAT ≤p 3-Färbung")
    assert c("SAT ≤ Clique", "3-SAT ≤ Clique")  # Variante als Operand bleibt Konflikt


async def test_dedup_casefold_title_candidate(board_env, tmp_path, monkeypatch, emb_on):
    """GROSSSCHREIBUNG darf den Titel-Kanal nicht brechen: Titel werden casefolded
    eingebettet („VERTEX COVER" vs. „Vertex Cover (VC)" lag real bei Cos 0.55)."""
    monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"vertex cover": 0}))
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-dedup-", {"pairs": {"1": "ja"}}),
    ]))
    await _run_dedup(board_env[0], board_env[1], tmp_path, [
        ("b-1", {"title": "VERTEX COVER", "description": "knapp"}),
        ("b-2", {"title": "Vertex Cover (VC)", "description": "Knotenüberdeckung ausführlich"}),
    ])
    stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"]
                     for c in ("b-1", "b-2")])
    assert stages == ["grouped", "grouping"]


async def test_dedup_no_embedding_passes_through(board_env, tmp_path, monkeypatch):
    """Ohne Embedding-Modell winkt die Stage durch — 0 Agent-Calls (kein n²-Fallback)."""
    counter = {}
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([("-dedup-", {"pairs": {}})], counter))
    await _run_dedup(board_env[0], board_env[1], tmp_path, [
        ("b-1", {"title": "SAT", "description": "d"}),
        ("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d"}),
    ])
    assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
    assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
    assert counter == {}


async def test_dedup_second_wave_merges_into_context(board_env, tmp_path, monkeypatch, emb_on):
    """Supplement-Welle: Neuling merged in den bestätigten Block; der bleibt unberührt."""
    db, ctx, files = board_env
    monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-dedup-", {"pairs": {"1": "ja"}}),
    ]))
    await db.kanban_upsert_card(TOPIC, B, "b-old", "block", "done_block",
                                {"title": "SAT", "description": "Erfüllbarkeitsproblem",
                                 "readers": ["r1"], "mirrored_norm": "sat"})
    await db.upsert_block(TOPIC, "sat", "SAT", "Erfüllbarkeitsproblem", [])
    await _run_dedup(db, ctx, tmp_path, [
        ("b-new", {"title": "SAT (Satisfiability Problem)", "description": "kurz", "readers": ["r9"]}),
    ])
    new = await db.kanban_get_card(TOPIC, B, "b-new")
    old = await db.kanban_get_card(TOPIC, B, "b-old")
    assert new["stage"] == "grouped"
    assert new["payload"]["merged_into"] == "SAT"
    assert old["stage"] == "done_block"  # context never demoted
    assert set(old["payload"]["readers"]) == {"r1", "r9"}


async def test_dedup_resume_no_new_calls(board_env, tmp_path, monkeypatch, emb_on):
    monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40}))
    counter = {}
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-dedup-", {"pairs": {"1": "nein"}}),
    ], counter))
    cards = [("b-1", {"title": "SAT", "description": "d1"}),
             ("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d2"})]
    await _run_dedup(board_env[0], board_env[1], tmp_path, cards)
    assert counter["-dedup-"] == 2  # zwei Panel-Judges
    await _run_dedup(board_env[0], board_env[1], tmp_path, cards)
    assert counter["-dedup-"] == 2  # judge files reused


async def test_dedup_complete_link_no_chaining(board_env, tmp_path, monkeypatch, emb_on):
    """A≈B ja, B≈C ja, A–C kein Kandidat → complete-link merged nur ein Paar."""
    # Winkel 0/30/60: A-B und B-C sind Kandidaten (cos .87), A-C nicht (cos .5 < Floor)
    monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"alpha": 0, "beta": 30, "gamma": 60}))
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-dedup-", {"pairs": {"1": "ja", "2": "ja"}}),
    ]))
    await _run_dedup(board_env[0], board_env[1], tmp_path, [
        ("b-1", {"title": "Konzept Alpha", "description": "a"}),
        ("b-2", {"title": "Konzept Beta", "description": "bb"}),
        ("b-3", {"title": "Konzept Gamma", "description": "c"}),
    ])
    db = board_env[0]
    stages = {cid: (await db.kanban_get_card(TOPIC, B, cid))["stage"]
              for cid in ("b-1", "b-2", "b-3")}
    assert sorted(stages.values()) == ["grouped", "grouping", "grouping"]


# ── Makespan: Slot-Priorität, vorgezogene Gliederung ────────────────────────────────

def test_agent_priority_order():
    """Board 1 zuerst; in Board 2 gewinnen späte Stages (Restarbeit vor Nachschub)."""
    from agents import _agent_priority as p
    t = "blocks-Markdown"
    assert p(f"{t}-research-1") < p(f"{t}-filter-abc-c0") < p(f"{t}-dedup-abc-c0") < p(f"{t}-supplement")
    # Gruppierung-Keys heißen "gruppierung": ohne eigenen Eintrag fielen sie ans Ende
    # und verhungerten hinter Board 2 (aak: 211 min Slot-Wartezeit)
    assert p(f"{t}-dedup-abc-c0") < p(f"{t}-gruppierung-xyz-cTOP") < p(f"{t}-supplement")
    assert p(f"{t}-gruppierung-xyz-cTOP") < p(f"{t}-ns-subblock-c1-r2-1")
    assert (p(f"{t}-outline-judge") < p(f"{t}-ns-artifact-example-c0")
            < p(f"{t}-ns-question-pattern-c0") < p(f"{t}-ns-relevance-final-c0")
            < p(f"{t}-ns-level-final-c0") < p(f"{t}-ns-facts-erg-c0")
            < p(f"{t}-ns-subblock-c1-r2-1"))
    assert p(f"{t}-supplement") < p(f"{t}-outline-1")
    assert p("guide-t-writer-k1") == 18  # unmatched → after everything


async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
    """Gliederung startet, sobald alle Karten die generate-Stage passiert haben —
    parallel zu den restlichen Artefakt-Stages des langsamsten Blocks."""
    import asyncio
    import board_artefacts as ba
    db, ctx, files = board_env
    await _seed(db)
    base_verify = ba._verify_block
    snapshot = {}

    async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
                          melde=None, keep_all=False):
        await asyncio.sleep(0.8)  # keeps one card in `verify` while the outline fires
        return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl,
                                 sources=sources, keep_all=keep_all)

    base_outline = ba._outline_block

    async def spy_outline(ctx, set_p, files, entries, instructions):
        cards = await db.kanban_cards(TOPIC, board="artefacts", kind="ablock")
        snapshot["unfinished"] = sum(1 for c in cards if c["stage"] != "done_artefact")
        return await base_outline(ctx, set_p, files, entries, instructions)

    monkeypatch.setattr(ba, "_verify_block", slow_verify)
    monkeypatch.setattr(ba, "_outline_block", spy_outline)
    ok = await asyncio.wait_for(
        bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
        timeout=30)
    assert ok
    assert snapshot["unfinished"] > 0  # outline ran while blocks were still in levels+
    outline = await db.get_outline(TOPIC)
    assert outline and "Kapitel 1" in outline


async def test_outline_facts_from_payloads(board_env, tmp_path):
    """_proc_outline speist die Prereq-Hints aus den Karten-Payloads —
    unabhängig vom globalen facts.json (das erst finalize schreibt)."""
    import board_artefacts as ba
    db, ctx, files = board_env
    await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "done_block",
                                {"title": "Alpha", "description": "d"})
    await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "verify",
                                {"title": "Alpha", "facts": {"Alpha": {"sub eins": {
                                    "sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}})
    await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline",
                                {"title": "Gliederung"})
    flow = _mk_flow(tmp_path)
    await ba._proc_outline(ctx, flow, files, "", [{"card_id": "outline", "payload": {}}])
    merged = json.loads((tmp_path / "outline-facts.json").read_text(encoding="utf-8"))
    assert merged["Alpha"]["sub eins"]["prerequisites"] == "Beta zuerst"
    assert (await db.kanban_get_card(TOPIC, "artefacts", "outline"))["stage"] == "done_artefact"


async def test_card_view_stepper(testdb):
    """Aktive artefacts-Karte mit Step-Name → step_i/step_n; Alt-String bleibt tolerierbar."""
    r = {"board": "artefacts", "card_id": "alpha", "stage": "verify", "retries": 0,
         "payload": {"title": "Alpha"}}
    v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Fix 1/2…", "step": "Fix"}})
    assert v["status"] == "active" and v["info"] == "Fix 1/2…"
    assert v["step_i"] == 2 and v["step_n"] == 2 and v["steps"][0] == "Verify"
    # legacy plain-string live info → no stepper, no crash
    v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Verify 0/1…"})
    assert v2["info"] == "Verify 0/1…" and "step_n" not in v2
    # step outside the card's stage group (e.g. supplement note) → no stepper
    v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Generate"}})
    assert "step_n" not in v3


async def test_seed_map_resolves_cascade(testdb):
    """Seeds folgen der Redirect-Kette bis zum lebenden Block; Zyklen/Dead-Ends verfallen."""
    import board_artefacts as ba
    db = testdb
    # chain: fragment → grouped member → living umbrella (with self-edge Listen→Listen)
    await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "rejected",
                                {"title": "Blockzitate (Blockquotes)", "reason": "fragment",
                                 "parent_norm": "eingerückte codeblöcke"})
    await db.kanban_upsert_card(TOPIC, B, "b-2", "block", "grouped",
                                {"title": "Eingerückte Codeblöcke", "merged_into": "Codeblöcke"})
    await db.kanban_upsert_card(TOPIC, B, "b-3", "block", "done_block",
                                {"title": "Codeblöcke", "mirrored_norm": "codeblöcke"})
    await db.kanban_upsert_card(TOPIC, B, "b-4", "block", "grouped",
                                {"title": "Listen", "merged_into": "Listen"})
    await db.kanban_upsert_card(TOPIC, B, "b-5", "block", "done_block",
                                {"title": "Listen", "mirrored_norm": "listen"})
    await db.kanban_upsert_card(TOPIC, B, "b-6", "block", "rejected",
                                {"title": "Aufgabenlisten", "reason": "fragment", "parent_norm": "listen"})
    # cycle: a → b → a, neither alive
    await db.kanban_upsert_card(TOPIC, B, "b-7", "block", "rejected",
                                {"title": "A-Ding", "reason": "fragment", "parent_norm": "b-ding"})
    await db.kanban_upsert_card(TOPIC, B, "b-8", "block", "rejected",
                                {"title": "B-Ding", "reason": "fragment", "parent_norm": "a-ding"})
    seeds = await ba._seed_map(TOPIC)
    # grouped umbrella members become seeds of their living target too (whole absorbed topics)
    assert {k: sorted(v) for k, v in seeds.items()} == {
        "codeblöcke": ["Blockzitate (Blockquotes)", "Eingerückte Codeblöcke"],
        "listen": ["Aufgabenlisten", "Listen"]}


def test_per_block_functions_accept_wrapper_kwargs():
    """Die board_artefacts-Prozessoren übergeben ns/lbl (generate auch seeds/sources) —
    ein fehlender Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken
    die Signatur)."""
    import inspect
    import block_calls as bc
    for fn in ("_generate_block", "_verify_block", "_artefakte_block"):
        params = inspect.signature(getattr(bc, fn)).parameters
        assert "ns" in params and "lbl" in params, fn
    assert "seeds" in inspect.signature(bc._generate_block).parameters
    for fn in ("_generate_block", "_verify_block"):  # Board 2 reicht die Block-Quellen durch
        assert "sources" in inspect.signature(getattr(bc, fn)).parameters, fn


# ── Inventar-Härtung: Sanitizer, Akronym-Regel, Supplement-Beleg ─────────────────────

def test_clean_title_strips_markdown_only():
    """`**` und Backticks fliegen; Math-Zeichen (|, _, einzelnes *) bleiben."""
    from textkit import clean_title
    assert clean_title("**ListScheduling**") == "ListScheduling"
    assert clean_title("**LPT** - Algo") == "LPT - Algo"
    assert clean_title("`code`  doppelt") == "code doppelt"
    assert clean_title("2|prec, pi∈{1,2}|Cmax") == "2|prec, pi∈{1,2}|Cmax"
    assert clean_title("x_i und P*") == "x_i und P*"


def test_title_variants_acronym_expansion():
    from blocks import _title_variants
    assert _title_variants("Satisfiability Problem (SAT)") == {"satisfiability problem", "sat"}
    assert _title_variants("DEA (Deterministischer Endlicher Automat)") == {
        "dea", "deterministischer endlicher automat"}
    assert _title_variants("SAT") == set()
    assert _title_variants("2|prec, pi∈{1,2}|Cmax") == set()


async def test_dedup_acronym_pair_judged(board_env, tmp_path, monkeypatch, emb_on):
    """Kurzform vs. Langform liegt unterm Embedding-Floor (real: Cos 0.53) — die
    Akronym-Regel macht das Paar trotzdem zum Kandidaten, das Panel merged bei 2× ja."""
    monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({}))  # alles orthogonal
    counter = {}
    monkeypatch.setattr(bi, "run_single_slot", _slot_router([
        ("-dedup-", {"pairs": {"1": "ja"}}),
    ], counter))
    await _run_dedup(board_env[0], board_env[1], tmp_path, [
        ("b-1", {"title": "SAT", "description": "kurz"}),
        ("b-2", {"title": "Satisfiability Problem (SAT)", "description": "lang"}),
    ])
    stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"]
                     for c in ("b-1", "b-2")])
    assert stages == ["grouped", "grouping"]
    assert counter["-dedup-"] == 2  # Panel lief — kein Auto-Merge


async def test_supplement_beleg_gate(board_env, tmp_path, monkeypatch):
    """Ohne Auszugs-Treffer fällt ein Vorschlag sofort; der Judge verwirft „nein";
    nur belegte Vorschläge überleben. Antwort ist Text (no-tool), Engine persistiert."""
    db, ctx, files = board_env
    korpus = tmp_path / "korpus"
    korpus.mkdir()
    (korpus / "Skript.txt").write_text(
        "Kapitel 1\nVertex Cover Definition und Übung dazu.\nMatching Grundlagen kurz.\n",
        encoding="utf-8")
    monkeypatch.setattr(bi, "source_folder", lambda t: korpus)
    calls = {}

    async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
        calls["key"], calls["caps"], calls["prompt"] = key, capabilities, prompt
        return bi.OK, payload((0, '{"relevant": {"1": "ja", "2": "nein"}}', ""))

    monkeypatch.setattr(bi, "run_single_slot", fake_slot)
    kept = await bi._supplement_beleg(ctx, _mk_flow(tmp_path), [
        ("Vertex Cover", "Knotenüberdeckung"),
        ("Matching", "Paarung"),
        ("Quantencomputer", "nicht im Material"),
    ])
    assert kept == [("Vertex Cover", "Knotenüberdeckung")]
    assert calls["caps"] == "none" and "-supplement-beleg" in calls["key"]
    assert "Quantencomputer" not in calls["prompt"]  # fiel schon am Auszugs-Filter
    assert (tmp_path / "supplement-beleg.json").exists()  # Resume-Guard


async def test_ingest_strips_markdown_title(testdb, tmp_path):
    flow = _mk_flow(tmp_path)
    n = await bi._ingest_titles(flow, "1. **ListScheduling** — Greedy-Verfahren", "r1")
    assert n == 1
    card = await testdb.kanban_get_card(TOPIC, B, "listscheduling")
    assert card["payload"]["title"] == "ListScheduling"


# ── QA-Gate: Inventar-Prüfung vor Board 2 ────────────────────────────────────────────

async def _run_flow(ctx, files, timeout=30, **kw):
    import asyncio
    return await asyncio.wait_for(
        bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False, **kw),
        timeout=timeout)


async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch):
    """Auto an, aber Repair schließt die Befunde nicht → Stillstand → Flow pausiert,
    Board-2-Karten warten in generate."""
    import qa as qa_mod
    import repair as repair_mod
    db, ctx, files = board_env

    async def bad_qa(topic, llm=False):
        return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}

    async def stuck_repair(topic, ebene=None):
        return {"note": 5.0}  # bewegt nichts

    monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
    monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
    monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair)
    monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
    await _seed(db)
    ok = await _run_flow(ctx, files)
    assert ok
    warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate")
    assert len(warten) == 4  # alle Blöcke gespawnt, keiner verarbeitet
    assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0


async def test_qa_gate_auto_reaches_100(board_env, monkeypatch):
    """Auto an, Repair hebt die Note auf 100 % → Gate öffnet, Board 2 läuft durch."""
    import qa as qa_mod
    import repair as repair_mod
    db, ctx, files = board_env

    async def bad_qa(topic, llm=False):
        return {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}

    async def good_repair(topic, ebene=None):
        return {"note": 10.0}

    monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
    monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
    monkeypatch.setattr(repair_mod, "repair_befunde", good_repair)
    monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
    await _seed(db)
    ok = await _run_flow(ctx, files)
    assert ok
    assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5


async def test_qa_gate_auto_off_pauses(board_env, monkeypatch):
    """Auto aus → nach der QA anhalten (Kontrollpunkt), egal welche Note; kein Repair."""
    import qa as qa_mod
    import repair as repair_mod
    db, ctx, files = board_env

    async def good_qa(topic, llm=False):
        return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}

    async def no_repair(topic, ebene=None):
        raise AssertionError("Auto aus darf nicht reparieren")

    monkeypatch.setattr(qa_mod, "qa_report", good_qa)
    monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
    monkeypatch.setattr(repair_mod, "repair_befunde", no_repair)
    monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
    await _seed(db)
    ok = await _run_flow(ctx, files, auto_inventory=False)
    assert ok
    warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate")
    assert len(warten) == 4  # Board 2 wartet trotz Note 10.0 (Auto aus = Kontrollpunkt)


async def test_qa_gate_force_overrides(board_env, monkeypatch):
    """qa_force=True („Trotzdem fortsetzen") übersteuert die schlechte Note."""
    import qa as qa_mod
    db, ctx, files = board_env
    called = {"n": 0}

    async def bad_qa(topic, llm=False):
        called["n"] += 1
        return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
    monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
    monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
    monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
    await _seed(db)
    ok = await _run_flow(ctx, files, qa_force=True)
    assert ok
    assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5
    assert called["n"] <= 2  # Gate-QA übersprungen (force); nur Artefakt-Loop + Abschluss-QA


async def test_qa_gate_off_means_no_qa_call(board_env, monkeypatch):
    import qa as qa_mod
    db, ctx, files = board_env
    called = {"n": 0}

    async def spy_qa(topic, llm=False):
        called["n"] += 1
        return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
    monkeypatch.setattr(qa_mod, "qa_report", spy_qa)
    monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
    monkeypatch.setattr(bi, "QA_GATE_NOTE", 0)
    await _seed(db)
    ok = await _run_flow(ctx, files)
    assert ok
    assert called["n"] == 2  # kein Gate-Lauf; Artefakt-Loop-QA + Abschluss-QA der Lauf-Summary


async def test_qa_gate_fail_open(board_env, monkeypatch):
    """QA crasht → Gate öffnet, Flow läuft komplett durch (fail-open)."""
    import qa as qa_mod
    db, ctx, files = board_env

    async def broken_qa(topic, llm=False):
        raise RuntimeError("kaputt")
    monkeypatch.setattr(qa_mod, "qa_report", broken_qa)
    monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
    await _seed(db)
    ok = await _run_flow(ctx, files)
    assert ok
    assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5


def test_qa_view_pausiert_logic(tmp_path, monkeypatch):
    import qa as qa_mod
    import json as _json
    monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path)
    (tmp_path / TOPIC).mkdir()
    (tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
        {"note": 5.0, "quoten": {"fremd": 0.2}, "fremd": ["X"], "unecht": ["Y"]}), encoding="utf-8")
    counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}}
    v = bi._qa_view(TOPIC, counts, None)
    assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"]
    from types import SimpleNamespace
    laufend = SimpleNamespace(state={})
    assert bi._qa_view(TOPIC, counts, laufend)["pausiert"] is False  # Flow läuft noch
    # Bausteine gelöscht → kein Badge, obwohl der Report noch existiert
    assert bi._qa_view(TOPIC, {}, None) is None


def test_qa_view_picks_newest_by_mtime(tmp_path, monkeypatch):
    """Run-id-Namen (…-1311-5e5c) sortieren lexikographisch VOR Zeitstempel-Namen —
    ein Re-Run überschreibt die run-id-Datei, das Badge muss trotzdem sie zeigen."""
    import os
    import qa as qa_mod
    import json as _json
    monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path)
    (tmp_path / TOPIC).mkdir()
    alt = tmp_path / TOPIC / "20260703-141649.json"
    alt.write_text(_json.dumps({"note": 10.0, "quoten": {}}), encoding="utf-8")
    os.utime(alt, (1000, 1000))
    neu = tmp_path / TOPIC / "20260703-1311-5e5c.json"
    neu.write_text(_json.dumps({"note": 8.9, "quoten": {}}), encoding="utf-8")
    os.utime(neu, (2000, 2000))
    v = bi._qa_view(TOPIC, {"inventory": {"done_block": 3}}, None)
    assert v["note"] == 8.9


async def test_supplement_material_mode_for_source_topics(board_env, tmp_path, monkeypatch):
    """Quellen-Thema: Supplement vergleicht gegen das MATERIAL (files, kein Web);
    thema-Modus behält die Websuche (voller Zugriff)."""
    db, ctx, files = board_env
    seen = {}

    async def spy_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
        seen[key] = (capabilities, prompt)
        m = _PATH_RE.search(prompt)
        if m and "-supplement" in key and "-beleg" not in key:
            with open(m.group(1), "w", encoding="utf-8") as f:
                json.dump({"blocks": []}, f)
        return "ok", payload(None)

    monkeypatch.setattr(bi, "run_single_slot", spy_slot)
    flow = _mk_flow(tmp_path)
    korpus = tmp_path / "korpus"
    korpus.mkdir()
    monkeypatch.setattr(bi, "source_folder", lambda t: korpus)
    await bi._supplement_producer(ctx, flow, ["Alpha"])
    caps, prompt = seen[f"blocks-{TOPIC}-supplement"]
    assert caps == "files"
    assert "LEARNING MATERIAL" in prompt and "Do NOT search the web" in prompt

    seen.clear()
    (tmp_path / "supplement.json").unlink()  # Resume-Guard zurücksetzen
    monkeypatch.setattr(bi, "source_folder", lambda t: None)
    await bi._supplement_producer(ctx, flow, ["Alpha"])
    caps, prompt = seen[f"blocks-{TOPIC}-supplement"]
    assert caps == "full"
    assert "Research the subject area" in prompt


# ── Anker-Gate: Quorum-Titel ohne Korpus-Beleg (Reader-Ko-Halluzination) ────────────

async def _anker_env(db, tmp_path, monkeypatch, titel_map, desc_map=None):
    (tmp_path / "korpus.txt").write_text("Der Graph ist zusammenhängend und endlich.", encoding="utf-8")
    monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path)

    async def fake_members(topic, cid):
        return [{"title": titel_map[cid], "description": (desc_map or {}).get(cid, ""),
                 "readers": ["r1", "r2"], "supplement": False}]

    monkeypatch.setattr(bi, "_member_rows", fake_members)
    monkeypatch.setattr(bi, "_rep", lambda rows: rows[0])
    for cid in titel_map:
        await db.kanban_upsert_card(TOPIC, B, cid, "cluster", "consensus_gate", {})
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    return ctx, [{"card_id": c, "payload": {}} for c in titel_map]


async def test_anker_gate_rejects_unbelegtes(testdb, tmp_path, monkeypatch):
    """Titel ohne Korpus-Anker, aber mit Evidenz → Beleg-Judge; „nein" → rejected/kein-beleg.
    Titel MIT Anker geht ohne Judge nach naming."""
    db = testdb
    ctx, cards = await _anker_env(db, tmp_path, monkeypatch,
                                  {"c1": "Graph Zusammenhang", "c2": "Königsberger Brückenproblem"},
                                  {"c2": "Der Graph ist endlich."})  # Evidenz da → Judge entscheidet

    async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout):
        assert "Brückenproblem" in prompt and "Zusammenhang" not in prompt  # nur der Anker-lose
        return "ok", payload((0, json.dumps({"relevant": {"1": "nein"}}), ""))

    monkeypatch.setattr(bi, "run_single_slot", fake_slot)
    await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), cards)
    assert (await db.kanban_get_card(TOPIC, B, "c1"))["stage"] == "naming"
    c2 = await db.kanban_get_card(TOPIC, B, "c2")
    assert c2["stage"] == "rejected" and c2["payload"]["reason"] == "kein-beleg"


async def test_anker_gate_leeres_pack_hart_nein(testdb, tmp_path, monkeypatch):
    """KEIN distinktives Token im Korpus → deterministisch rejected, Judge läuft NICHT
    (der Judge winkte 3 Kanon-Titel auf Schein-Auszügen durch)."""
    db = testdb
    ctx, cards = await _anker_env(db, tmp_path, monkeypatch, {"c9": "Königsberger Brückenproblem"})

    async def never_slot(*a, **kw):
        raise AssertionError("Judge darf bei leerem Evidence-Pack nicht laufen")

    monkeypatch.setattr(bi, "run_single_slot", never_slot)
    await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), cards)
    c9 = await db.kanban_get_card(TOPIC, B, "c9")
    assert c9["stage"] == "rejected" and c9["payload"]["reason"] == "kein-beleg"


async def test_anker_gate_fail_open(testdb, tmp_path, monkeypatch):
    """Judge-Ausfall bei VORHANDENER Evidenz → Titel bleibt (2-Reader-Rückhalt)."""
    db = testdb
    ctx, cards = await _anker_env(db, tmp_path, monkeypatch, {"c9": "Königsberger Brückenproblem"},
                                  {"c9": "Der Graph ist endlich."})

    async def broken_slot(*a, **kw):
        return "failed", None

    monkeypatch.setattr(bi, "run_single_slot", broken_slot)
    await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), cards)
    assert (await db.kanban_get_card(TOPIC, B, "c9"))["stage"] == "naming"


def test_hat_anker_ziffern_suffix():
    ctoks = {"tsp", "graph", "kanten"}
    assert bi._hat_anker("ΔTSP1-Algorithmus", ctoks)   # tsp1 → tsp
    assert not bi._hat_anker("Königsberger Brückenproblem", ctoks)
    assert not bi._hat_anker("Algorithmus Verfahren", ctoks)  # nur Stopwörter → kein Anker


async def test_reset_generate_loescht_globale_dateien(testdb, tmp_path):
    """Reset auf Spalte generate: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots
    weg — Reste des Vor-Laufs würden sonst in den frischen Lauf zurückmergen."""
    db = testdb
    await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefakte", {"title": "Alpha"})
    await db.put_subblock(TOPIC, "alpha", "s1", "Alpha", "S1", status="consensus")
    arbeit = tmp_path / "arbeit"
    (arbeit / "ab-alpha").mkdir(parents=True)
    (arbeit / "ab-alpha" / "facts.json").write_text("{}", encoding="utf-8")
    files = {"arbeit": arbeit}
    for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
        files[k] = tmp_path / f"{k}.json"
        files[k].write_text("{}", encoding="utf-8")
    moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
    assert moved == 1
    assert not await db.list_subblocks(TOPIC)
    assert not (arbeit / "ab-alpha").exists()
    assert all(not files[k].exists() for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"))


# ── Naming-Abstraktion: freier Name nur mit Anker ───────────────────────────────────

def test_naming_schema_varianten():
    assert bi._naming_schema({"best": 2}, 3) == (2, None, False)
    assert bi._naming_schema({"best": 1, "name": "Kurzer Titel"}, 3) == (1, "Kurzer Titel", False)
    assert bi._naming_schema({"ok": True}, 3) == (None, None, True)
    assert bi._naming_schema({"best": 9}, 3) is None
    assert bi._naming_schema({"best": 1, "name": "x" * 90}, 3) == (1, None, False)  # zu lang


def test_name_verankert_thema_subset():
    rows = [{"title": "Bubblesort-Schleife und Tauschoperation", "description": "innere Schleife"},
            {"title": "Bubblesort Durchläufe", "description": ""}]
    assert bi._name_verankert("Bubblesort Tauschoperation", rows, None)
    assert not bi._name_verankert("Königsberger Brückenproblem", rows, None)  # fremde Begriffe
    assert not bi._name_verankert("und der", rows, None)  # nur Stopwörter → kein Anker


def test_name_verankert_korpus():
    ctoks = {"partition", "problem", "vollständigkeit"}
    rows = [{"title": "irrelevant", "description": ""}]
    assert bi._name_verankert("Partition-Problem", rows, ctoks)
    assert not bi._name_verankert("Rucksackproblem Optimierung", rows, ctoks)


async def test_naming_vergibt_verankerten_namen(testdb, tmp_path, monkeypatch):
    """Judge liefert best+name; verankerter Name gewinnt, unverankerter fällt auf Member zurück."""
    db = testdb
    antwort = {"val": {"best": 1, "name": "Bubblesort Grundprinzip"}}

    async def fake_members(topic, cid):
        return [{"norm": "bubblesort - grundprinzip und ablauf (kap. 2)",
                 "title": "Bubblesort - Grundprinzip und Ablauf (Kap. 2)",
                 "description": "Sortieren durch Tauschen", "readers": ["r1"], "sources": []},
                {"norm": "sortieren durch tauschen", "title": "Sortieren durch Tauschen",
                 "description": "", "readers": ["r2"], "sources": []}]

    async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
        import json as _json
        return "ok", payload((0, _json.dumps(antwort["val"]), ""))

    monkeypatch.setattr(bi, "_member_rows", fake_members)
    monkeypatch.setattr(bi, "run_single_slot", fake_slot)
    await db.kanban_upsert_card(TOPIC, B, "c1", "cluster", "naming", {})
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c1", "payload": {}})
    card = await db.kanban_get_card(TOPIC, B, "c1")
    assert card["payload"]["title"] == "Bubblesort Grundprinzip"

    # unverankerter Name → Member-Titel gewinnt
    antwort["val"] = {"best": 2, "name": "Vergleichsbasierte Sortierverfahren"}
    await db.kanban_upsert_card(TOPIC, B, "c2", "cluster", "naming", {})
    await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c2", "payload": {}})
    card = await db.kanban_get_card(TOPIC, B, "c2")
    assert card["payload"]["title"] == "Sortieren durch Tauschen"


async def test_namecheck_ok_behaelt_titel(testdb, tmp_path, monkeypatch):
    """Check-Judge bestätigt mit ok:true → Titel und Beschreibung bleiben unverändert."""
    db = testdb

    async def fake_members(topic, cid):
        return [{"norm": "a", "title": "A", "description": "da", "readers": ["r1"], "sources": []},
                {"norm": "b", "title": "B", "description": "db", "readers": ["r2"], "sources": []}]

    async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
        assert "Eigener Titel" in prompt  # current_title steht im Prompt
        return "ok", payload((0, '{"ok": true}', ""))

    monkeypatch.setattr(bi, "_member_rows", fake_members)
    monkeypatch.setattr(bi, "run_single_slot", fake_slot)
    payload = {"title": "Eigener Titel", "description": "Eigene Beschreibung", "main_norm": "a"}
    await db.kanban_upsert_card(TOPIC, B, "c9", "cluster", "naming_check", payload)
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    await bi._namecheck_one(ctx, _mk_flow(tmp_path), {"card_id": "c9", "payload": payload})
    block = await db.kanban_get_card(TOPIC, B, "b-c9")
    assert block["payload"]["title"] == "Eigener Titel"
    assert block["payload"]["description"] == "Eigene Beschreibung"


async def test_namecheck_strips_source_numbering(testdb, tmp_path, monkeypatch):
    """M1.2: wählt der Check-Judge einen numerierten Titel, wird die Katalog-Nummer deterministisch
    zum Konzept gestrippt ('Satz 7.13 (Christofides)' → 'Christofides')."""
    db = testdb

    async def fake_members(topic, cid):
        return [{"norm": "satz 7.13 (christofides)", "title": "Satz 7.13 (Christofides)",
                 "description": "3/2-Approximation für metrisches TSP", "readers": ["r1"], "sources": []},
                {"norm": "b", "title": "B", "description": "db", "readers": ["r2"], "sources": []}]

    async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
        return "ok", payload((0, '{"best": 1}', ""))  # Judge wählt den numerierten Member-Titel

    monkeypatch.setattr(bi, "_member_rows", fake_members)
    monkeypatch.setattr(bi, "run_single_slot", fake_slot)
    payload = {"title": "Satz 7.13 (Christofides)", "description": "3/2-Approximation",
               "main_norm": "satz 7.13 (christofides)"}
    await db.kanban_upsert_card(TOPIC, B, "c7", "cluster", "naming_check", payload)
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    await bi._namecheck_one(ctx, _mk_flow(tmp_path), {"card_id": "c7", "payload": payload})
    block = await db.kanban_get_card(TOPIC, B, "b-c7")
    assert block["payload"]["title"] == "Christofides"


async def test_consensus_gate_single_reader_majority_quorum(testdb, tmp_path, monkeypatch):
    """M5.1: Einzel-Reader-Fund → clarify mit Mehrheits-Quorum statt Einstimmigkeit."""
    db = testdb
    monkeypatch.setattr(bi, "source_folder", lambda t: None)  # thema: kein Korpus

    async def fake_members(topic, cid):
        return [{"title": "Seltenes Konzept", "description": "einmal erwähnt",
                 "readers": ["r1"], "supplement": False}]

    monkeypatch.setattr(bi, "_member_rows", fake_members)
    monkeypatch.setattr(bi, "_rep", lambda rows: rows[0])
    await db.kanban_upsert_card(TOPIC, B, "cs", "cluster", "consensus_gate", {})
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), [{"card_id": "cs", "payload": {}}])
    card = await db.kanban_get_card(TOPIC, B, "cs")
    assert card["stage"] == "clarify"
    assert card["payload"]["quorum"] == "majority"


# ── Sanierung: Titel auf Korpus-Form, Beschreibungspflicht (QA: fremd/hygiene) ──────

def test_sanierung_schema_varianten():
    assert bi._sanierung_schema({"title": " k-Color ", "description": "d"}) == ("k-Color", "d")
    assert bi._sanierung_schema({"title": "", "description": "nur Beschreibung"}) == ("", "nur Beschreibung")
    assert bi._sanierung_schema({"title": "x" * 90, "description": "d"}) == ("", "d")  # zu lang
    assert bi._sanierung_schema({"title": "", "description": ""}) is None
    assert bi._sanierung_schema("quatsch") is None


def test_sanierung_noetig():
    ctoks = {"color", "graph"}
    assert bi._sanierung_noetig({"title": "k-Color", "description": ""}, ctoks)          # leere Beschreibung
    assert bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, ctoks)      # kein Korpus-Anker
    assert not bi._sanierung_noetig({"title": "k-Color", "description": "d"}, ctoks)
    assert not bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, None)   # thema: kein Korpus
    # M1.3: Referenz-Titel / unbalancierte Klammer / >80 Zeichen triggern auch ohne Korpus (Singletons)
    assert bi._sanierung_noetig({"title": "Bemerkung 7.22", "description": "d"}, None)
    assert bi._sanierung_noetig({"title": "N P (Definition 6.19", "description": "d"}, None)
    assert bi._sanierung_noetig({"title": "A" * 81, "description": "d"}, None)
    assert not bi._sanierung_noetig({"title": "Clique", "description": "d"}, None)


def _sanierung_env(tmp_path, monkeypatch, antwort):
    """Korpus mit 'k-Color'-Oberflächenform; Judge antwortet mit `antwort`."""
    (tmp_path / "korpus.txt").write_text(
        "Das k-Color Problem: Kann der Graph mit k Farben gefärbt werden? NP-vollständig.",
        encoding="utf-8")
    monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path)
    seen = {}

    async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
        seen[key] = prompt
        return "ok", payload((0, json.dumps(antwort["val"]), ""))

    monkeypatch.setattr(bi, "run_single_slot", fake_slot)
    return GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False), seen


async def test_singleton_unverankert_wird_saniert(testdb, tmp_path, monkeypatch):
    """Singleton-Cluster mit Reader-Titel ohne Korpus-Anker: Naming wird nicht mehr
    übersprungen — der Titel wird auf die Korpus-Oberflächenform umgeschrieben
    (gemessener aak-Fall 'k-Coloring' statt 'k-Color')."""
    db = testdb
    antwort = {"val": {"title": "k-Color", "description": "Kann der Graph mit k Farben gefärbt werden?"}}
    ctx, seen = _sanierung_env(tmp_path, monkeypatch, antwort)

    async def fake_members(topic, cid):
        return [{"norm": "k-coloring", "title": "k-Coloring",
                 "description": "Kann der Graph mit k Farben gefärbt werden?",
                 "readers": ["r1", "r2"], "sources": []}]

    monkeypatch.setattr(bi, "_member_rows", fake_members)
    payload = {"title": "k-Coloring", "description": "Kann der Graph mit k Farben gefärbt werden?"}
    await db.kanban_upsert_card(TOPIC, B, "c1", "cluster", "naming", payload)
    await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c1", "payload": payload})
    card = await db.kanban_get_card(TOPIC, B, "c1")
    assert card["stage"] == "naming_check"
    assert card["payload"]["title"] == "k-Color"
    assert any("-sanierung-" in k for k in seen)


async def test_leere_beschreibung_wird_gefuellt(testdb, tmp_path, monkeypatch):
    """Verankerter Titel, leere Beschreibung (gemessener aak-Fall der SetCover-Fragmente):
    Beschreibung wird belegt nachgefasst, Titel bleibt."""
    db = testdb
    antwort = {"val": {"title": "k-Color", "description": "Färbbarkeit mit k Farben."}}
    ctx, _seen = _sanierung_env(tmp_path, monkeypatch, antwort)

    async def fake_members(topic, cid):
        return [{"norm": "k-color", "title": "k-Color", "description": "",
                 "readers": ["r1"], "sources": []}]

    monkeypatch.setattr(bi, "_member_rows", fake_members)
    payload = {"title": "k-Color", "description": ""}
    await db.kanban_upsert_card(TOPIC, B, "c5", "cluster", "naming", payload)
    await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c5", "payload": payload})
    card = await db.kanban_get_card(TOPIC, B, "c5")
    assert card["payload"]["title"] == "k-Color"
    assert card["payload"]["description"] == "Färbbarkeit mit k Farben."


async def test_sanierung_unverankerter_vorschlag_verfaellt(testdb, tmp_path, monkeypatch):
    """Judge-Vorschlag ohne Korpus-Anker wird verworfen (dieselbe Messlatte wie QA-fremd);
    die Beschreibung wird trotzdem übernommen."""
    db = testdb
    antwort = {"val": {"title": "Graphfärbungsproblem", "description": "Färbbarkeit mit k Farben."}}
    ctx, _seen = _sanierung_env(tmp_path, monkeypatch, antwort)

    async def fake_members(topic, cid):
        return [{"norm": "k-coloring", "title": "k-Coloring",
                 "description": "Kann der Graph mit k Farben gefärbt werden?",
                 "readers": ["r1"], "sources": []}]

    monkeypatch.setattr(bi, "_member_rows", fake_members)
    payload = {"title": "k-Coloring", "description": "Kann der Graph mit k Farben gefärbt werden?"}
    await db.kanban_upsert_card(TOPIC, B, "c2", "cluster", "naming", payload)
    await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c2", "payload": payload})
    card = await db.kanban_get_card(TOPIC, B, "c2")
    assert card["payload"]["title"] == "k-Coloring"  # unverankert → verfällt
    assert card["stage"] == "naming_check"


async def test_sanierung_fail_open(testdb, tmp_path, monkeypatch):
    """Judge-Ausfall → Karte läuft unverändert weiter (kein Deadletter am Naming)."""
    db = testdb
    (tmp_path / "korpus.txt").write_text("Der Graph ist endlich.", encoding="utf-8")
    monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path)

    async def broken_slot(*a, **kw):
        return "failed", None

    async def fake_members(topic, cid):
        return [{"norm": "x", "title": "Graph", "description": "", "readers": ["r1"], "sources": []}]

    monkeypatch.setattr(bi, "run_single_slot", broken_slot)
    monkeypatch.setattr(bi, "_member_rows", fake_members)
    payload = {"title": "Graph", "description": ""}
    await db.kanban_upsert_card(TOPIC, B, "c3", "cluster", "naming", payload)
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c3", "payload": payload})
    card = await db.kanban_get_card(TOPIC, B, "c3")
    assert card["stage"] == "naming_check" and card["payload"]["title"] == "Graph"


async def test_sanierung_anker_und_beschreibung_ok_kein_judge(testdb, tmp_path, monkeypatch):
    """Verankerter Titel + Beschreibung vorhanden → kein Sanierungs-Call."""
    db = testdb
    (tmp_path / "korpus.txt").write_text("Das k-Color Problem im Graph.", encoding="utf-8")
    monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path)

    async def never_slot(*a, **kw):
        raise AssertionError("Sanierung darf ohne Befund-Form nicht laufen")

    async def fake_members(topic, cid):
        return [{"norm": "k-color", "title": "k-Color", "description": "d", "readers": ["r1"], "sources": []}]

    monkeypatch.setattr(bi, "run_single_slot", never_slot)
    monkeypatch.setattr(bi, "_member_rows", fake_members)
    payload = {"title": "k-Color", "description": "d"}
    await db.kanban_upsert_card(TOPIC, B, "c4", "cluster", "naming", payload)
    ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
    await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c4", "payload": payload})
    assert (await db.kanban_get_card(TOPIC, B, "c4"))["stage"] == "naming_check"
