"""Board 2 „Artefakte": per finished block, prepare the learning artefacts the guide presents.

A card is spawned by board 1's `done` column per mirrored block and runs through:
  generate → verify (inkl. Fix-Tail) → artefakte (Gen + Prüfer) → finalize
(die verschmolzenen Calls liegen in block_calls.py — 4–5 serielle Segmente statt ~20).
finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte
files + the DB tables. Danach zwei topic-weite BARRIEREN: `konsolidierung` (cross-block
sub dedup, faltet per repair.falte_sub) und `outline` (prerequisite graph → chapter order),
re-run once per generation run — outline läuft parallel zur Dedup-Barriere."""

import asyncio
import hashlib
import json
import logging
import re

import database as db
import blocks
import embedding
from block_calls import _artefakte_block, _generate_block, _verify_block
from blocks import ARTEFACT_TYPES, _match_sub, _neg_set, _sink_json, _outline_block
from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from kanban import Flow, Stage
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot
from textkit import _norm_title, _title, parse_facts

log = logging.getLogger("creator.board_artefacts")

BOARD = "artefacts"
DONE = "done_artefact"


def _nset(msg: str, step: int | None = None) -> None:
    """Progress no-op — the kanban board itself is the progress display."""


def _safe(norm: str) -> str:
    return re.sub(r"\W+", "-", norm).strip("-")[:24] or "block"


def _sub_key(existing: set[str], sn: str) -> str:
    """Agents echo the short sub title while the sub row is keyed 'kurztitel: beschreibung' —
    resolve to the stored key: exact, unambiguous prefix, then unambiguous substring
    containment either way (agents paraphrase/truncate, measured 23 orphans of ~560 rows).
    Ambiguous or unresolvable echoes stay unchanged (visible as QA orphan)."""
    if sn in existing:
        return sn
    hits = [s for s in existing if s.startswith(sn + ":")]
    if len(hits) == 1:
        return hits[0]
    if not hits:
        hits = [s for s in sorted(existing) if sn in s or s in sn]
        if len(hits) == 1:
            return hits[0]
    return sn


def _card_set_p(flow: Flow, norm: str):
    """Per-card progress: the inner step messages land in-memory on the flow —
    board_snapshot shows them as the card's info line + phase stepper while active.
    The step INDEX is resolved to its NAME at write time (indices shift with the
    source type, names are stable)."""
    info = flow.state.setdefault("card_info", {})

    def set_p(msg: str, step: int | None = None) -> None:
        name = ""
        if step is not None:
            steps = flow.state.get("blocks_steps")
            if steps is None:
                steps = flow.state["blocks_steps"] = blocks._blocks_steps(flow.topic)
            if 0 <= step < len(steps):
                name = steps[step]
        info[f"{BOARD}:{norm}"] = {"msg": msg, "step": name}
    return set_p


def _pfiles(files: dict, norm: str) -> dict:
    """Per-block file namespace: own work dir + facts/artefakte paths, global rest."""
    sub = files["arbeit"] / f"ab-{_norm_title(norm).replace(' ', '_')[:60]}"
    sub.mkdir(parents=True, exist_ok=True)
    return {**files, "arbeit": sub, "facts": sub / "facts.json", "artefakte": sub / "artefakte.json"}


def _entry_line(p: dict) -> str:
    d = p.get("description")
    return f"{p['title']} — {d}" if d else p["title"]


def make_spawner(topic: str, files: dict):
    """Hook for board 1's `done` column: one artefact card per mirrored block."""

    async def spawn(block_card_id: str, payload: dict):
        norm = payload.get("mirrored_norm")
        if not norm:
            return
        await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "generate", {
            "title": payload.get("title", ""),
            "description": payload.get("description", ""),
            "n_size": payload.get("n_size", 0),  # LPT estimate until subs_n exists
        })
    return spawn


async def _gather_cards(ctx: GenContext, flow: Flow, cards, one):
    results = await asyncio.gather(*[one(c) for c in cards], return_exceptions=True)
    errs = [r for r in results if isinstance(r, Exception)]
    if errs:
        for e in errs[1:]:  # nur errs[0] wird re-raised — der Rest darf nicht stumm verschwinden
            log.error("weitere Karten-Exception im Batch: %r", e)
        raise errs[0]
    flow.wake.set()


def _fail_or_cancel(ctx: GenContext, what: str):
    # A per-card failure belongs on the card (last_error/dead-letter), never in the
    # topic banner — the inner block functions may have set it there.
    blocks._blocks_errors.pop(ctx.topic, None)
    if ctx.is_cancelled():
        return None  # leave the card where it is
    raise RuntimeError(f"{what} ohne Ergebnis")


# ── Stage processors (one call per card, all parallel) ─────────────────────────────
async def _seed_map(topic: str) -> dict[str, list[str]]:
    """Demoted fragments become seed candidates of their SURVIVING parent block.
    parent_norm may point at a block that itself got grouped/merged/renamed — follow the
    redirect chain (grouped → merged_into, rejected → parent_norm, done → mirrored_norm)
    to the living board-2 card id (= mirrored_norm). A dead end drops the seed (as before)."""
    alive: set[str] = set()
    redirect: dict[str, str] = {}
    rejected: list[dict] = []
    for r in await db.kanban_cards(topic, board="inventory", kind="block"):
        p = r["payload"]
        tn = _norm_title(p.get("title", ""))
        if not tn:
            continue
        if r["stage"] in ("done", "done_block"):
            mn = p.get("mirrored_norm") or tn
            alive.add(mn)
            if tn != mn:
                redirect.setdefault(tn, mn)
        elif r["stage"] == "grouped" and p.get("merged_into"):
            redirect.setdefault(tn, _norm_title(p["merged_into"]))
            # umbrella members are absorbed WHOLE topics ("Aufgabenlisten" → "Listen") —
            # without a seed the umbrella's finders may simply miss them (measured).
            rejected.append({"title": p.get("title", ""), "parent_norm": _norm_title(p["merged_into"])})
        elif r["stage"] == "rejected":
            if p.get("parent_norm"):
                redirect.setdefault(tn, p["parent_norm"])
            rejected.append(p)

    def _resolve(norm: str) -> str | None:
        seen: set[str] = set()
        cur = norm
        while cur and cur not in seen:
            if cur in alive:  # alive check BEFORE following (self-edges like „Listen"→„Listen")
                return cur
            seen.add(cur)
            cur = redirect.get(cur, "")
        return None

    seeds: dict[str, list[str]] = {}
    for p in rejected:
        pn = p.get("parent_norm")
        if pn and (target := _resolve(pn)):
            seeds.setdefault(target, []).append(p.get("title", ""))
    return seeds


def _melder(flow: Flow, norm: str):
    """Live-Stepper der Karte: Phasenname → card_info (ging bei der Call-Verschmelzung
    verloren — Karten liefen ohne Badge/Stepper durch das Board)."""
    set_p = _card_set_p(flow, norm)

    def melde(schritt: str) -> None:
        try:
            set_p(f"{schritt}…", step=blocks._step_idx(flow.topic, schritt))
        except ValueError:
            set_p(f"{schritt}…")
    return melde


async def _proc_generate(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
    """Verschmolzener Erzeuger: 2 unabhängige Generatoren liefern Subs+Facts+Einstufung
    in EINEM Call, Konsens im Code (block_calls._generate_block)."""
    topic = flow.topic
    # fragments demoted to a parent become seed candidates of the parent's subblocks
    seeds = await _seed_map(topic)

    async def one(c):
        p = c["payload"]
        norm = c["card_id"]
        gen = await _generate_block(ctx, _pfiles(files, norm), p.get("title", ""),
                                    p.get("description", ""), instructions,
                                    ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ",
                                    sources=p.get("sources"),
                                    seeds=[s for s in seeds.get(norm, []) if s] or None,
                                    melde=_melder(flow, norm))
        if gen is None:
            return _fail_or_cancel(ctx, f"Generate {p.get('title', norm)}")
        p["raw"], p["facts"] = gen["raw"], gen["facts"]
        p["unsicher"], p["votes"] = gen["unsicher"], gen["votes"]
        p["subs_n"] = sum(len(v) for v in gen["raw"].values()) + len(gen["unsicher"])
        await db.kanban_set_payload(topic, BOARD, norm, p)
        await db.kanban_advance(topic, BOARD, norm, "verify")

    await _gather_cards(ctx, flow, cards, one)


async def _proc_verify(ctx: GenContext, flow: Flow, files: dict, q: dict, instructions: str, cards):
    """Verschmolzener Prüfer: MECE + Facts + Einstufung in einem Panel-Call, Fix-Tail
    inklusive (block_calls._verify_block) — ersetzt facts-check/konsolidierung/levels/
    relevance als eigene Stages."""
    topic = flow.topic

    async def one(c):
        p = c["payload"]
        norm = c["card_id"]
        title = p.get("title", "")
        gen = {"raw": p.get("raw") or {}, "facts": p.get("facts") or {},
               "unsicher": p.get("unsicher") or [], "votes": p.get("votes") or {}}
        res = await _verify_block(ctx, _pfiles(files, norm), title, gen, q, instructions,
                                  ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
                                  sources=p.get("sources"), melde=_melder(flow, norm),
                                  keep_all=True)
        if res is None:
            return None  # nur Cancel — Karte bleibt liegen
        p["raw"], p["facts"], p["sidecar"] = res["raw"], res["facts"], res["sidecar"]
        p.pop("unsicher", None)
        p.pop("votes", None)
        await db.kanban_set_payload(topic, BOARD, norm, p)
        await db.kanban_advance(topic, BOARD, norm, "artefakte")

    await _gather_cards(ctx, flow, cards, one)


async def _proc_artefakte(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
    """Fragen + Flashcards + Beispiele in einem Generator-Call, ein Prüfer-Call dahinter
    (block_calls._artefakte_block)."""
    topic = flow.topic

    async def one(c):
        p = c["payload"]
        norm = c["card_id"]
        title = p.get("title", "")
        res = await _artefakte_block(ctx, _pfiles(files, norm), title,
                                     (p.get("sidecar") or {}).get(title) or [],
                                     instructions, ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
                                     melde=_melder(flow, norm))
        if res is None:
            return None  # nur Cancel
        p["pattern"] = res["pattern"]
        p["artefacts"] = res["artefacts"]
        await db.kanban_set_payload(topic, BOARD, norm, p)
        await db.kanban_advance(topic, BOARD, norm, "finalize")

    await _gather_cards(ctx, flow, cards, one)


def _cross_schema(data) -> dict[int, str] | None:
    """{"pairs": {"1": "a"|"b"|"nein"}} → {pair_nr: verdict} · otherwise None."""
    if not isinstance(data, dict) or not isinstance(data.get("pairs"), dict):
        return None
    out: dict[int, str] = {}
    for k, v in data["pairs"].items():
        try:
            nr = int(k)
        except (ValueError, TypeError):
            continue
        s = str(v).strip().casefold()
        if s in ("a", "b", "nein"):
            out[nr] = s
    return out or None


async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
    """BARRIER/drain am RUN-ENDE — cross-block sub dedup: the SAME statement carried by two
    blocks (measured on Markdown: tab handling in 3 blocks, HTML blocks, backslash escapes —
    the in-block paths never see these). Embedding candidates (block≠block, cos ≥
    SUB_DUP_KANDIDAT_COS) go to a two-judge panel; UNANIMITY decides which block keeps the
    statement. Sitzt seit dem Umbau NACH finalize: als Mittel-Barriere wartete jede fertige
    Karte auf die langsamste (gemessen: 8:46 min Leerlauf pro Block, kanban-smoke). Der
    Verlierer wird per repair.falte_sub gefaltet (variant + Fragen/Artefakte umhängen) —
    die wenigen Cross-Dubletten kosten so ein paar umsonst generierte Artefakte statt
    Minuten Wandzeit für alle. Fail-open on judge failure/dissent."""
    from repair import falte_sub
    topic = flow.topic
    work_dir = flow.work_dir
    # Resume-Karten aus der alten Stage-Position (Barriere lag vor den Fragen): erst fertig
    # generieren — die Barriere feuert erneut, wenn alle wieder hier sind. Direkt dedupen
    # ginge schief: finalize würde den gefalteten Sub aus dem Karten-Sidecar re-spiegeln.
    nachzuegler = [(c["card_id"], "artefakte" if "sidecar" in c["payload"] else "generate")
                   for c in cards if "pattern" not in c["payload"]]
    if nachzuegler:
        await db.kanban_advance_many(topic, BOARD, nachzuegler)
        flow.wake.set()
        return

    async def _advance_all():
        await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) for c in cards])
        flow.wake.set()

    rows = [r for r in await db.list_subblocks(topic) if r["status"] == "consensus"]
    if len(rows) < 2 or not EMBEDDING_AKTIV or not await asyncio.to_thread(embedding.available):
        await _advance_all()
        return
    sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows])
    if sims is None:
        await _advance_all()
        return
    negs = [_neg_set(r["sub_title"]) for r in rows]
    pairs = [(i, j) for i in range(len(rows)) for j in range(i + 1, len(rows))
             if rows[i]["block_norm"] != rows[j]["block_norm"] and negs[i] == negs[j]
             and float(sims[i][j]) >= SUB_DUP_KANDIDAT_COS]
    if not pairs:
        await _advance_all()
        return

    def _kp(r: dict) -> list:
        return parse_facts(r.get("facts")).get("key_points") or []

    def _side(tag: str, r: dict) -> str:
        return f"{tag}: [Block: {r['block']}] {r['sub_title']}" + "".join(f"\n   - {p}" for p in _kp(r))

    # chunked judging: ONE call over all pairs scaled its timeout past 50 min, and a hung
    # call blocked the barrier for the full window (measured on aak: 196 pairs, 2×54 min)
    chunks = [pairs[lo:lo + CROSS_CHUNK_PAARE] for lo in range(0, len(pairs), CROSS_CHUNK_PAARE)]

    async def _urteile_chunk(chunk: list[tuple[int, int]]) -> dict[int, str]:
        """Two judges (+ substitute, + tie-breaker) over one pair chunk → {local_k: verdict};
        empty dict = fail-open (pairs stay)."""
        lines = "\n\n".join(
            f"{k}.\n{_side('A', rows[i])}\n{_side('B', rows[j])}"
            for k, (i, j) in enumerate(chunk, 1))
        h = hashlib.md5(lines.encode()).hexdigest()[:8]
        paths = [work_dir / f"sub-crossblock-{h}-j{j}.json" for j in (1, 2)]

        async def _judge(j, path, plines, n):
            if _cross_schema(_json_file(path)) is not None:
                return  # resume
            status, _v = await run_single_slot(
                ctx, f"Sub-Crossblock j{j}", key=f"blocks-{topic}-sub-crossblock-{h}-j{j}",
                prompt=_prompt("Subblock-Crossblock", topic=topic, pairs=plines, extra=_extra(instructions)),
                role="judge", capabilities="none",
                payload=lambda result, p=path: _sink_json(result, p, _cross_schema),
                timeout=_timeout("subblock_check", n))
            if status == FAILED:
                _log(topic, f"Sub-Crossblock j{j} ohne Ergebnis — fail-open")

        await asyncio.gather(*[_judge(j, p, lines, len(chunk)) for j, p in zip((1, 2), paths)])
        if ctx.is_cancelled():
            return {}
        outs = [o for p in paths if (o := _cross_schema(_json_file(p))) is not None]
        if len(outs) == 1:  # Ersatz-Richter statt fail-open bei EINEM Ausfall
            ersatz = work_dir / f"sub-crossblock-{h}-jE.json"
            await _judge("E", ersatz, lines, len(chunk))
            if ctx.is_cancelled():
                return {}
            outs = [o for p in [*paths, ersatz] if (o := _cross_schema(_json_file(p))) is not None]
        if len(outs) != 2:
            if outs:
                _log(topic, "Sub-Crossblock: nur 1/2 Richter — fail-open")
            return {}
        final = {k: (outs[0].get(k, "nein") if outs[0].get(k, "nein") == outs[1].get(k, "nein")
                     else "uneinig") for k in range(1, len(chunk) + 1)}
        disputed = [k for k, v in final.items() if v == "uneinig"]
        if disputed:  # tie-breaker: a third judge sees ONLY the disputed pairs, majority 2/3
            d_lines = "\n\n".join(
                f"{x}.\n{_side('A', rows[chunk[k - 1][0]])}\n{_side('B', rows[chunk[k - 1][1]])}"
                for x, k in enumerate(disputed, 1))
            p3 = work_dir / f"sub-crossblock-{h}-j3.json"
            await _judge(3, p3, d_lines, len(disputed))
            if ctx.is_cancelled():
                return {}
            v3 = _cross_schema(_json_file(p3)) or {}
            if not v3:
                _log(topic, "Sub-Crossblock j3 ohne Ergebnis — strittige Paare bleiben")
            for x, k in enumerate(disputed, 1):
                t = v3.get(x, "nein")
                if t in (outs[0].get(k, "nein"), outs[1].get(k, "nein")):
                    final[k] = t  # majority 2/3; anything else stays disputed → no fold
        return final

    chunk_finals = await asyncio.gather(*[_urteile_chunk(c) for c in chunks])
    if ctx.is_cancelled():
        return
    final_all: dict[int, str] = {}  # global pair index (1-based over `pairs`) → verdict
    for cnr, fin in enumerate(chunk_finals):
        for k, v in fin.items():
            final_all[cnr * CROSS_CHUNK_PAARE + k] = v
    journal = {"paare": len(pairs), "chunks": len(chunks), "gefaltet": [], "verdicts": []}
    gone: set[tuple] = set()
    for k, (i, j) in enumerate(pairs, 1):
        verdict = final_all.get(k, "nein")
        journal["verdicts"].append({"a": f"{rows[i]['block']} · {rows[i]['sub_title']}",
                                    "b": f"{rows[j]['block']} · {rows[j]['sub_title']}",
                                    "verdict": verdict})
        if verdict not in ("a", "b"):
            continue
        win, lose = (rows[i], rows[j]) if verdict == "a" else (rows[j], rows[i])
        wk = (win["block_norm"], win["sub_norm"])
        lk = (lose["block_norm"], lose["sub_norm"])
        if lk in gone or wk in gone:  # keeper already folded → don't chain away the content
            continue
        await falte_sub(topic, files, win, lose)
        gone.add(lk)
        journal["gefaltet"].append({"weg": f"{lose['block']} · {lose['sub_title']}",
                                    "bleibt": f"{win['block']} · {win['sub_title']}"})
    if journal["gefaltet"]:
        _log(topic, f"Sub-Crossblock: {len(journal['gefaltet'])} blockübergreifende Dublette(n) gefaltet")
    hg = hashlib.md5("\n".join(f"{i}:{j}" for i, j in pairs).encode()).hexdigest()[:8]
    atomic_write_json(work_dir / f"sub-crossblock-{hg}.json", journal, indent=1)
    await _advance_all()


# ── Finalize (SERIAL): merge into the global files + DB tables ─────────────────────
def _merge_json(path, block_keys: dict) -> None:
    data = _json_file(path)
    if not isinstance(data, dict):
        data = {}
    data.update(block_keys)
    atomic_write_json(path, data, indent=1)


async def _proc_finalize(ctx: GenContext, flow: Flow, files: dict, cards):
    topic = flow.topic
    for c in cards:
        p = c["payload"]
        title = p.get("title", "")
        sidecar = p.get("sidecar") or {}
        pattern = p.get("pattern") or {}
        artefacts = p.get("artefacts") or {}
        # global sidecar files (the legacy read path of guide/frontend/resume)
        _merge_json(files["sub_roh"], {t: subs for t, subs in (p.get("raw") or {}).items()})
        _merge_json(files["facts"], p.get("facts") or {})
        _merge_json(files["sidecar"], sidecar)
        _merge_json(files["question_pattern"], pattern)
        art_global = _json_file(files["artefakte"])
        if not isinstance(art_global, dict):
            art_global = {}
        for typ in ARTEFACT_TYPES:
            kept = [e for e in art_global.get(typ, [])
                    if _norm_title(_title(str(e.get("block", "")))) != _norm_title(title)]
            art_global[typ] = kept + list(artefacts.get(typ, []))
        atomic_write_json(files["artefakte"], art_global, indent=1)
        # DB mirrors — per block only (no global deletes)
        await blocks._mirror_sidecar_db(topic, sidecar)
        # stale question/artefact rows of a PREVIOUS run keyed to gone subs: finalize only
        # upserts, so re-runs left orphans (measured: 28).
        await db.delete_question_pattern(topic, _norm_title(title))
        await db.delete_sub_artefakte(topic, _norm_title(title))
        # consensus rows of a PREVIOUS run that this run's sidecar no longer carries would
        # linger without facts/questions/artefacts (measured: 25) — drop them per block;
        # variant/discarded stay for QA. Then default-level the mirror's own stragglers.
        for btitle, subs in sidecar.items():
            keep = {_norm_title(str(s.get("title", ""))) for s in subs if isinstance(s, dict)}
            await db.delete_stale_consensus(topic, _norm_title(btitle), keep - {""})
        await db.default_subblock_levels(topic, _norm_title(title))
        sub_keys: dict[str, set[str]] = {}

        async def _keys(bnorm: str) -> set[str]:
            if bnorm not in sub_keys:
                sub_keys[bnorm] = {r["sub_norm"] for r in await db.list_subblocks(topic, bnorm)}
            return sub_keys[bnorm]

        for btitle, entries in pattern.items():
            bnorm = _norm_title(btitle)
            for e in entries if isinstance(entries, list) else []:
                sub = str(e.get("subblock", "")).strip()
                sn = _norm_title(sub)
                question = str(e.get("question", "")).strip()
                if bnorm and sn and question:
                    sn = _sub_key(await _keys(bnorm), sn)
                    await db.upsert_question_pattern(topic, bnorm, sn, btitle, sub, question)
        btitles = list(sidecar.keys())
        for typ in ARTEFACT_TYPES:
            for e in artefacts.get(typ, []):
                bt = _match_sub(str(e.get("block", "")), btitles)
                bnorm, sn = _norm_title(bt), _norm_title(str(e.get("subblock", "")))
                if not bnorm or not sn:
                    continue
                sn = _sub_key(await _keys(bnorm), sn)
                data = json.dumps({k: v for k, v in e.items() if k not in ("block", "subblock")},
                                  ensure_ascii=False)
                await db.put_sub_artifact(topic, bnorm, sn, typ, data, bt, str(e.get("subblock", "")))
        await db.kanban_advance(topic, BOARD, c["card_id"], DONE)
        _log(topic, f"Artefakte fertig: {title}")
    flow.wake.set()


# ── Outline (topic-wide barrier singleton) ─────────────────────────────────────────
OUTLINE_CARD = "outline"


async def ensure_outline_card(topic: str) -> None:
    """(Re-)queue the outline singleton — run once per generation run, after everything."""
    await db.kanban_upsert_card(topic, BOARD, OUTLINE_CARD, "outline", "outline",
                                {"title": "Gliederung"})


async def _proc_outline(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
    topic = flow.topic
    done = await db.kanban_cards(topic, board="inventory", stage="done_block")
    done.sort(key=lambda c: c["updated_at"])
    entries = {i: _entry_line(c["payload"]) for i, c in enumerate(done, 1)
               if c["payload"].get("title")}
    if entries:
        # The outline may run BEFORE finalize has merged the global facts.json — feed the
        # prereq hints of _learning_order from the card payloads instead (complete as soon
        # as every block passed the facts stage, which the trimmed barrier guarantees).
        facts_map: dict = {}
        for bc in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
            facts_map.update(bc["payload"].get("facts") or {})
        fp = flow.work_dir / "outline-facts.json"
        atomic_write_json(fp, facts_map, indent=1)
        plan = await _outline_block(ctx, _nset, {**files, "facts": fp}, entries, instructions)
        if ctx.is_cancelled():
            return
        if isinstance(plan, dict) and plan.get("chapters"):
            chapters = [
                {"title": ch.get("title", "Kapitel"),
                 "blocks": [_title(entries[n]) for n in ch.get("numbers", []) if n in entries]}
                for ch in plan["chapters"]
            ]
            await db.set_outline(topic, json.dumps({"chapters": chapters}, ensure_ascii=False))
    await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) for c in cards])
    flow.wake.set()


# ── Stage list (appended after board 1 in chain order) ─────────────────────────────
_ALT_STAGES = ("subblocks", "facts", "levels", "relevance", "question_pattern", "artefacts",
               "konsolidierung")  # abgeschaltete Stage: verwaiste Karten auf generate zurück


async def migriere_alt_karten(topic: str) -> int:
    """Harter Schnitt: Karten der alten Stage-Treppe beim Flow-Start auf `generate`
    zurücksetzen (Payload auf die Spawn-Felder reduziert — Zwischenstände der alten
    Struktur sind für die verschmolzenen Calls wertlos). → Anzahl migrierter Karten."""
    moves = []
    for c in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
        if c["stage"] in _ALT_STAGES:
            p = c["payload"]
            await db.kanban_set_payload(topic, BOARD, c["card_id"], {
                "title": p.get("title", ""), "description": p.get("description", ""),
                "n_size": p.get("n_size", 0), "sources": p.get("sources")})
            moves.append((c["card_id"], "generate"))
    if moves:
        await db.kanban_advance_many(topic, BOARD, moves)
    return len(moves)


def artefact_stages(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
                    instructions: str) -> list[Stage]:
    research_done = lambda: flow.research_done  # noqa: E731
    return [
        Stage(BOARD, "generate", lambda cs: _proc_generate(ctx, flow, files, instructions, cs)),
        Stage(BOARD, "verify", lambda cs: _proc_verify(ctx, flow, files, q, instructions, cs)),
        Stage(BOARD, "artefakte", lambda cs: _proc_artefakte(ctx, flow, files, instructions, cs)),
        Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
        # Cross-Block-Dedup (konsolidierung) entfällt im Bottom-up: Board 1 hat die Atome
        # global geclustert/dedupliziert, es gibt keine block-übergreifenden Sub-Dubletten mehr.
        Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
              barrier=True, drain=True, gate=research_done),
    ]
