"""Guide-Generierung als Konsens-Pipeline.

Gliederung: Auswahl der Bausteine (deterministisch je Format) → 3 Vorschläge
(Grace), die Bausteine NUMMERN-basiert in Kapitel ordnen → ein Judge merged die
Vorschläge zu einer kohärenten Reihenfolge.
Schreiben: Writer je Baustein. Lese-Prüfung: Check→Fix (eine Runde),
Folgerunden prüfen nur ersetzte Sections; danach bleiben Beanstandungen stehen.
Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
"""

import asyncio
import json
import logging
import math
from datetime import datetime, timezone
from pathlib import Path

from agents import run_agent
from bausteine import _pdfs_konvertieren, quelle_ordner
from config import (
    DEFAULT_PROVIDER, FORMAT_ZWECK, KONSENS_GRACE,
    LESBARKEIT_AKTIV, TEMPLATES_DIR,
)
import lesbarkeit
from database import list_guides, update_guide, list_bausteine, list_subbausteine, set_guide_content
from fsutil import atomic_write_json, atomic_write_text
from jsonio import read_json_file as _json_datei
from paths import bausteine_path, guide_content_path, project_dir, subbausteine_path
from pipeline import (
    CANCELLED, FAILED, GenContext, _claude_error, _extra,
    _fail, _gather_error, _gather_fortschritt, _log, _prompt, _race,
    _semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
    is_guide_cancelled, run_single_slot,
)
from textkit import (
    _eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks,
    _titel, _titel_aufloesen, _titel_index,
)

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

GUIDE_STEPS = ("Gliederung", "Inhalte", "Inhalts-Check", "Schreiben", "Lese-Prüfung")

# Inhalte/Inhalts-Check/Lese-Prüfung laufen in Paketen von ~GUIDE_CHUNK Bausteinen je Agent.
# Nur der Writer (Schreiben) bleibt 1 Agent je Baustein (variable Längen, kein Kürzen, keine
# Längen-Angleichung zwischen Bausteinen).
GUIDE_CHUNK = 10

# Prüf-Schritte als Panel: CHECK_PANEL Judges je Chunk, Section beanstandet bei Mehrheit.
# Ein einzelner Judge ist bias-/sampling-anfällig; ein kleines Panel ist stabiler.
CHECK_PANEL = 3

# Lese-Prüfung: nur EINE Runde (Check + Fix). Folgerunden brachten kaum Mehrwert
# (1 Agent je Baustein prüft ohnehin fein), kosten aber extra Agenten.
LESE_RUNDEN = 1


async def _load_subbausteine(topic: str) -> dict[str, list[dict]]:
    """Subbausteine je Baustein — DB-first ({titel, stufe, relevanz}), Fallback Sidecar-Datei.
    Fehlt beides → {} (Guide nimmt alles)."""
    out: dict[str, list[dict]] = {}
    for r in await list_subbausteine(topic):
        if r["status"] == "konsens" and r["sub_titel"] and r["stufe"] in ("einfach", "mittel", "schwer"):
            out.setdefault(r["baustein"], []).append(
                {"titel": r["sub_titel"], "stufe": r["stufe"], "relevanz": r["relevanz"]})
    if out:
        return out
    data = _json_datei(subbausteine_path(topic))
    if not isinstance(data, dict):
        return {}
    for titel, subs in data.items():
        if not isinstance(subs, list):
            continue
        gut = [s for s in subs if isinstance(s, dict) and str(s.get("titel", "")).strip()
               and s.get("stufe") in ("einfach", "mittel", "schwer")]
        if gut:
            out[titel] = gut
    return out


def _zuteilung_subs(chunk: list[dict], entries: dict[int, str], subs_by_titel: dict[str, list[dict]]) -> str:
    """Listet je Kapitel die Bausteine, darunter ihre Subbausteine mit Stufe."""
    lines: list[str] = []
    for ch in chunk:
        lines.append(f"KAPITEL: {ch['title']}")
        for num in ch["nums"]:
            lines.append(f"- {entries[num]}")
            for s in subs_by_titel.get(_titel(entries[num]), []):
                lines.append(f"    [{s['stufe']}] {s['titel']}")
    return "\n".join(lines)


def _guide_files(content_path: Path) -> dict:
    d, stem = content_path.parent, content_path.stem
    return {
        "gliederung_slots": [d / f"{stem}.gliederung-{i}.json" for i in (1, 2, 3)],
        "gliederung": d / f"{stem}.gliederung.json",  # Judge-Ausgabe
        # chunk-/lese-check-/fix-Dateien sind dynamisch:
        # {stem}.chunk-i.md, {stem}.lese-check-r{n}-{i}.json, {stem}.fix-r{n}-{i}.md
    }


def guide_slot_dateien(content_path: Path) -> list[Path]:
    """Alle Schritt-Dateien eines Guides (für den Frischstart)."""
    return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path]


def _fertig_path(content_path: Path) -> Path:
    return content_path.parent / f"{content_path.stem}.fertig"


def guide_fertig_step(content_path: Path) -> int:
    """Höchster VOLL abgeschlossener Schritt-Index (Marker je Thema+Format). -1 = keiner.
    Existiert die Content-Datei, sind alle Schritte fertig."""
    if content_path.exists():
        return len(GUIDE_STEPS) - 1
    try:
        return int(_fertig_path(content_path).read_text(encoding="utf-8").strip())
    except (OSError, ValueError):
        return -1


def _set_fertig(content_path: Path, step: int) -> None:
    """Marker auf `step` setzen — monoton (nur erhöhen), außer beim Re-Run-Reset (force)."""
    if step > guide_fertig_step(content_path):
        atomic_write_text(_fertig_path(content_path), str(step))


def _reset_fertig(content_path: Path, step: int) -> None:
    """Marker hart auf `step` setzen (für Re-Run ab Schritt; step kann sinken)."""
    if step < 0:
        _fertig_path(content_path).unlink(missing_ok=True)
    else:
        atomic_write_text(_fertig_path(content_path), str(step))


# Slot-Datei-Globs je Schritt (Index = GUIDE_STEPS). Stem-verankert, kollisionsfrei.
_STEP_GLOBS = (
    ("gliederung*",),                        # 0 Gliederung (inkl. Auswahl-Filter)
    ("inhalt-chunk-*", "inhalt-nach-*"),     # 1 Inhalte (inkl. Nachrunde)
    ("inhalt-check-*", "inhalt-fix-*"),      # 2 Inhalts-Check
    ("chunk-*",),                            # 3 Schreiben (chunk-* matcht auch chunk-nach-*)
    ("lese-check-*", "fix-r*"),           # 4 Lese-Prüfung
)


def _reset_guide_ab_step(content_path: Path, step: int) -> None:
    """Re-Run ab Schritt: Content + alle Slot-Dateien der Schritte ≥ step löschen.
    Frühere Schritte bleiben → der Resume baut ab `step` neu (alles darunter wiederverwendet)."""
    content_path.unlink(missing_ok=True)  # nicht mehr „done" → kein Frischstart-Wipe
    d, stem = content_path.parent, content_path.stem
    for globs in _STEP_GLOBS[step:]:
        for pat in globs:
            for p in d.glob(f"{stem}.{pat}"):
                p.unlink(missing_ok=True)
    _reset_fertig(content_path, step - 1)  # Schritte < step gelten als fertig


def _lese_probleme_schema(data):
    """{"ok": true} → [] · {"probleme": [{"section", "problem"}]} → Liste · sonst None."""
    if not isinstance(data, dict):
        return None
    if data.get("ok") is True:
        return []
    p = data.get("probleme")
    if not isinstance(p, list) or not p:
        return None
    out = []
    for x in p:
        if not isinstance(x, dict) or not isinstance(x.get("section"), str) or not isinstance(x.get("problem"), str):
            return None
        out.append({"section": x["section"].strip(), "problem": x["problem"].strip()})
    return out or None


def _panel_probleme(judge_paths: list[Path], geltung: set[int], idx: dict[str, int]) -> dict[int, str]:
    """Panel-Aggregation: mehrere Judge-Outputs eines Chunks → beanstandete {num: problem}.

    Eine Stimme je Judge, der eine Section nennt. Beanstandet, wenn > Hälfte der
    GELIEFERTEN (valid geparsten) Judges sie nennt (3→≥2, 2→≥2, 1→≥1). Robust gegen
    Einzel-Ausfall: fehlende Dateien zählen nicht mit. Problem-Text vom erstnennenden Judge.
    """
    outputs = [p for p in (_lese_probleme_schema(_json_datei(j)) for j in judge_paths) if p is not None]
    if not outputs:
        return {}
    votes: dict[int, int] = {}
    problem: dict[int, str] = {}
    for out in outputs:
        gesehen: set[int] = set()
        for item in out:
            num = _titel_aufloesen(idx, item["section"])
            if num is None or num not in geltung or num in gesehen:
                continue
            gesehen.add(num)
            votes[num] = votes.get(num, 0) + 1
            problem.setdefault(num, item["problem"])
    schwelle = len(outputs) / 2
    return {num: problem[num] for num, v in votes.items() if v > schwelle}


def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None:
    """{"kapitel": [{"titel", "nummern": [1, 3, 7]}]} → [{"title", "nums"}].

    Nummern sind die IDs aus `entries` (1-basiert, wie dem Agenten präsentiert).
    `soll_min`/`soll_max` = erlaubte Spanne gewählter Bausteine (mit kleiner Toleranz).
    """
    if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list):
        return None
    gueltig = set(entries)
    chapters: list[dict] = []
    seen: set[int] = set()
    total = unknown = 0
    for ch in data["kapitel"]:
        if not isinstance(ch, dict) or not isinstance(ch.get("nummern"), list):
            return None
        nums = []
        for t in ch["nummern"]:
            total += 1
            num = t if isinstance(t, int) and not isinstance(t, bool) else None
            if num is None or num not in gueltig:
                unknown += 1
            elif num not in seen:
                nums.append(num)
                seen.add(num)
        if nums:
            chapters.append({"title": str(ch.get("titel", "")).strip() or "Kapitel", "nums": nums})
    if not chapters or total == 0:
        return None
    if (total - unknown) / total < 0.85:
        return None
    if len(seen) < 0.9 * soll_min or len(seen) > 1.1 * soll_max:
        return None
    return chapters


def _fallback_gliederung(entries: dict[int, str]) -> list[dict]:
    """Deterministische Gliederung, wenn die Agenten keine liefern: ein Kapitel mit
    allen gewählten Bausteinen in Reihenfolge. Garantiert vollständige Abdeckung."""
    return [{"title": "Inhalte", "nums": list(entries)}]


def _mit_resten(plan: list[dict], entries: dict[int, str]) -> list[dict]:
    """Stellt sicher, dass JEDER gewählte Baustein im Plan steht — fehlende landen in
    einem Kapitel „Weitere" (gegen Agenten/Judge, die Bausteine weglassen)."""
    drin = {num for ch in plan for num in ch.get("nums", [])}
    fehlen = [num for num in entries if num not in drin]
    return [*plan, {"title": "Weitere", "nums": fehlen}] if fehlen else plan


async def _generate_sections(
    guide_id: str, topic: str, format_name: str, entries: dict[int, str],
    facts: str, instructions: str, provider: str,
    content_path: Path,
) -> list[dict] | None:
    def is_cancelled() -> bool:
        return is_guide_cancelled(guide_id)

    ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id)
    spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
    files = _guide_files(content_path)
    zweck = FORMAT_ZWECK[format_name]

    # Subbausteine je Baustein (DB-first) — früh geladen: steuert Auswahl + Sub-Filter je Format.
    # Fehlt sie → {} (Fallback: Guide nimmt alles).
    subs_raw = await _load_subbausteine(topic)

    def _hat_relevanz(num, art):
        return any(isinstance(s, dict) and s.get("relevanz") == art for s in subs_raw.get(_titel(entries[num]), []))

    # Auswahl (Teil von Gliederung). Deterministisch je Format:
    #   Guide=relevante Bausteine · FullGuide=alle · Rest=reine Rand-Bausteine (keine im Guide).
    if format_name == "FullGuide":
        auswahl = list(entries)
    elif format_name == "Rest":
        auswahl = [num for num in entries if not _hat_relevanz(num, "relevant")]
    else:  # Guide
        auswahl = [num for num in entries if _hat_relevanz(num, "relevant")]
        if not auswahl:
            _log(topic, "Guide: keine Relevanz-Daten — alle Bausteine genommen")
            auswahl = list(entries)
    if not auswahl:
        await _fail(guide_id, "Keine passenden Bausteine für dieses Format")
        return None

    sel_entries = {num: entries[num] for num in auswahl}
    soll = len(sel_entries)
    # Nummerierte Liste (ID = Baustein-Nummer aus entries) — Agenten/Judge ordnen per Nummer.
    sel_liste = "\n".join(f"{num}. {t}" for num, t in sel_entries.items())

    # Schritt 0: Gliederung — bis zu 3 Vorschläge (Grace), ein Judge merged sie. Bricht NIE ab:
    # 0 gültige → Code-Fallback, 1 → direkt, ≥2 → Judge (mit Vorschlag als Rückfall).
    # Gültiges gliederung.json überspringt den Schritt.
    plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
    if plan is None:
        await _set_step(guide_id, 0, "Gliederungs-Vorschläge (3 Agenten)…")
        files["gliederung"].unlink(missing_ok=True)
        vorschlaege: list[list[dict]] = []
        offen = []
        for i, path in enumerate(files["gliederung_slots"], 1):
            res = _resolve_gliederung(_json_datei(path), sel_entries, soll, soll)
            if res is not None:
                vorschlaege.append(res)
            else:
                offen.append((i, path))
        if len(vorschlaege) < 3 and offen:
            slots = [
                {
                    "key": f"{guide_id}-gliederung-{i}",
                    "prompt": _prompt(
                        "Guide-Gliederung",
                        topic=topic, format_name=format_name, bausteine=sel_liste,
                        out_path=path, extra=_extra(instructions),
                    ),
                    "role": "guide", "capabilities": "files",
                    "payload": (lambda result, p=path: _resolve_gliederung(_json_datei(p), sel_entries, soll, soll)),
                }
                for i, path in offen
            ]
            # Quorum 1: nimm, was kommt — kein Mindestzwang, kein Abbruch.
            neue = await _race(
                topic, "Gliederung", slots, 1, _timeout("plan", soll),
                provider, cancelled=is_cancelled, grace=KONSENS_GRACE,
            )
            if is_cancelled():
                return None
            vorschlaege += neue or []

        if not vorschlaege:
            _log(topic, "Gliederung: kein gültiger Vorschlag — deterministischer Fallback")
            plan = _fallback_gliederung(sel_entries)
        elif len(vorschlaege) == 1:
            plan = vorschlaege[0]  # ein Vorschlag → kein Judge nötig
        else:
            await _set_step(guide_id, 0, "Gliederungen zusammenführen…")
            bloecke = "\n\n".join(
                f"### Vorschlag {i}\n"
                + "\n".join(f"KAPITEL: {ch['title']}\n  Nummern: {', '.join(str(num) for num in ch['nums'])}" for ch in v)
                for i, v in enumerate(vorschlaege, 1)
            )
            status, plan = await run_single_slot(
                ctx, "Gliederungs-Judge",
                key=f"{guide_id}-gliederung-judge",
                prompt=_prompt(
                    "Guide-Gliederung-Judge",
                    topic=topic, format_name=format_name, zweck=zweck, n=len(vorschlaege),
                    bausteine=sel_liste, gliederungen=bloecke,
                    out_path=files["gliederung"], extra=_extra(instructions),
                ),
                role="judge", capabilities="files",
                payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
                timeout=_timeout("plan_judge", soll),
            )
            if status == CANCELLED:
                return None
            if status == FAILED or plan is None:
                _log(topic, "Gliederung-Judge ohne Ergebnis — bester Vorschlag bleibt")
                plan = vorschlaege[0]

    # Garantie: jeder gewählte Baustein steht im Plan (gegen weglassende Agenten/Judges).
    plan = _mit_resten(plan, sel_entries)
    _set_fertig(content_path, 0)  # Gliederung steht

    # Grobe Chunks (~GUIDE_CHUNK Bausteine je Agent) für Inhalte, Inhalts-Check und Lese-Prüfung.
    # Der Writer baut darunter pro Baustein (eigene feine Chunks, s.u.) → variable Längen.
    total_sections = sum(len(c["nums"]) for c in plan)
    chunks = _split_chunks(plan, max(1, math.ceil(total_sections / GUIDE_CHUNK)))
    # Subbausteine je Baustein, je Format gefiltert: Guide=relevant · FullGuide=alle · Rest=Rand.
    # Subs ohne Relevanz-Feld (Alt-Themen) zählen als „nicht Rand" (→ Guide/FullGuide).
    if format_name == "FullGuide":
        subs_by_titel = {t: list(subs) for t, subs in subs_raw.items()}
    elif format_name == "Rest":
        subs_by_titel = {t: [s for s in subs if s.get("relevanz") == "rand"] for t, subs in subs_raw.items()}
    else:  # Guide
        subs_by_titel = {t: [s for s in subs if s.get("relevanz") != "rand"] for t, subs in subs_raw.items()}
    subs_by_titel = {t: subs for t, subs in subs_by_titel.items() if subs}
    zuteilungen = [_zuteilung_subs(chunk, entries, subs_by_titel) for chunk in chunks]
    chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks]
    writer_count = len(chunks)
    idx = _titel_index(entries)

    # Schritt 2: Inhalte je Baustein identifizieren — pro Chunk ein Agent (Marker-Output, Resume).
    inhalt_paths = [content_path.parent / f"{content_path.stem}.inhalt-chunk-{i}.md" for i in range(1, writer_count + 1)]
    offen = [i for i, p in enumerate(inhalt_paths) if not p.exists()]
    if offen:
        async def melde(d, t): await _set_step(guide_id, 1, f"Sammle Inhalte {d}/{t}…")
        results = await _gather_fortschritt([
            run_agent(
                f"{guide_id}-inhalt-{i + 1}",
                _prompt(
                    "Guide-Inhalt",
                    topic=topic, zuteilung=zuteilungen[i], facts=facts,
                    out_path=inhalt_paths[i], extra=_extra(instructions),
                ),
                _timeout("inhalt", chunk_sizes[i]), provider=provider, role="guide", capabilities="full",
            )
            for i in offen
        ], writer_count, melde, start=writer_count - len(offen))
        if is_cancelled():
            return None
        if not any(p.exists() for p in inhalt_paths):
            await _fail(guide_id, _gather_error("Inhalts-Fehler", list(results)))
            return None

    inhalt_by_num: dict[int, str] = {}
    for p in inhalt_paths:
        if not p.exists():
            continue
        for sec in _parse_fragment(p.read_text(encoding="utf-8")):
            num = _titel_aufloesen(idx, sec["titel"])
            if num is not None and num not in inhalt_by_num and sec["md"].strip():
                inhalt_by_num[num] = sec["md"]
    if not inhalt_by_num:
        await _fail(guide_id, "Keine Inhalte identifiziert")
        return None

    # Nachrunde: fehlende Bausteine (Chunk-Ausfall oder Lazy-Output) gezielt nachziehen — eine Runde.
    geplant_nums = [num for ch in plan for num in ch["nums"]]
    fehlend = [num for num in geplant_nums if num not in inhalt_by_num]
    if fehlend:
        _log(topic, f"Inhalte: {len(fehlend)} Baustein(e) fehlen — Nachrunde…")
        nach_chunks = [[{"title": "Weitere", "nums": fehlend[k:k + GUIDE_CHUNK]}] for k in range(0, len(fehlend), GUIDE_CHUNK)]
        nach_paths = [content_path.parent / f"{content_path.stem}.inhalt-nach-{k}.md" for k in range(1, len(nach_chunks) + 1)]
        nach_offen = [k for k, p in enumerate(nach_paths) if not p.exists()]
        if nach_offen:
            async def melde_n(d, t): await _set_step(guide_id, 1, f"Sammle fehlende Inhalte {d}/{t}…")
            await _gather_fortschritt([
                run_agent(
                    f"{guide_id}-inhalt-nach-{k + 1}",
                    _prompt(
                        "Guide-Inhalt",
                        topic=topic, zuteilung=_zuteilung_subs(nach_chunks[k], entries, subs_by_titel),
                        facts=facts, out_path=nach_paths[k], extra=_extra(instructions),
                    ),
                    _timeout("inhalt", len(nach_chunks[k][0]["nums"])), provider=provider, role="guide", capabilities="full",
                )
                for k in nach_offen
            ], len(nach_chunks), melde_n, start=len(nach_chunks) - len(nach_offen))
            if is_cancelled():
                return None
        for p in nach_paths:
            if not p.exists():
                continue
            for sec in _parse_fragment(p.read_text(encoding="utf-8")):
                num = _titel_aufloesen(idx, sec["titel"])
                if num is not None and num not in inhalt_by_num and sec["md"].strip():
                    inhalt_by_num[num] = sec["md"]

    if all(p.exists() for p in inhalt_paths):
        _set_fertig(content_path, 1)  # Inhalte vollständig

    inhalt_chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in inhalt_by_num] for chunk in chunks]

    # Schritt 3: Inhalte prüfen — CHECK_PANEL Judges je Chunk, Mehrheit beanstandet.
    # + beanstandete einmal überarbeiten. Resume: nur fehlende Judge-Dateien neu starten.
    check_judge_paths = [
        [content_path.parent / f"{content_path.stem}.inhalt-check-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)]
        for i in range(1, writer_count + 1)
    ]
    offen_slots = [
        (i, j) for i in range(writer_count) if inhalt_chunk_nums[i]
        for j in range(CHECK_PANEL) if _lese_probleme_schema(_json_datei(check_judge_paths[i][j])) is None
    ]
    if offen_slots:
        await _set_step(guide_id, 2, "Prüfe Inhalte…")
        sections_je_chunk = {
            i: "\n\n".join(f"SECTION: {_titel(entries[num])}\n{inhalt_by_num[num]}" for num in inhalt_chunk_nums[i])
            for i, _ in offen_slots
        }
        slots = [{
            "key": f"{guide_id}-inhalt-check-{i + 1}-j{j + 1}",
            "prompt": _prompt(
                "Guide-Inhalt-Check",
                topic=topic, format_name=format_name, sections=sections_je_chunk[i],
                out_path=check_judge_paths[i][j], extra=_extra(instructions),
            ),
            "role": "judge", "capabilities": "files",
            "payload": (lambda result, p=check_judge_paths[i][j]: _lese_probleme_schema(_json_datei(p))),
        } for i, j in offen_slots]
        n_checks = len(slots)
        upd = lambda n: asyncio.create_task(_set_step(guide_id, 2, f"Prüfe Inhalte {n}/{n_checks}…"))
        await _race(topic, "Inhalts-Prüfung", slots, len(slots), _timeout("inhalt_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=KONSENS_GRACE)
        if is_cancelled():
            return None

    probleme_by_num: dict[int, str] = {}
    for i in range(writer_count):
        if inhalt_chunk_nums[i]:
            probleme_by_num.update(_panel_probleme(check_judge_paths[i], set(inhalt_chunk_nums[i]), idx))

    if probleme_by_num:
        _log(topic, f"Inhalts-Prüfung: {len(probleme_by_num)} Baustein(e) beanstandet")
        await _set_step(guide_id, 2, f"Überarbeite {len(probleme_by_num)} Inhalt(e)…")
        fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in inhalt_chunk_nums]
        fix_paths = [content_path.parent / f"{content_path.stem}.inhalt-fix-{i + 1}.md" for i in range(writer_count)]
        fix_offen = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()]
        results = await asyncio.gather(*[
            run_agent(
                f"{guide_id}-inhalt-fix-{i + 1}",
                _prompt(
                    "Guide-Inhalt-Fix",
                    topic=topic, facts=facts,
                    auftraege="\n\n".join(
                        f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELL:\n{inhalt_by_num[num]}"
                        for num in fix_chunks[i]
                    ),
                    out_path=fix_paths[i], extra=_extra(instructions),
                ),
                _timeout("inhalt", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
            )
            for i in fix_offen
        ], return_exceptions=True)
        if is_cancelled():
            return None
        for p in fix_paths:
            if not p.exists():
                continue
            for sec in _parse_fragment(p.read_text(encoding="utf-8")):
                num = _titel_aufloesen(idx, sec["titel"])
                if num in probleme_by_num and sec["md"].strip():
                    inhalt_by_num[num] = sec["md"]

    _set_fertig(content_path, 2)  # Inhalts-Check durch

    # Schritt 4: Schreiben — Writer formuliert die geprüften Inhalte aus (Resume).
    # FEINE Chunks: genau 1 Baustein je Writer → variable Längen, kein Budget-Rationieren.
    def inhalte_text(chunk) -> str:
        nums = [num for ch in chunk for num in ch["nums"] if num in inhalt_by_num]
        return "\n\n".join(f"<!-- section: {_titel(entries[num])} -->\n{inhalt_by_num[num]}" for num in nums)

    w_chunks = [[{"title": ch["title"], "nums": [num]}] for ch in plan for num in ch["nums"]]
    w_zuteil = [_zuteilung_subs(c, entries, subs_by_titel) for c in w_chunks]
    paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, len(w_chunks) + 1)]
    offen = [i for i, p in enumerate(paths) if not p.exists()]
    if offen:
        async def melde(d, t): await _set_step(guide_id, 3, f"Schreibe Sections {d}/{t}…")
        results = await _gather_fortschritt([
            run_agent(
                f"{guide_id}-w{i + 1}",
                _prompt(
                    "Guide-Writer",
                    topic=topic, format_name=format_name, zuteilung=w_zuteil[i],
                    inhalte=inhalte_text(w_chunks[i]),
                    spec=spec, out_path=paths[i], extra=_extra(instructions),
                ),
                _timeout("writer", 1), provider=provider, role="guide", capabilities="files",
            )
            for i in offen
        ], len(w_chunks), melde, start=len(w_chunks) - len(offen))
        if is_cancelled():
            return None
        for i, r in zip(offen, results):
            if isinstance(r, BaseException):
                _log(topic, f"Writer {i + 1}: {type(r).__name__}: {r}")
            elif r[0] != 0:
                _log(topic, f"Writer {i + 1}: {_claude_error('Fehler', *r)}")
            elif not paths[i].exists():
                _log(topic, f"Writer {i + 1}: keine Ausgabedatei erstellt")
        if not any(p.exists() for p in paths):
            await _fail(guide_id, _gather_error("Writer-Fehler", list(results)))
            return None

    by_num: dict[int, dict] = {}
    for p in paths:
        if not p.exists():
            continue
        for sec in _parse_fragment(p.read_text(encoding="utf-8")):
            num = _titel_aufloesen(idx, sec["titel"])
            if num is None:
                _log(topic, f"Writer lieferte unbekannte Section '{sec['titel'][:40]}' (ignoriert)")
            elif num not in by_num:
                by_num[num] = sec
    if not by_num:
        await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
        return None

    # Nachrunde: fehlende Sections (Writer-Ausfall) gezielt nachschreiben — eine Runde.
    nach_fehlend = [num for num in geplant_nums if num not in by_num]
    if nach_fehlend:
        _log(topic, f"Schreiben: {len(nach_fehlend)} Section(s) fehlen — Nachrunde…")
        nw_chunks = [[{"title": "Weitere", "nums": [num]}] for num in nach_fehlend]
        nw_paths = [content_path.parent / f"{content_path.stem}.chunk-nach-{k}.md" for k in range(1, len(nw_chunks) + 1)]
        nw_offen = [k for k, p in enumerate(nw_paths) if not p.exists()]
        if nw_offen:
            async def melde_nw(d, t): await _set_step(guide_id, 3, f"Schreibe fehlende Sections {d}/{t}…")
            await _gather_fortschritt([
                run_agent(
                    f"{guide_id}-w-nach-{k + 1}",
                    _prompt(
                        "Guide-Writer",
                        topic=topic, format_name=format_name, zuteilung=_zuteilung_subs(nw_chunks[k], entries, subs_by_titel),
                        inhalte=inhalte_text(nw_chunks[k]), spec=spec, out_path=nw_paths[k], extra=_extra(instructions),
                    ),
                    _timeout("writer", 1), provider=provider, role="guide", capabilities="files",
                )
                for k in nw_offen
            ], len(nw_chunks), melde_nw, start=len(nw_chunks) - len(nw_offen))
            if is_cancelled():
                return None
        for p in nw_paths:
            if not p.exists():
                continue
            for sec in _parse_fragment(p.read_text(encoding="utf-8")):
                num = _titel_aufloesen(idx, sec["titel"])
                if num is not None and num not in by_num and sec["md"].strip():
                    by_num[num] = sec

    if all(p.exists() for p in paths):
        _set_fertig(content_path, 3)  # Schreiben vollständig

    # Schritt 3: Lese-Prüfungs-Loop — Check pro Writer-Paket, Fix nur für
    # beanstandete Sections; Folgerunden prüfen NUR die ersetzten Sections.
    # Nach dem Runden-Cap bleiben offene Beanstandungen stehen.
    chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks]

    def sections_text(nums: list[int]) -> str:
        return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)

    def auftraege_text(nums: list[int], probleme: dict[int, str]) -> str:
        return "\n\n".join(
            f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
            for num in nums
        )

    scope = chunk_nums
    for runde in range(1, LESE_RUNDEN + 1):
        # CHECK_PANEL Judges je Paket; Mehrheit beanstandet. Aggregation robust gegen Einzel-Ausfall.
        check_judge_paths = [
            [content_path.parent / f"{content_path.stem}.lese-check-r{runde}-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)]
            for i in range(1, writer_count + 1)
        ]
        offen_slots = [
            (i, j) for i in range(writer_count) if scope[i]
            for j in range(CHECK_PANEL) if _lese_probleme_schema(_json_datei(check_judge_paths[i][j])) is None
        ]
        if offen_slots:
            await _set_step(guide_id, 4, "Prüfe Lesbarkeit…")
            sections_je_chunk = {i: sections_text(scope[i]) for i, _ in offen_slots}
            slots = [{
                "key": f"{guide_id}-lese-check-r{runde}-{i + 1}-j{j + 1}",
                "prompt": _prompt(
                    "Guide-Lese-Check",
                    topic=topic, format_name=format_name, spec=spec,
                    sections=sections_je_chunk[i],
                    out_path=check_judge_paths[i][j], extra=_extra(instructions),
                ),
                "role": "judge", "capabilities": "files",
                "payload": (lambda result, p=check_judge_paths[i][j]: _lese_probleme_schema(_json_datei(p))),
            } for i, j in offen_slots]
            n_checks = len(slots)
            upd = lambda n: asyncio.create_task(_set_step(guide_id, 4, f"Prüfe Lesbarkeit {n}/{n_checks}…"))
            res = await _race(topic, f"Lese-Prüfung r{runde}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=KONSENS_GRACE)
            if is_cancelled():
                return None
            if res is None:
                _log(topic, f"Lese-Prüfung Runde {runde}: kein volles Quorum — vorhandene Judges aggregiert")

        probleme_by_num: dict[int, str] = {}
        for i in range(writer_count):
            if scope[i]:
                probleme_by_num.update(_panel_probleme(check_judge_paths[i], set(scope[i]), idx))

        # Deterministisches Lesbarkeits-Gate: zu schwere Sections in dieselbe
        # Überarbeitung einreihen (LLM-Beanstandung hat Vorrang). Gate aus → no-op.
        if LESBARKEIT_AKTIV:
            md_by_num = {num: by_num[num]["md"] for nums in scope for num in nums if num in by_num}
            hinweise = await asyncio.to_thread(lesbarkeit.bewerte_sections, md_by_num)
            if hinweise:
                _log(topic, f"Lesbarkeit: {len(hinweise)} Section(s) zu schwer")
            for num, hinweis in hinweise.items():
                probleme_by_num.setdefault(num, hinweis)

        if not probleme_by_num:
            break

        _log(topic, f"Lese-Prüfung Runde {runde}: {len(probleme_by_num)} Section(s) beanstandet")
        await _set_step(guide_id, 4, f"Überarbeite {len(probleme_by_num)} Section(s) (Runde {runde})…")
        fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in chunk_nums]
        fix_paths = [content_path.parent / f"{content_path.stem}.fix-r{runde}-{i + 1}.md" for i in range(writer_count)]
        fix_offen = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()]
        results = await asyncio.gather(*[
            run_agent(
                f"{guide_id}-fix-r{runde}-w{i + 1}",
                _prompt(
                    "Guide-Sections-Fix",
                    topic=topic, format_name=format_name, facts=facts, spec=spec,
                    auftraege=auftraege_text(fix_chunks[i], probleme_by_num),
                    out_path=fix_paths[i], extra=_extra(instructions),
                ),
                _timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
            )
            for i in fix_offen
        ], return_exceptions=True)
        if is_cancelled():
            return None
        for i, r in zip(fix_offen, results):
            if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0):
                _log(topic, f"Sections-Fix {i + 1} (Runde {runde}) fehlgeschlagen — Original bleibt")
        ersetzt: set[int] = set()
        for p in fix_paths:
            if not p.exists():
                continue
            for sec in _parse_fragment(p.read_text(encoding="utf-8")):
                num = _titel_aufloesen(idx, sec["titel"])
                if num in probleme_by_num and sec["md"].strip():
                    by_num[num] = sec
                    ersetzt.add(num)
        _log(topic, f"Lese-Prüfung Runde {runde}: {len(ersetzt)} Section(s) überarbeitet")
        if not ersetzt:
            break
        if runde == LESE_RUNDEN:
            _log(topic, f"Lese-Prüfung: 1 Runde — Überarbeitung bleibt ungeprüft")
            break
        scope = [[num for num in nums if num in ersetzt] for nums in chunk_nums]
    _set_fertig(content_path, 4)  # Lese-Prüfung durch

    # Prüfbar = Format hat Prüfung UND Baustein hat ≥1 relevanten Subbaustein.
    # Guide ist immer prüfbar (auch ohne Relevanz-Daten, Fallback = alles).
    def _pruefbar(num):
        if format_name == "Guide":
            return True
        if format_name == "FullGuide":
            return any(isinstance(s, dict) and s.get("relevanz") == "relevant"
                       for s in subs_raw.get(_titel(entries[num]), []))
        return False  # Rest u.a. → reine Lese-Sections

    await _set_progress(guide_id, "Setze zusammen…")
    chapters: list[dict] = []
    for ch in plan:
        sections = [
            {"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"],
             "kompakt": by_num[num].get("kompakt", ""), "pruefbar": _pruefbar(num)}
            for num in ch["nums"] if num in by_num
        ]
        if sections:
            chapters.append({"title": ch["title"], "sections": sections})
    geplant = {num for ch in plan for num in ch["nums"]}
    missing = sorted(geplant - set(by_num))
    if missing:
        _log(topic, f"Sections fehlen in der Writer-Ausgabe: {[_titel(entries[n]) for n in missing]}")
    if not chapters:
        await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
        return None
    return chapters


async def reconcile_guides() -> None:
    """DB↔Dateisystem abgleichen: status=done ohne Content-Datei → error.

    Läuft beim Server-Start (nach init_db) — fängt Crashes zwischen
    Datei-Write und Status-Update ab.
    """
    for g in await list_guides():
        if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists():
            log.warning("[%s] Guide %s: done ohne Content-Datei — auf error gesetzt", g["topic"], g["id"])
            now = datetime.now(timezone.utc).isoformat()
            await update_guide(g["id"], status="error", error_msg="Inhalt fehlt — neu generieren", updated_at=now)


async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_step: int | None = None) -> None:
    async with _semaphore:
        now = datetime.now(timezone.utc).isoformat()
        await update_guide(guide_id, status="generating", progress="Starte…", updated_at=now)

        content_path = guide_content_path(topic, format_name)
        content_path.parent.mkdir(parents=True, exist_ok=True)
        project = quelle_ordner(topic)  # Ordner-Quelle (projekt/uni/link) → Pfad, sonst None

        try:
            if is_guide_cancelled(guide_id):
                return

            if project:
                await asyncio.to_thread(_pdfs_konvertieren, project)

            # Re-Run ab Schritt: Content + Slots ab `ab_step` löschen, Rest bleibt → Resume baut ab dort.
            # Sonst „Neu erstellen": fertiger Guide → kompletter Frischstart.
            # Sonst sind Schritt-Dateien Reste eines Abbruchs/Fehlers → Resume.
            if ab_step is not None:
                _reset_guide_ab_step(content_path, ab_step)
            elif content_path.exists():
                for p_alt in guide_slot_dateien(content_path):
                    p_alt.unlink(missing_ok=True)

            bs = await list_bausteine(topic, status="konsens")
            if bs:
                alle = {i: (f"{b['titel']} — {b['beschreibung']}" if b["beschreibung"] else b["titel"])
                        for i, b in enumerate(bs, 1)}
            else:  # Fallback: bausteine.md (Alt-Themen)
                bp = bausteine_path(topic)
                alle = _lade_bausteine(bp.read_text(encoding="utf-8")) if bp.exists() else {}
            if not alle:
                await _fail(guide_id, "Keine Bausteine gefunden")
                return
            entries = _eindeutige_titel(alle)
            facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
            chapters = await _generate_sections(
                guide_id, topic, format_name, entries,
                facts, instructions, provider, content_path,
            )
            if chapters is None or is_guide_cancelled(guide_id):
                return
            content = {"topic": topic, "format": format_name, "chapters": chapters}

            atomic_write_json(content_path, content, indent=1)  # Brücke (Resume/Fallback)
            await set_guide_content(topic, format_name, json.dumps(content, ensure_ascii=False))

            now = datetime.now(timezone.utc).isoformat()
            await update_guide(guide_id, status="done", progress=None, step=None, updated_at=now)

        except asyncio.TimeoutError:
            await _fail(guide_id, "Timeout bei der Generierung")
        except FileNotFoundError:
            await _fail(guide_id, "Bausteine fehlen")
        except Exception as e:
            log.exception("[%s] Guide-Generierung fehlgeschlagen (%s)", topic, guide_id)
            await _fail(guide_id, str(e)[:2000])
        finally:
            clear_guide_cancelled(guide_id)
