"""Verschmolzene Board-2-Calls (block_calls.py): Generate-Konsens, Verify-Faltung mit
Fix-Tail, Artefakte in einem Durchgang — Agenten gefaked, gegen Test-DB."""

import json

import pytest

import block_calls as bc
import blocks as blx
import board_artefacts as ba
from pipeline import FAILED, OK, GenContext
from textkit import _norm_title

TOPIC = "t"


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


def _sub(title, level="beginner", relevance="relevant", kp=None, cf=None):
    return {"title": title, "level": level, "relevance": relevance,
            "key_points": [f"kp {title}"] if kp is None else kp,
            "prerequisites": "", "hurdles": "", "cited_facts": cf or [], "example_idea": ""}


# ── Schemas ─────────────────────────────────────────────────────────────────────────

def test_gen_schema_normalisiert():
    """Gültige Einträge werden normalisiert; ungültiges level/relevance fällt auf ""
    (Stimme entfällt, der Sub bleibt); Einträge ohne Titel fliegen."""
    out = bc._gen_schema({"subs": [
        {"title": " **A** ", "level": "Beginner", "relevance": "RELEVANT",
         "key_points": ["k", ""], "cited_facts": [{"text": "t", "source": " s "},
                                                  {"text": ""}, "quatsch"]},
        {"title": "B", "level": "profi", "relevance": "mittel"},
        {"title": "  "},
    ]})
    assert [e["title"] for e in out] == ["A", "B"]
    assert out[0]["level"] == "beginner" and out[0]["relevance"] == "relevant"
    assert out[0]["key_points"] == ["k"]
    assert out[0]["cited_facts"] == [{"text": "t", "source": "s"}]
    assert out[1]["level"] == "" and out[1]["relevance"] == ""


def test_gen_schema_kaputt_ist_none():
    assert bc._gen_schema(None) is None
    assert bc._gen_schema({"subs": "x"}) is None
    assert bc._gen_schema({"subs": []}) is None
    assert bc._gen_schema({"subs": [{"level": "beginner"}]}) is None  # nur titellose Einträge


def test_verify_schema_pflichtkeys_und_leeres_verdikt():
    """Mindestens EIN Pflicht-Key muss da sein; leere Listen heißen „alles ok"."""
    assert bc._verify_schema({}, 3) is None
    assert bc._verify_schema({"irgendwas": 1}, 3) is None
    v = bc._verify_schema({"gruppen": []}, 3)
    assert v["gruppen"] == [] and v["fremd"] == set() and v["luecken"] == []
    assert v["uebernehmen"] == {} and v["facts_probleme"] == [] and v["levels"] == {}


def test_verify_schema_grenzen_und_normalisierung():
    """ids außerhalb 1..n und bools fallen raus; Ein-Element-Gruppen zählen nicht;
    uebernehmen/levels werden casefolded bzw. enum-geprüft."""
    v = bc._verify_schema({
        "gruppen": [{"haupt": 2, "weitere": [1, 9, True]}, {"haupt": 3, "weitere": []}],
        "kataloge": [{"titel": " K ", "mitglieder": [1, 2]}, {"titel": "", "mitglieder": [1, 2]}],
        "fremd": [True, 1, "2", 9],
        "luecken": ["  x  ", "", 7],
        "uebernehmen": {"3": " JA ", "9": "ja"},
        "facts_probleme": [{"nr": 2, "discard": 1, "hinweis": " h "}, {"nr": 9}, "quatsch"],
        "levels": {"1": "expert", "2": "quatsch"},
        "relevanz": {"1": "peripheral"},
    }, 3)
    assert v["gruppen"] == [{"haupt": 2, "ids": [1, 2]}]
    assert v["kataloge"] == [{"titel": "K", "ids": [1, 2]}]
    assert v["fremd"] == {1, 2}
    assert v["luecken"] == ["x"]
    assert v["uebernehmen"] == {3: "ja"}
    assert v["facts_probleme"] == [{"nr": 2, "discard": True, "hinweis": "h"}]
    assert v["levels"] == {1: "expert"} and v["relevanz"] == {1: "peripheral"}


def test_art_gen_schema_pattern_ist_pflicht():
    """Ohne verwertbares pattern kein Verdikt (Leitner hängt an den Fragen);
    cards/examples sind best-effort und werden einzeln validiert."""
    assert bc._art_gen_schema({"cards": [], "examples": []}) is None
    assert bc._art_gen_schema("x") is None
    out = bc._art_gen_schema({
        "pattern": [{"block": "B", "subblock": "S", "question": "F?"},
                    {"block": "B", "subblock": "", "question": "F?"}],
        "cards": [{"block": "B", "subblock": "S", "question": "F?", "answer": "A"},
                  {"block": "B", "subblock": "S", "question": "F?"}],
        "examples": [{"block": "B", "subblock": "S", "problem": "P", "steps": ["s1", ""], "result": ""},
                     {"block": "B", "subblock": "S", "problem": "P", "steps": []}],
    })
    assert len(out["pattern"]) == 1 and len(out["cards"]) == 1
    assert out["examples"] == [{"block": "B", "subblock": "S", "problem": "P",
                                "steps": ["s1"], "result": ""}]


def test_art_check_schema_varianten():
    """{"ok": true} → leeres Verdikt; ohne bekannten Key None; Beispiel-Indizes sind
    1-basiert, bools/0 zählen nicht."""
    ok = bc._art_check_schema({"ok": True})
    assert ok == {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()}
    assert bc._art_check_schema({"foo": 1}) is None
    v = bc._art_check_schema({"examples_probleme": [1, "2", {"index": 3}, True, 0, -1],
                              "pattern_ergaenzt": [{"block": "B", "subblock": "S", "question": "F?"}]})
    assert v["examples_probleme"] == {1, 2, 3}
    assert len(v["pattern_ergaenzt"]) == 1 and v["pattern"] == []


# ── Generate ────────────────────────────────────────────────────────────────────────

@pytest.fixture
def env(testdb, tmp_path, monkeypatch):
    """Ohne Modell (exakte Norm-Gleichheit), ohne Korpus (thema-Selbst-Recherche)."""
    monkeypatch.setattr(bc, "EMBEDDING_AKTIV", False)
    monkeypatch.setattr(blx, "EMBEDDING_AKTIV", False)  # _dedup_subblocks aus
    monkeypatch.setattr(bc, "material_folder", lambda t: None)
    monkeypatch.setattr(bc, "load_source", lambda t: {"type": "thema"})
    return testdb, _ctx(), {"arbeit": tmp_path}


def _mk_enrich_slot(output):
    """run_single_slot-Fake für die Anreicherung: schreibt die gescriptete Antwort via
    payload (wie der Engine-Sink); output=None → Ausfall."""
    calls = []

    async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
        calls.append({"key": key, "prompt": prompt})
        if output is None:
            return FAILED, None
        return OK, payload((0, json.dumps(output), ""))

    fake.calls = calls
    return fake


async def test_enrich_reichert_feste_subs_an(env, monkeypatch):
    """Die Subs stehen fest (Board 1). EIN Call füllt Facts + Level + Relevanz — er
    erfindet und entfernt nichts; die Board-1-Subs bleiben in Reihenfolge."""
    db, ctx, files = env
    await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
    monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [
        _sub("Sub A", kp=["k1"]),
        _sub("Sub B", level="expert", relevance="peripheral", kp=["k2"])]}))
    gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
    assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
    assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1"]
    assert gen["unsicher"] == []
    assert gen["votes"]["sub b"] == {"level": ["expert"], "relevance": ["peripheral"]}
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
    assert rows["sub a"] == rows["sub b"] == "consensus"


async def test_enrich_ohne_subs_kein_call(env, monkeypatch):
    """Kein Board-1-Sub → nichts anzureichern, kein Modell-Call."""
    db, ctx, files = env
    called = []

    async def spy(*a, **k):
        called.append(1)
        return OK, None
    monkeypatch.setattr(bc, "run_single_slot", spy)
    gen = await bc._generate_block(ctx, files, "Alpha", "d")
    assert gen == {"raw": {"Alpha": []}, "facts": {"Alpha": {}}, "unsicher": [], "votes": {}}
    assert called == []


async def test_enrich_ausgelassener_sub_bleibt_leer(env, monkeypatch):
    """Lässt der Call einen Sub aus, bleibt er erhalten (Vollständigkeit) — mit leeren
    Facts, die der Prüfer/QA als Lücke sieht."""
    db, ctx, files = env
    await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
    monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]}))
    gen = await bc._generate_block(ctx, files, "Alpha", "d")
    assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
    assert gen["facts"]["Alpha"]["sub b"]["key_points"] == []
    assert gen["facts"]["Alpha"]["sub b"]["cited_facts"] == []


async def test_enrich_resume_ohne_neue_calls(env, monkeypatch):
    """Vorhandene enrich-Datei → kein neuer Call, Ergebnis wird übernommen."""
    db, ctx, files = env
    await _seed_rows(db, "alpha", ["Sub A"])
    fake = _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]})
    monkeypatch.setattr(bc, "run_single_slot", fake)
    gen1 = await bc._generate_block(ctx, files, "Alpha", "d")
    n = len(fake.calls)
    gen2 = await bc._generate_block(ctx, files, "Alpha", "d")
    assert len(fake.calls) == n  # aus Datei resumed
    assert gen2["raw"] == gen1["raw"]


# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────

def _gen_von(title, subs, unsicher=None, votes=None):
    """Karten-Payload wie aus _generate_block: raw/facts/unsicher/votes."""
    return {"raw": {title: list(subs)},
            "facts": {title: {_norm_title(s): {"key_points": [f"kp {s}"], "prerequisites": "",
                                               "hurdles": "", "cited_facts": [], "example_idea": ""}
                              for s in subs}},
            "unsicher": unsicher or [], "votes": votes or {}}


def _judge_slot(antworten, fix=None):
    """run_single_slot-Fake: Prüfer-Antwort je j-Suffix, Fix-Antwort für -sb-fix-;
    fehlender Eintrag = FAILED."""
    calls = []

    async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
        calls.append({"key": key, "prompt": prompt})
        if "-sb-fix-" in key:
            if fix is None:
                return FAILED, None
            return OK, payload((0, json.dumps(fix), ""))
        j = key.rsplit("-j", 1)[-1]
        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_rows(db, bnorm, titles, status="consensus"):
    for t in titles:
        await db.put_subblock(TOPIC, bnorm, _norm_title(t), bnorm.title(), t, status=status)


async def test_verify_gruppe_faltet_einstimmig(env, monkeypatch):
    """Beide Prüfer gruppieren 1+2 → haupt gewinnt, Verlierer wird variant und seine
    Facts wandern per Union zum Gewinner; Dissens-Gruppen falten nicht."""
    db, ctx, files = env
    subs = ["Marker Regel", "Marker Regel im Detail erklärt", "Eigenes Thema"]
    await _seed_rows(db, "alpha", subs)
    verdikt = {"gruppen": [{"haupt": 2, "weitere": [1]}]}
    fake = _judge_slot({"1": verdikt, "2": {"gruppen": [{"haupt": 2, "weitere": [1]},
                                                        {"haupt": 3, "weitere": [2]}]}})
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
    assert res["raw"] == {"Alpha": [subs[1], subs[2]]}  # Gruppe 2+3 war einseitig → kein Fold
    wf = res["facts"]["Alpha"][_norm_title(subs[1])]
    assert wf["key_points"] == [f"kp {subs[1]}", f"kp {subs[0]}"]  # Union geerbt
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
    assert rows[_norm_title(subs[0])] == "variant"
    assert rows[_norm_title(subs[1])] == "consensus"


async def test_verify_fremd_nur_einstimmig(env, monkeypatch):
    """Fremd 2/2 → discarded + raus; einseitig fremd → bleibt."""
    db, ctx, files = env
    subs = ["CSS Regel", "Echte Regel"]
    await _seed_rows(db, "alpha", subs)
    fake = _judge_slot({"1": {"fremd": [1, 2]}, "2": {"fremd": [1]}})
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
    assert res["raw"] == {"Alpha": ["Echte Regel"]}
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
    assert rows["css regel"] == "discarded" and rows["echte regel"] == "consensus"


async def test_verify_keep_all_behaelt_alle_atome(env, monkeypatch):
    """Bottom-up (keep_all=True): kein Board-1-Atom wird entfernt oder gefaltet, selbst
    wenn beide Prüfer fremd/gruppen/discard melden. Nur Facts-Korrektur (Hinweis) und
    Level/Relevanz greifen weiter."""
    db, ctx, files = env
    subs = ["Sub A", "Sub B", "Sub C"]
    await _seed_rows(db, "alpha", subs)
    fix = {"subs": [_sub("Sub A", kp=["korrigiert"])]}
    verdikt = {"fremd": [3], "gruppen": [{"haupt": 1, "weitere": [2]}],
               "facts_probleme": [{"nr": 1, "discard": True, "hinweis": "Zahl falsch"},
                                  {"nr": 2, "discard": True}],
               "levels": {"2": "expert"}}
    fake = _judge_slot({"1": verdikt, "2": verdikt}, fix=fix)
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {}, keep_all=True)
    assert res["raw"] == {"Alpha": subs}  # alle drei bleiben — nichts entfernt/gefaltet
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
    assert all(rows[_norm_title(s)] == "consensus" for s in subs)
    side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
    assert side["Sub A"]["facts"]["key_points"] == ["korrigiert"]  # Hinweis trotz discard=True
    assert side["Sub B"]["level"] == "expert"  # Level-Korrektur greift


async def test_verify_facts_discard_nur_2von2(env, monkeypatch):
    """Facts-Discard ist irreversibel → nur 2/2; die einseitige Stimme ohne Hinweis
    löst auch keinen Fix aus."""
    db, ctx, files = env
    subs = ["Sub A", "Sub B"]
    await _seed_rows(db, "alpha", subs)
    fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "discard": True},
                                                 {"nr": 2, "discard": True}]},
                        "2": {"facts_probleme": [{"nr": 1, "discard": True}]}})
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
    assert res["raw"] == {"Alpha": ["Sub B"]}
    assert not any("-sb-fix-" in c["key"] for c in fake.calls)
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
    assert rows["sub a"] == "discarded" and rows["sub b"] == "consensus"


async def test_verify_korrektur_ab_einer_stimme(env, monkeypatch):
    """Ein Hinweis EINES Prüfers reicht: der Fix-Call läuft und ersetzt die Facts des
    beanstandeten Subs; die Einstufung bleibt."""
    db, ctx, files = env
    subs = ["Sub A", "Sub B"]
    await _seed_rows(db, "alpha", subs)
    fix = {"subs": [_sub("Sub A", kp=["korrigierte Aussage"])]}
    fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "hinweis": "Zahl falsch"}]},
                        "2": {"gruppen": []}}, fix=fix)
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
    assert any("-sb-fix-" in c["key"] for c in fake.calls)
    side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
    assert side["Sub A"]["facts"]["key_points"] == ["korrigierte Aussage"]
    assert res["facts"]["Alpha"]["sub a"]["key_points"] == ["korrigierte Aussage"]
    assert side["Sub B"]["facts"]["key_points"] == ["kp Sub B"]  # unbeanstandet


async def test_verify_luecke_belegt_wird_neuer_sub(env, monkeypatch):
    """Lücken-Schnitt beider Prüfer → Fix legt den belegten Fund als neuen consensus-Sub
    an; ein unbelegter „Fund" verfällt am Beleg-Gate."""
    db, ctx, files = env
    subs = ["Sub A"]
    await _seed_rows(db, "alpha", subs)
    fix = {"subs": [_sub("Escaping von Sonderzeichen", level="expert", kp=["belegt"]),
                    _sub("Unbelegte Behauptung", kp=[])]}
    fake = _judge_slot({"1": {"luecken": ["Escaping fehlt"]},
                        "2": {"luecken": ["Escaping unbehandelt"]}}, fix=fix)
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
    assert res["raw"] == {"Alpha": ["Sub A", "Escaping von Sonderzeichen"]}
    neu = next(s for s in res["sidecar"]["Alpha"] if s["title"] == "Escaping von Sonderzeichen")
    assert neu["level"] == "expert" and neu["facts"]["key_points"] == ["belegt"]
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
    assert rows[_norm_title("Escaping von Sonderzeichen")] == "consensus"
    assert _norm_title("Unbelegte Behauptung") not in rows


async def test_verify_ersatzrichter_bei_ausfall(env, monkeypatch):
    """Fällt EIN Prüfer aus, springt der Ersatz jE ein — Einstimmigkeit mit ihm faltet."""
    db, ctx, files = env
    subs = ["CSS Regel", "Echte Regel"]
    await _seed_rows(db, "alpha", subs)
    fake = _judge_slot({"1": {"fremd": [1]}, "E": {"fremd": [1]}})  # j2 → FAILED
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
    assert res["raw"] == {"Alpha": ["Echte Regel"]}
    assert [c["key"].rsplit("-j", 1)[-1] for c in fake.calls] == ["1", "2", "E"]


async def test_verify_failopen_verwirft_nur_unsicher(env, monkeypatch):
    """Nur 1 Prüfer (auch der Ersatz fällt aus) → fail-open: consensus bleibt unangetastet,
    unsicher wird verworfen (ohne Panel keine Übernahme-Entscheidung)."""
    db, ctx, files = env
    subs = ["Sub A"]
    unsicher = [_sub("Unsicher B")]
    await _seed_rows(db, "alpha", subs)
    await _seed_rows(db, "alpha", ["Unsicher B"], status="candidate")
    fake = _judge_slot({"1": {"fremd": [1], "uebernehmen": {"2": "ja"}}})  # j2+jE → FAILED
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
    assert res["raw"] == {"Alpha": ["Sub A"]}  # fremd-Einzelstimme wirkt NICHT
    rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
    assert rows["sub a"] == "consensus" and rows["unsicher b"] == "discarded"


async def test_verify_level_korrektur_wiegt_doppelt(env, monkeypatch):
    """Explizite Prüfer-Korrektur (×2) schlägt die Generator-Stimme; Patt fällt auf
    advanced/relevant (heutige Defaults)."""
    db, ctx, files = env
    subs = ["Sub A", "Sub B"]
    await _seed_rows(db, "alpha", subs)
    votes = {"sub a": {"level": ["beginner"], "relevance": []},
             "sub b": {"level": ["beginner", "expert"], "relevance": []}}
    fake = _judge_slot({"1": {"levels": {"1": "expert"}}, "2": {"gruppen": []}})
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, votes=votes), {})
    side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
    assert side["Sub A"]["level"] == "expert"     # 2× Korrektur > 1× Generator
    assert side["Sub B"]["level"] == "advanced"   # 1:1-Patt → Default
    assert side["Sub A"]["relevance"] == "relevant"  # keine Stimme → Default


async def test_verify_resume_ohne_neue_calls(env, monkeypatch):
    """Vorhandene verify-j-Dateien → kein neuer Prüfer-Call."""
    db, ctx, files = env
    subs = ["Sub A"]
    await _seed_rows(db, "alpha", subs)
    fake = _judge_slot({"1": {"gruppen": []}, "2": {"gruppen": []}})
    monkeypatch.setattr(bc, "run_single_slot", fake)
    await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
    n = len(fake.calls)
    await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
    assert len(fake.calls) == n


# ── Artefakte ───────────────────────────────────────────────────────────────────────

def _sidecar(titles):
    return [{"title": t, "level": "beginner", "relevance": "relevant",
             "facts": {"key_points": [f"kp {t}"]}} for t in titles]


def _art_slot(gen_out, check_out):
    """run_single_slot-Fake für Artefakte: gen_out je Teil (dict oder callable(prompt)),
    check_out fürs Prüfer-Verdikt."""
    calls = []

    async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
        calls.append({"key": key, "prompt": prompt})
        if "-art-gen-" in key:
            out = gen_out(prompt) if callable(gen_out) else gen_out
            return OK, payload((0, json.dumps(out), ""))
        if "-art-check-" in key:
            if check_out is None:
                return FAILED, None
            return OK, payload((0, json.dumps(check_out), ""))
        raise AssertionError(f"unerwarteter Call {key}")

    fake.calls = calls
    return fake


async def test_artefakte_ein_call_liefert_alles(env, monkeypatch):
    """EIN Generator-Call liefert pattern+cards+examples, der Prüfer sagt ok →
    Rohfassung wird übernommen, block-Feld auf den Karten-Block normiert."""
    db, ctx, files = env
    gen_out = {"pattern": [{"block": "Echo", "subblock": "Sub A", "question": "F?"}],
               "cards": [{"block": "Echo", "subblock": "Sub A", "question": "F?", "answer": "A"}],
               "examples": [{"block": "Echo", "subblock": "Sub A", "problem": "P",
                             "steps": ["s1"], "result": "R"}]}
    fake = _art_slot(gen_out, {"ok": True})
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
    assert [c["key"] for c in fake.calls if "-art-gen-" in c["key"]].__len__() == 1
    assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
    assert res["artefacts"]["flashcard"] == [{"block": "Alpha", "subblock": "Sub A",
                                              "question": "F?", "answer": "A"}]
    assert res["artefacts"]["example"][0]["block"] == "Alpha"  # Agent-Echo „Echo" normiert


async def test_artefakte_check_entfernt_beispiel_und_ergaenzt_frage(env, monkeypatch):
    """examples_probleme wirft das beanstandete Beispiel; pattern_ergaenzt füllt die
    fehlende Frage nach — der Prüfer-Prompt listet den fraglosen Sub."""
    db, ctx, files = env
    gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
               "cards": [],
               "examples": [{"block": "Alpha", "subblock": "Sub A", "problem": "P1", "steps": ["x"], "result": ""},
                            {"block": "Alpha", "subblock": "Sub A", "problem": "P2", "steps": ["y"], "result": ""}]}
    check = {"examples_probleme": [{"index": 1}],
             "pattern_ergaenzt": [{"block": "Alpha", "subblock": "Sub B", "question": "F B?"}]}
    fake = _art_slot(gen_out, check)
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B"]))
    assert [e["problem"] for e in res["artefacts"]["example"]] == ["P2"]
    assert res["pattern"]["Alpha"] == [{"subblock": "Sub A", "question": "F?"},
                                       {"subblock": "Sub B", "question": "F B?"}]
    check_prompt = next(c["prompt"] for c in fake.calls if "-art-check-" in c["key"])
    assert "STILL MISSING A QUESTION" in check_prompt and "Sub B" in check_prompt


async def test_artefakte_split_ab_schwelle(env, monkeypatch):
    """> ART_SPLIT_SUBS Subs → ZWEI parallele Generator-Calls, jeder sieht nur seine
    Hälfte; die Ergebnisse werden zusammengeführt."""
    db, ctx, files = env
    monkeypatch.setattr(bc, "ART_SPLIT_SUBS", 2)

    def gen_out(prompt):
        subs = [t for t in ("Sub A", "Sub B", "Sub C") if f"- {t}" in prompt]
        return {"pattern": [{"block": "Alpha", "subblock": s, "question": f"F {s}?"} for s in subs],
                "cards": [], "examples": []}

    fake = _art_slot(gen_out, {"ok": True})
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B", "Sub C"]))
    gen_keys = [c["key"] for c in fake.calls if "-art-gen-" in c["key"]]
    assert len(gen_keys) == 2 and gen_keys[0].endswith("-t1") and gen_keys[1].endswith("-t2")
    assert [p["subblock"] for p in res["pattern"]["Alpha"]] == ["Sub A", "Sub B", "Sub C"]


async def test_artefakte_check_ausfall_uebernimmt_rohfassung(env, monkeypatch):
    """Prüfer ohne Ergebnis → fail-open, die Generator-Rohfassung zählt."""
    db, ctx, files = env
    gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
               "cards": [], "examples": []}
    fake = _art_slot(gen_out, None)
    monkeypatch.setattr(bc, "run_single_slot", fake)
    res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
    assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}


async def test_artefakte_leerer_block(env):
    db, ctx, files = env
    res = await bc._artefakte_block(ctx, files, "Alpha", [])
    assert res == {"pattern": {"Alpha": []}, "artefacts": {"flashcard": [], "example": []}}


# ── Migration der alten Stage-Treppe ────────────────────────────────────────────────

async def test_migriere_alt_karten(testdb):
    """Karten in alten Stages gehen mit reduziertem Payload zurück nach generate
    (alte Zwischenstände sind für die verschmolzenen Calls wertlos); Terminal- und
    Neu-Struktur-Karten bleiben unangetastet."""
    db = testdb
    await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts",
                                {"title": "Alpha", "description": "d", "n_size": 3,
                                 "sources": ["s1"], "raw": {"Alpha": ["alt"]},
                                 "facts": {"Alpha": {}}})
    await db.kanban_upsert_card(TOPIC, "artefacts", "beta", "ablock", "question_pattern",
                                {"title": "Beta", "sidecar": {}})
    await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "done_artefact",
                                {"title": "Gamma", "raw": {"Gamma": ["bleibt"]}})
    await db.kanban_upsert_card(TOPIC, "artefacts", "delta", "ablock", "verify",
                                {"title": "Delta", "raw": {"Delta": ["neu"]}})
    n = await ba.migriere_alt_karten(TOPIC)
    assert n == 2
    alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
    assert alpha["stage"] == "generate"
    assert alpha["payload"] == {"title": "Alpha", "description": "d", "n_size": 3, "sources": ["s1"]}
    assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "generate"
    gamma = await db.kanban_get_card(TOPIC, "artefacts", "gamma")
    assert gamma["stage"] == "done_artefact" and gamma["payload"]["raw"] == {"Gamma": ["bleibt"]}
    delta = await db.kanban_get_card(TOPIC, "artefacts", "delta")
    assert delta["stage"] == "verify" and delta["payload"]["raw"] == {"Delta": ["neu"]}
