"""Board 3 „Guide": one card per block, linear stages with gates between them.

  lernziele   judge-Rolle   Backward Design — objectives BEFORE writing
  zuweisung   code          chapter/order from the outline artefact + facts grounding
  writer      guide-Rolle   ONE coherent per-block text, only from VERIFIED FACTS
  pruefer     judge-Rolle   EIN Call: CoVe-Fakten + Coverage + Lesbarkeit (lasen vorher
                            denselben Text in 3 seriellen Calls) + deterministische Gates
  fix         guide-Rolle   EIN Rewrite unter allen Auflagen; bei falsch/Lücken danach
                            genau ein Re-Prüfer-Pass (der alte Lese-Fix blieb ungeprüft)

Runner: one asyncio task per card (cards are fixed from the start — no queue engine
needed); stage transitions are persisted in guide_cards, so the board is live and
cancel/resume just picks cards up at their stored stage. Assembly keeps the exact
legacy content format → content_fuer_level / TopicDetail stay untouched.
"""

import asyncio
import json
import logging
import re

import database as db
import readability
from blocks import _sink_json
from config import (FIX_LAENGE_BAND, READABILITY_ACTIVE, TEMPLATES_DIR,
                    MAX_CONCURRENT_AGENTS_PER_TOPIC, ZIELE_MAX)
from guide_qa import block_budget
from jsonio import read_json_file as _json_file
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
                      _timeout, is_guide_cancelled, run_single_slot)
from textkit import _norm_title, _parse_fragment, _title

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

GUIDE_STAGES = ("lernziele", "zuweisung", "writer", "pruefer", "fix")
STAGE_LABELS = {"lernziele": "Lernziele", "zuweisung": "Zuweisung", "writer": "Writer",
                "pruefer": "Prüfen", "fix": "Fix", "done": "Fertig"}
from config import GATE_FIX_MIN, WRITER_SPLIT_SUBS  # zentral tunebar
# Simultaneous cards = the per-topic agent cap: every card busies exactly ONE agent at a
# time (its stages run serially), so a lower number just idles slots (was hardcoded 10
# from the old 10-slot era while the .env already allowed 24).
CARD_CONCURRENCY = MAX_CONCURRENT_AGENTS_PER_TOPIC


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


def _ziele_schema(data):
    """{"ziele":[{id,text,sub}]} → list of dicts · None on invalid structure."""
    if not isinstance(data, dict) or not isinstance(data.get("ziele"), list) or not data["ziele"]:
        return None
    out, seen = [], set()
    for z in data["ziele"]:
        if not isinstance(z, dict):
            return None
        zid = str(z.get("id", "")).strip()
        text = str(z.get("text", "")).strip()
        if not zid or not text or zid in seen or len(out) >= ZIELE_MAX:
            continue
        seen.add(zid)
        out.append({"id": zid, "text": text, "sub": str(z.get("sub", "")).strip()})
    return out or None


def _gate_schema(data):
    """{"ok":true} → [] · {"claims":[{text,grund,urteil}]} → list · None invalid.
    urteil "falsch" (contradicts the facts/itself) vs "unbelegt" (true but underivable) —
    default unbelegt. Entries whose grund starts with "belegt" are dropped: one judge
    returned a 65-entry full inventory including SUPPORTED claims."""
    if not isinstance(data, dict):
        return None
    if data.get("ok") is True:
        return []
    claims = data.get("claims")
    if not isinstance(claims, list) or not claims:
        return None
    out = []
    for c in claims:
        if isinstance(c, dict) and str(c.get("text", "")).strip():
            grund = str(c.get("grund", "")).strip()
            if grund.casefold().startswith("belegt"):
                continue
            urteil = str(c.get("urteil", "")).strip().casefold()
            out.append({"text": str(c["text"]).strip(), "grund": grund,
                        "urteil": urteil if urteil == "falsch" else "unbelegt"})
    return out


def _pruefer_schema(data, ziel_ids: set[str]):
    """Verschmolzenes Prüfer-Verdikt: Claims (Fakten-Gate-Semantik via _gate_schema) +
    Coverage (ziele/luecken/ballast) + Lesbarkeit (lese_probleme). {"ok":true} = leeres
    Verdikt. `ziele` muss alle ids abdecken, wenn Ziele existieren — sonst optional."""
    if not isinstance(data, dict):
        return None
    if data.get("ok") is True:
        return {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
    if not any(k in data for k in ("claims", "ziele", "luecken", "ballast", "lese_probleme")):
        return None
    claims = _gate_schema({"claims": data["claims"]}) if data.get("claims") else []
    if claims is None:
        return None
    ziele = {}
    for k, v in (data.get("ziele") or {}).items() if isinstance(data.get("ziele"), dict) else []:
        ziele[str(k)] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
    if ziel_ids and not ziel_ids <= set(ziele):
        return None
    luecken = [{"ziel": str(l.get("ziel", "")), "fehlt": str(l.get("fehlt", ""))}
               for l in data.get("luecken", []) if isinstance(l, dict) and str(l.get("fehlt", "")).strip()]
    ballast = [str(b).strip() for b in data.get("ballast", []) if str(b).strip()]
    lese = [str(p.get("problem", "")).strip() for p in data.get("lese_probleme", [])
            if isinstance(p, dict) and str(p.get("problem", "")).strip()]
    return {"claims": claims, "ziele": ziele, "luecken": luecken, "ballast": ballast,
            "lese_probleme": lese}


def _first_section(md: str) -> dict | None:
    secs = _parse_fragment(md) if md else []
    return secs[0] if secs else None


class _Env:
    """Shared per-run context for the card tasks."""

    def __init__(self, ctx, guide_id, topic, format_name, instructions, content_path,
                 subs_by_title, chapter_map, fallback_facts, spec):
        self.ctx = ctx
        self.guide_id = guide_id
        self.topic = topic
        self.format = format_name
        self.instructions = instructions
        self.content_path = content_path
        self.subs_by_title = subs_by_title    # block title → [sub dicts]
        self.chapter_map = chapter_map        # block_norm → (chapter title, ord)
        self.fallback_facts = fallback_facts  # generic source hint (legacy topics without facts)
        self.spec = spec

    def slot(self, name: str):
        return self.content_path.parent / f"{self.content_path.stem}.{name}"


def _memo(env, attr: str) -> dict:
    """Lazy per-Karte-Cache auf dem env-Objekt (funktioniert auch für Test-Mocks). Lernziele,
    Beispiel-Rows und Facts-Grounding sind während EINES Laufs immutabel, wurden aber je Karte
    2–3× neu geholt (writer, pruefer, re-pruefer). Karten haben disjunkte block_norm-Keys und
    laufen ihre Stages seriell → kein Race."""
    d = env.__dict__.get(attr)
    if d is None:
        d = env.__dict__[attr] = {}
    return d


async def _ziele(env: _Env, block_norm: str) -> list[dict]:
    cache = _memo(env, "_ziele_cache")
    if block_norm not in cache:
        cache[block_norm] = await db.list_lernziele(env.topic, block_norm)
    return cache[block_norm]


def _ziele_text(ziele: list[dict]) -> str:
    return "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"


def _card_facts(env: _Env, block_title: str) -> str:
    cache = _memo(env, "_facts_cache")
    if block_title not in cache:
        from guide import _facts_grounding  # lazy: guide imports this module
        grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])})
        cache[block_title] = grounding or env.fallback_facts
    return cache[block_title]


async def _card_examples(env: _Env, block_norm: str, subs: list[dict],
                         include_unmatched: bool = True) -> str:
    """Verified worked examples of the block as writer input, matched to `subs` via
    sub_norm (a split half gets only its own). Rows whose sub does not match (generation
    mismatch) go to the full writer / split part 1 so they never vanish silently."""
    cache = _memo(env, "_example_rows")
    if block_norm not in cache:
        cache[block_norm] = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm)
    rows = cache[block_norm]
    if not rows:
        return ""
    wanted = {_norm_title(s["title"]) for s in subs}
    out = []
    for r in rows:
        matched = r["sub_norm"] in wanted
        if not matched and not include_unmatched:
            continue
        data = json.loads(r["data"]) if isinstance(r["data"], str) else (r["data"] or {})
        steps = " ".join(f"{i}) {s}" for i, s in enumerate(data.get("steps") or [], 1))
        where = (f"Subbaustein „{r['sub_title']}“" if matched
                 else "Subbaustein unklar — dort einweben, wo es fachlich passt")
        out.append(f"- {where}:\n  Problem: {data.get('problem', '')}\n"
                   f"  Schritte: {steps}\n  Ergebnis: {data.get('result', '')}")
    if not out:
        return ""
    return ("VERIFIED WORKED EXAMPLES (already fact-checked; each belongs to ONE subblock):\n"
            + "\n".join(out) + "\n"
            "Weave each example into the ausführlich text of EXACTLY its subblock, right "
            "after the concept it applies has been explained — as a short worked-through "
            "passage (problem → steps → result recognizable, flowing prose or a compact "
            "numbered list). Take all values and results over VERBATIM, never recompute "
            "or alter them. NEVER put examples into the compact layer. Subblocks without "
            "an example get none.")


def _card_assignment(env: _Env, card: dict) -> str:
    from guide import _level_label
    lines = [f"- {card['block']}"]
    for s in env.subs_by_title.get(card["block"], []):
        lines.append(f"    [{_level_label(s)}] {s['title']}")
    return "\n".join(lines)


# Live info per active card (in-memory): what the card is doing RIGHT NOW —
# board_snapshot shows it as the info line while status == active.
_live_info: dict[tuple[str, str, str], str] = {}


def _live(env: _Env, card: dict, msg: str) -> None:
    _live_info[(env.topic, env.format, card["block_norm"])] = msg


async def _set(env: _Env, card: dict, **fields):
    card.update(fields)
    await db.set_guide_card(env.topic, env.format, card["block_norm"], **fields)


# ── Stages ─────────────────────────────────────────────────────────────────────────
async def _stage_lernziele(env: _Env, card: dict) -> bool:
    norm = card["block_norm"]
    if not await db.list_lernziele(env.topic, norm):
        subs = "\n".join(f"- [{s.get('level', 'beginner')}] {s['title']}"
                         for s in env.subs_by_title.get(card["block"], [])) or "(keine)"

        async def _versuch(suffix: str):
            path = env.slot(f"ziele-{_safe(norm)}{suffix}.json")
            return await run_single_slot(
                env.ctx, f"Lernziele {card['block']}", key=f"{env.guide_id}-ziele-{_safe(norm)}{suffix}",
                prompt=_prompt("Guide-Lernziele", topic=env.topic, block=card["block"],
                               subs=subs, facts=_card_facts(env, card["block"]),
                               out_path=path, extra=_extra(env.instructions)),
                role="judge", capabilities="files",
                payload=lambda result, p=path: _ziele_schema(_json_file(p)),
                timeout=_timeout("lernziele", len(env.subs_by_title.get(card["block"], []))))

        status, ziele = await _versuch("")
        if status == CANCELLED:
            return False
        if status == FAILED:
            await _set(env, card, status="error", gate_info="Lernziele ohne Ergebnis")
            return False
        if not ziele:  # ein Ersatz-Versuch — leere Liste heißt: das Coverage-Gate läuft leer
            status, ziele = await _versuch("-2")
            if status == CANCELLED:
                return False
            if not isinstance(ziele, list):
                ziele = []
            if not ziele:
                _log(env.topic, f"Lernziele {card['block']}: zweimal leer — Block ohne Coverage-Gate")
        for z in ziele:
            await db.put_lernziel(env.topic, norm, z["id"], z["text"], _norm_title(z["sub"]))
    await _set(env, card, stage="zuweisung", status="open")
    return True


async def _stage_zuweisung(env: _Env, card: dict) -> bool:
    chapter, ord_ = env.chapter_map.get(card["block_norm"], ("Weitere Inhalte", 10_000))
    await _set(env, card, chapter=chapter, ord=ord_, stage="writer")
    return True


# A single section over ~45 subs measurably breaks the writer/coverage (Front Matter:
# 4/6 objectives open after 2 rounds). First drafts of oversized cards are written in two
# halves and merged back into ONE canonical section (all gates/assembly read one section).
# WRITER_SPLIT_SUBS: siehe config.py


def _merge_split_sections(sec_a: dict, sec_b: dict) -> str:
    """Rebuild ONE canonical fragment from two half-sections: header + anchor from part A,
    sub blocks of both parts in order, both layers. Part B's framing is dropped — its
    prompt forbids an intro; keeping it would inject a second lead-in mid-section."""
    lines = []
    if sec_a.get("chapters"):
        lines.append(f"<!-- kapitel: {sec_a['chapters']} -->")
    lines.append(f"<!-- section: {sec_a['title']} -->")
    lines.append("<!-- compact -->")
    if sec_a.get("anker_compact"):
        lines.append(sec_a["anker_compact"])
    for sub in [*sec_a["subs"], *sec_b["subs"]]:
        if sub.get("compact"):
            lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
            lines.append(sub["compact"])
    lines.append("<!-- ausführlich -->")
    if sec_a.get("anchor"):
        lines.append(sec_a["anchor"])
    for sub in [*sec_a["subs"], *sec_b["subs"]]:
        if sub.get("md"):
            lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
            lines.append(sub["md"])
    return "\n\n".join(lines)


async def _write_split(env: _Env, card: dict, ziele_text: str):
    """First draft in two halves (parallel), merged into one section.
    → merged text | None (failed) | False (cancelled)."""
    from guide import _level_label
    norm = card["block_norm"]
    subs = env.subs_by_title.get(card["block"], [])
    half = (len(subs) + 1) // 2
    parts = (subs[:half], subs[half:])
    hints = (
        "TEIL 1/2: Schreibe den Abschnitts-EINSTIEG und die folgenden Unterpunkte. "
        "Weitere Unterpunkte folgen in Teil 2 — KEIN Fazit, KEIN Ausblick am Ende.",
        "TEIL 2/2: FORTSETZUNG desselben Abschnitts. KEIN neuer Einstieg, KEINE "
        "Wiederholung von Teil 1 — direkt mit den Unterpunkten weitermachen.",
    )

    async def _one(i):
        assignment = "\n".join([f"- {card['block']}"]
                               + [f"    [{_level_label(s)}] {s['title']}" for s in parts[i]])
        path = env.slot(f"card-{_safe(norm)}-r0-{'ab'[i]}.md")
        path.unlink(missing_ok=True)

        def _payload(result, p=path):
            t = p.read_text(encoding="utf-8") if p.exists() else ""
            sec = _first_section(t)
            return t if sec and sec.get("md", "").strip() else None

        return await run_single_slot(
            env.ctx, f"Writer {card['block']} ({i + 1}/2)",
            key=f"{env.guide_id}-w-{_safe(norm)}-r0-{'ab'[i]}",
            prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
                           chapter=card.get("chapter") or "Inhalte",
                           assignment=assignment, ziele=ziele_text,
                           facts=_card_facts(env, card["block"]),
                           examples=await _card_examples(env, norm, parts[i],
                                                         include_unmatched=(i == 0)),
                           gaps="\n" + hints[i] + "\n",
                           budget=block_budget(parts[i]),
                           spec=env.spec, out_path=path, extra=_extra(env.instructions)),
            role="guide", capabilities="files", payload=_payload,
            timeout=_timeout("writer", 1))

    results = await asyncio.gather(_one(0), _one(1))
    if any(s == CANCELLED for s, _ in results):
        return False
    if any(s == FAILED for s, _ in results):
        return None
    return _merge_split_sections(_first_section(results[0][1]), _first_section(results[1][1]))


async def _stage_writer(env: _Env, card: dict) -> bool:
    norm = card["block_norm"]
    ziele_text = _ziele_text(await _ziele(env, norm))
    # oversized first drafts: two halves, merged into one canonical section
    if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
        text = await _write_split(env, card, ziele_text)
        if text is False:
            return False
        if text is None:
            await _set(env, card, status="error", gate_info="Writer (Split) ohne Ergebnis")
            return False
        await _set(env, card, md=text, stage="pruefer", status="open")
        return True

    path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
    path.unlink(missing_ok=True)

    def _payload(result):
        text = path.read_text(encoding="utf-8") if path.exists() else ""
        sec = _first_section(text)
        return text if sec and sec.get("md", "").strip() else None

    status, text = await run_single_slot(
        env.ctx, f"Writer {card['block']}", key=f"{env.guide_id}-w-{_safe(norm)}-r{card['writer_rounds']}",
        prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
                       chapter=card.get("chapter") or "Inhalte",
                       assignment=_card_assignment(env, card), ziele=ziele_text,
                       facts=_card_facts(env, card["block"]),
                       examples=await _card_examples(env, norm, env.subs_by_title.get(card["block"], [])),
                       gaps="", spec=env.spec,
                       budget=block_budget(env.subs_by_title.get(card["block"], [])),
                       out_path=path, extra=_extra(env.instructions)),
        role="guide", capabilities="files", payload=_payload,
        timeout=_timeout("writer", 1))
    if status == CANCELLED:
        return False
    if status == FAILED:
        await _set(env, card, status="error", gate_info="Writer ohne Ergebnis")
        return False
    await _set(env, card, md=text, stage="pruefer", status="open")
    return True


def _n_rel(env: _Env, card: dict) -> int:
    return sum(1 for s in env.subs_by_title.get(card["block"], [])
               if s.get("relevance") != "peripheral")


def _det_hinweise(env: _Env, card: dict, sec: dict) -> list[str]:
    """Deterministische Befunde (extern geerdet, kein LLM): Readability-Modell +
    Längenbudget — dieselbe Formel wie der QA-Detektor (guide_qa.block_budget), nur mit
    engerem Band, damit der Fix VOR der QA-Grenze greift. Gehen direkt in den Fix
    und als „nicht wiederholen"-Notiz in den Prüfer-Prompt."""
    out: list[str] = []
    subs_all = env.subs_by_title.get(card["block"], [])
    if not any(s.get("relevance") != "peripheral" for s in subs_all):
        return out  # kein Inventar als Budget-Basis → kein Längen-Urteil (wie der QA-Detektor)
    budget = block_budget(subs_all)
    aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1)
    zeichen = len(aus[1] if len(aus) == 2 else sec["md"])
    lo, hi = FIX_LAENGE_BAND
    if not (lo * budget <= zeichen <= hi * budget):
        out.append(
            f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(lo * budget)}–{round(hi * budget)}): "
            f"schreibe den ausführlich-Teil auf etwa {budget} Zeichen GESAMT um — Sockel-Prosa und "
            f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten")
    return out


async def _det_readability(sec: dict) -> list[str]:
    if not READABILITY_ACTIVE:
        return []
    hints = await asyncio.to_thread(readability.rate_sections, {1: sec["md"]})
    return [hints[1]] if hints.get(1) else []


def _auftraege(verdict: dict, det: list[str], n_rel: int = 0) -> tuple[list[str], bool]:
    """Prüfer-Verdikt → Fix-Auftragszeilen. kritisch = falsch-Claims oder Lücken
    (nur die rechtfertigen den Re-Prüfer-Pass — Fakten/Coverage sind der Qualitätskern).
    Claims-Schwelle: wenige nur-„unbelegt" lohnen keinen Fix-Pass — bei kleinen Sektionen
    sinkt sie auf die Sub-Zahl (2 unbelegte Claims in 2 Subs sind viel, nicht wenig)."""
    claims = verdict["claims"]
    falsch = [c for c in claims if c["urteil"] == "falsch"]
    schwelle = min(GATE_FIX_MIN, n_rel) if n_rel else GATE_FIX_MIN
    if claims and not falsch and len(claims) < schwelle:
        claims = []
    zeilen = [f"- CLAIM ({c['urteil']}): {c['text']}" + (f" — {c['grund']}" if c['grund'] else "")
              for c in claims]
    zeilen += [f"- LÜCKE ({l['ziel']}): {l['fehlt']}" for l in verdict["luecken"]]
    zeilen += [f"- BALLAST (kürzen): {b}" for b in verdict["ballast"]]
    zeilen += [f"- LESBARKEIT: {p}" for p in verdict["lese_probleme"]]
    zeilen += [f"- LESBARKEIT: {p}" for p in det]
    return zeilen, bool(falsch or verdict["luecken"])


async def _pruefer_call(env: _Env, card: dict, sec: dict, tag: str, det: list[str]) -> dict | None:
    """EIN Judge-Call prüft Fakten + Coverage + Lesbarkeit (die drei lasen vorher denselben
    Section-Text in drei seriellen Calls). Text-Antwort + Engine-Sink (Datei-schreibende
    Judges lieferten invalides JSON). → Verdikt | None (FAILED/CANCELLED)."""
    norm = card["block_norm"]
    ziele = await _ziele(env, norm)
    ziele_text = _ziele_text(ziele)
    ids = {z["ziel_id"] for z in ziele}
    facts = _card_facts(env, card["block"])
    ex = await _card_examples(env, norm, env.subs_by_title.get(card["block"], []))
    if ex:  # der Fix sieht dieselben Facts — Beispiele überleben den Fix-Pass
        facts += "\n\nVERIFIED WORKED EXAMPLES (count as verified facts for this check):\n" + ex
    hinweise = ("\nALREADY NOTED deterministically (do NOT repeat, they go to the fix anyway):\n"
                + "\n".join(f"- {d}" for d in det) + "\n") if det else "\n"
    path = env.slot(f"pruefer-{_safe(norm)}-{tag}.json")
    status, verdict = await run_single_slot(
        env.ctx, f"Prüfer {card['block']}", key=f"{env.guide_id}-pruef-{_safe(norm)}-{tag}",
        prompt=_prompt("Guide-Pruefer", topic=env.topic, block=card["block"],
                       section=sec["md"], facts=facts, ziele=ziele_text, spec=env.spec,
                       hinweise=hinweise, extra=_extra(env.instructions)),
        role="judge", capabilities="none",
        payload=lambda result: _sink_json(result, path, lambda d: _pruefer_schema(d, ids)),
        timeout=_timeout("pruefer", 1))
    if status != OK or verdict is None:
        return None
    for zid, ok in verdict["ziele"].items():
        if zid in ids:
            await db.set_ziel_covered(env.topic, norm, zid, ok)
    return verdict


async def _stage_pruefer(env: _Env, card: dict) -> bool:
    """Verschmolzener Qualitäts-Pass: Fakten-Gate + Coverage + Lese-Check in EINEM Call
    (vorher 3 serielle Judges + bis zu 3 Edit-Pässe, die einander überschrieben und deren
    letzter ungeprüft blieb). Befunde → Fix-Stage; ohne Befund → done."""
    sec = _first_section(card["md"])
    if sec is None:
        await _set(env, card, status="error", gate_info="Writer-Fragment unlesbar")
        return False
    det = (await _det_readability(sec)) + _det_hinweise(env, card, sec)
    verdict = await _pruefer_call(env, card, sec, f"r{card['writer_rounds']}", det)
    if verdict is None:
        if is_guide_cancelled(env.guide_id):
            return False
        # fail-open wie das alte Gate: Karte nie blockieren — deterministische Befunde
        # gehen trotzdem in den Fix
        _log(env.topic, f"Prüfer {card['block']}: kein Ergebnis — nur deterministische Checks")
        verdict = {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
    zeilen, kritisch = _auftraege(verdict, det, _n_rel(env, card))
    if not zeilen:
        await _set(env, card, md=card["md"], stage="done", status="ok", gate_info="")
        return True
    _log(env.topic, f"Prüfer {card['block']}: {len(zeilen)} Befund(e){' (kritisch)' if kritisch else ''} → Fix")
    await _set(env, card, gate_info=("KRITISCH\n" if kritisch else "") + "\n".join(zeilen),
               stage="fix", status="open")
    return True


async def _stage_fix(env: _Env, card: dict) -> bool:
    """EIN kompletter Section-Rewrite unter allen Auflagen (ersetzt Fakten-Fix +
    Writer-Revision + Lese-Fix). Danach GENAU EIN Re-Prüfer-Pass, wenn der Fix wegen
    falsch-Claims/Lücken lief — der alte Lese-Fix blieb ungeprüft. Rest-Befunde bleiben
    sichtbar (gate_info), keine weitere Fix-Runde."""
    from guide import _level_label
    norm = card["block_norm"]
    sec = _first_section(card["md"])
    if sec is None:
        await _set(env, card, status="error", gate_info="Fragment unlesbar")
        return False
    info = card.get("gate_info") or ""
    kritisch = info.startswith("KRITISCH\n")
    auftraege = info.removeprefix("KRITISCH\n")
    subs = env.subs_by_title.get(card["block"], [])
    sub_list = "\n".join(f"- [{_level_label(s)}] {s['title']}" for s in subs) or "(none)"
    fixp = env.slot(f"fix-{_safe(norm)}-r{card['writer_rounds']}.md")
    fixp.unlink(missing_ok=True)

    def _fixload(result):
        text = fixp.read_text(encoding="utf-8") if fixp.exists() else ""
        return text if _first_section(text) else None

    fstatus, fixed = await run_single_slot(
        env.ctx, f"Fix {card['block']}", key=f"{env.guide_id}-gfix-{_safe(norm)}-r{card['writer_rounds']}",
        prompt=_prompt("Guide-Fix", topic=env.topic, format_name=env.format, block=card["block"],
                       section=card["md"], facts=_card_facts(env, card["block"]), spec=env.spec,
                       auftraege=auftraege, sub_list=sub_list, out_path=fixp,
                       extra=_extra(env.instructions)),
        role="guide", capabilities="files", payload=_fixload,
        timeout=_timeout("writer", 1))
    if fstatus == CANCELLED:
        return False
    angewandt = False
    if fstatus == OK and fixed:
        new_sec = _first_section(fixed)
        # marker invariant: a fix that loses the sub markers kills the level filter → discard
        if sec.get("subs") and not (new_sec and new_sec.get("subs")):
            _log(env.topic, f"Fix {card['block']} ohne Sub-Marker — verworfen")
        else:
            card["md"] = fixed
            angewandt = True
    rest = ""
    if not angewandt:
        # Fix ohne Ergebnis: Befunde nicht stumm löschen — sie bleiben im gate_info sichtbar
        rest = "Fix ohne Ergebnis — offene Befunde:\n" + auftraege
        _log(env.topic, f"Fix {card['block']}: nicht angewandt — Befunde bleiben sichtbar")
    if kritisch and angewandt:
        sec2 = _first_section(card["md"])
        verdict = await _pruefer_call(env, card, sec2, "re", [])
        if verdict is None and is_guide_cancelled(env.guide_id):
            return False
        if verdict is None:
            rest = "Re-Prüfer ohne Ergebnis — Fix ungeprüft übernommen"
            _log(env.topic, f"Re-Prüfer {card['block']}: kein Ergebnis — Fix ungeprüft übernommen")
        else:
            zeilen, _k = _auftraege(verdict, [], _n_rel(env, card))
            if zeilen:
                rest = "Rest-Befunde nach Fix:\n" + "\n".join(zeilen)
                _log(env.topic, f"Re-Prüfer {card['block']}: {len(zeilen)} Rest-Befund(e) bleiben")
    await _set(env, card, md=card["md"], stage="done", status="ok", gate_info=rest)
    return True


_STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
             "writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix}


async def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
    async with sem:
        try:
            await _run_card_inner(env, card)
        finally:
            _live_info.pop((env.topic, env.format, card["block_norm"]), None)


async def _run_card_inner(env: _Env, card: dict) -> None:
    while card["stage"] != "done":
        if is_guide_cancelled(env.guide_id):
            await _set(env, card, status="open")  # no longer being worked
            return
        fn = _STAGE_FN.get(card["stage"])
        if fn is None:  # unknown stage → park as error
            await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
            return
        if card["status"] != "active":
            await _set(env, card, status="active")  # live board: this card is being worked
        _live(env, card, STAGE_LABELS.get(card["stage"], card["stage"]) + "…")
        try:
            if not await fn(env, card):
                return
        except Exception as e:
            log.exception("[%s] guide card %s failed", env.topic, card["block"])
            await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300])
            return


# ── Orchestration ──────────────────────────────────────────────────────────────────
async def _progress_reporter(guide_id: str, topic: str, format_name: str, takt: float = 2.0) -> None:
    """Live-Fortschritt fürs Frontend; ein DB-Fehler darf den Reporter nie beenden
    (der Fortschritt fror sonst still ein), unveränderter Stand wird nicht geschrieben."""
    zuletzt = None
    while True:
        try:
            counts = await db.guide_stage_counts(topic, format_name)
            stand = (counts.get("done", 0), sum(counts.values()))
            if stand != zuletzt:
                zuletzt = stand
                await db.update_guide(guide_id, progress=f"Board: {stand[0]}/{stand[1]} Karten fertig")
        except Exception:
            log.exception("[%s] guide progress reporter", topic)
        await asyncio.sleep(takt)


async def _chapter_map(topic: str, entries: dict[int, str]) -> dict[str, tuple[str, int]]:
    """block_norm → (chapter title, global order) from the outline artefact."""
    from guide import _outline_from_db, _fallback_outline, _with_remainder
    plan = await _outline_from_db(topic, entries) or _fallback_outline(entries)
    plan = _with_remainder(plan, entries)
    out: dict[str, tuple[str, int]] = {}
    i = 0
    for ch in plan:
        for num in ch.get("nums", []):
            if num in entries:
                out[_norm_title(_title(entries[num]))] = (ch.get("title") or "Kapitel", i)
                i += 1
    return out


async def run_guide_board(guide_id: str, topic: str, format_name: str, entries: dict[int, str],
                          instructions: str, provider: str, content_path) -> list[dict] | None:
    """Seed one card per block (existing cards keep their stage — resume), run all cards,
    assemble the chapters in the legacy content format. → chapters | None (cancel/empty)."""
    from blocks import source_folder
    from guide import _load_subblocks
    ctx = GenContext(topic=topic, provider=provider,
                     is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
    import uuid
    from datetime import datetime, timezone
    db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
    try:
        spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
        subs_raw = await _load_subblocks(topic)
        project = source_folder(topic)
        fallback = (_prompt("Guide-Facts-Projekt", project=project) if project
                    else _prompt("Guide-Facts-Thema"))
        env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
                   subs_raw, await _chapter_map(topic, entries), fallback, spec)
        await db.upsert_guide_cards_many(
            topic, format_name,
            [(_norm_title(_title(line)), _title(line)) for line in entries.values()])
        cards = await db.list_guide_cards(topic, format_name)
        open_cards = [c for c in cards if c["stage"] != "done"]
        if open_cards:
            sem = asyncio.Semaphore(CARD_CONCURRENCY)
            reporter = asyncio.create_task(_progress_reporter(guide_id, topic, format_name))
            try:
                ergebnisse = await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards],
                                                  return_exceptions=True)
                for c, r in zip(open_cards, ergebnisse):
                    if isinstance(r, BaseException):
                        log.error("[%s] guide card task %s: %r", topic, c["block"], r)
            finally:
                reporter.cancel()
        if is_guide_cancelled(guide_id):
            return None
        # assembly — identical shape to the legacy pipeline
        cards = await db.list_guide_cards(topic, format_name)
        chapters: list[dict] = []
        by_chapter: dict[str, list[dict]] = {}
        order: list[str] = []
        for c in sorted(cards, key=lambda c: (c["ord"], c["block_norm"])):
            if c["stage"] != "done":
                _log(topic, f"Guide: Karte '{c['block']}' nicht fertig ({c['stage']}) — Abschnitt fehlt")
                continue
            sec = _first_section(c["md"])
            if sec is None:
                continue
            ch = c["chapter"] or "Inhalte"
            if ch not in by_chapter:
                by_chapter[ch] = []
                order.append(ch)
            by_chapter[ch].append({
                "num": c["ord"], "title": c["block"], "md": sec["md"],
                "compact": sec.get("compact", ""), "anchor": sec.get("anchor", ""),
                "anker_compact": sec.get("anker_compact", ""), "subs": sec.get("subs", []),
                "checkable": format_name == "Guide" or bool(
                    any(s.get("relevance") == "relevant" for s in subs_raw.get(c["block"], []))),
            })
        for ch in order:
            chapters.append({"title": ch, "sections": by_chapter[ch]})
        if chapters:
            try:  # Abschluss-Guide-QA (best-effort): speist das Badge mit einer frischen Note
                import guide_qa
                rep = await guide_qa.guide_qa_report(topic, llm=True)
                if rep:
                    await asyncio.to_thread(guide_qa._write_report, rep)
            except Exception:
                log.exception("[%s] Abschluss-Guide-QA fehlgeschlagen", topic)
        return chapters or None
    finally:
        # erst NACH der Abschluss-Guide-QA leeren: deren Judge-Events gehören zum
        # Lauf — vorher fielen sie ohne run_id aus jeder Run-Aggregation
        db.set_current_run(topic, None)


async def done_step(topic: str, format_name: str) -> int:
    """Sidebar dots: highest fully completed stage index. -1 = nothing, len(stages) at done."""
    counts = await db.guide_stage_counts(topic, format_name)
    if not counts:
        return -1
    if set(counts) == {"done"}:
        return len(GUIDE_STAGES)
    lowest = min(GUIDE_STAGES.index(s) for s in counts if s in GUIDE_STAGES)
    return lowest - 1 if lowest > 0 else -1


async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
    """Live guide board: columns with counts + cards (title, rounds, covered objectives)."""
    cards = await db.list_guide_cards(topic, format_name)
    ziele = {}
    for z in await db.list_lernziele(topic):
        d = ziele.setdefault(z["block_norm"], [0, 0])
        d[1] += 1
        d[0] += 1 if z["covered"] else 0
    columns = []
    for stage in (*GUIDE_STAGES, "done"):
        in_stage = [c for c in cards if c["stage"] == stage]
        views = []
        for c in in_stage[:limit]:
            zc = ziele.get(c["block_norm"])
            info = c["gate_info"][:200] if c["status"] == "error" else ""
            if c["status"] == "active":
                info = _live_info.get((topic, format_name, c["block_norm"]), "") or info
            views.append({"title": c["block"], "card_id": c["block_norm"],
                          "status": c["status"] if c["status"] in ("error", "active") else "open",
                          "rounds": c["writer_rounds"],
                          "info": info,
                          "ziele": f"{zc[0]}/{zc[1]}" if zc else ""})
        columns.append({"key": stage, "label": STAGE_LABELS[stage],
                        "total": len(in_stage), "cards": views})
    import qa as qa_mod  # lazy wie in board_inventory
    note_guide = (qa_mod.latest_report(topic, guide=True) or {}).get("note_guide")
    return {"columns": columns, "qa_guide": note_guide}


async def repair_karten(topic: str, format_name: str) -> list[str]:
    """QA-Befund-getriebenes Guide-Repair: Karten, die im jüngsten Guide-QA-Report
    Befunde tragen, gehen zurück auf `pruefer` (md bleibt) — Prüfer+Fix beheben gezielt,
    generate_guide resumt die offenen Karten und misst am Ende neu. Pendant zum
    Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel."""
    import qa as qa_mod
    reports = qa_mod.report_paths(topic, guide=True)
    rep = _json_file(reports[-1]) if reports else None
    if not rep:
        return []
    cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
    norms: set[str] = set()
    for e in rep.get("marker_fehlend", []):        # "Block · sub"
        norms.add(_norm_title(str(e).split(" · ")[0]))
    for e in rep.get("ziel_ohne_anker", []):       # "block_norm · (id) text"
        norms.add(str(e).split(" · ")[0])
    for e in rep.get("laengen_ausreisser", []):    # {"block": titel}
        norms.add(_norm_title(e.get("block", "") if isinstance(e, dict) else str(e)))
    for e in rep.get("lesbarkeit", []):            # "Block: hinweis"
        norms.add(_norm_title(str(e).split(":")[0]))
    for t in rep.get("fachlich_falsch", []) or []:
        norms.add(_norm_title(str(t)))
    for e in rep.get("redundanz", []):             # {"a": "Block: absatz", "b": …}
        for seite in ("a", "b"):
            norms.add(_norm_title(str(e.get(seite, "")).split(":")[0]))
    betroffen = []
    for n in sorted(norms & set(cards)):
        await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
        betroffen.append(cards[n]["block"])
    return betroffen


