import asyncio
import json
import logging
import shutil
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone

from fastapi import APIRouter, HTTPException
from fastapi.responses import Response

from agents import active_agents, provider_available
from config import DEFAULT_PROVIDER, PROJECTS_DIR, UNI_DIR, PROVIDERS
from database import (
    create_guide, delete_guide, get_guide, list_guides, update_guide,
    create_topic, list_topics as db_list_topics, delete_topic,
    list_block_progress, get_block_progress, set_open_question,
    set_block_score_and_streak,
    delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
    delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
    get_sub_artefakte, kanban_reset, delete_guide_board,
    get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm,
    list_runs, get_db,
)
from textkit import _norm_title
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files
from board_inventory import add_research_agent, board_snapshot, requeue_dead
from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup, leitner_step, PRACTICE_NEW_PER_SESSION
from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
from pipeline import cancel_guide
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
from models import (
    GuideCreateRequest, GuideResponse,
    TopicCreateRequest,
    BlocksCreateRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
    GuideFormatRequest,
    GuideChatRequest, GuideChatResponse,
    ProviderInfo,
    FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
    BlockChatRequest, BlockChatResponse,
    BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
    BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse,
    PracticeAnswerRequest,
)
from paths import blocks_topics, guide_content_path, project_dir, topic_dir, source_path, safe_folder
from fsutil import atomic_write_json

router = APIRouter(prefix="/api")


@router.get("/providers", response_model=list[ProviderInfo])
async def get_providers():
    return [{"id": pid, "available": provider_available(pid)} for pid in PROVIDERS]


@router.get("/topics")
async def get_topics():
    db_topics = await db_list_topics()
    guides = await list_guides()
    derived = {g["topic"] for g in guides}
    derived.update(blocks_topics())
    derived.update(job["topic"] for job in active_blocks())
    # DB is authoritative (order: newest first); append derived entries without a DB row at the end
    return db_topics + sorted(derived - set(db_topics))


@router.get("/stats")
async def get_stats():
    """Tracker: number of topics + per format created/completed."""
    guides, levels = await load_learnstate()
    topics = set(await db_list_topics()) | {g["topic"] for g in guides} | set(blocks_topics())
    if PROJECTS_DIR.is_dir():
        topics |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
    return {"topics": len(topics), "formats": formats_stats(guides, levels)}


@router.get("/health")
async def health():
    await (await get_db()).execute("SELECT 1")
    return {"ok": True}


@router.get("/runs")
async def get_runs(topic: str, limit: int = 10):
    """Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id."""
    return {"runs": await list_runs(topic, limit)}


@router.get("/topics/progress")
async def topic_progress(topic: str):
    """Completion status per format + topic completion — for unlocking the next expansion stage."""
    guides, levels = await load_learnstate()
    status = {fmt: ist_completed(topic, fmt, guides, levels) for fmt in FORMATE}
    status["completed"] = topic_completed(topic, guides, levels)
    return status


@router.post("/topics")
async def add_topic(req: TopicCreateRequest):
    await create_topic(req.name.strip())
    return {"ok": True}


@router.delete("/topics")
async def remove_topic(topic: str):
    guides = [g for g in await list_guides() if g["topic"] == topic]
    status = await blocks_status(topic)
    if status["generating"] or any(g["status"] == "generating" for g in guides):
        raise HTTPException(409, "Generierung läuft — erst abbrechen")
    await delete_topic(topic)
    await delete_block_data(topic)
    await delete_topic_pipeline(topic)
    await delete_source(topic)          # topic config (DB) — removed together with the topic
    await delete_guide_content(topic)
    # guides/Board/Kanban mitlöschen — GET /topics leitet Topics aus guides ab,
    # sonst taucht das gelöschte Topic sofort wieder auf
    for g in guides:
        await delete_guide(g["id"])
    await delete_guide_board(topic)
    await kanban_reset(topic)
    import qa
    # rmtree (potenziell große Topic-Ordner) in den Threadpool — der Event-Loop bedient
    # parallel laufende Flows/Polls, blockierendes Datei-I/O friert die alle ein
    def _wipe():
        shutil.rmtree(topic_dir(topic), ignore_errors=True)
        shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True)  # QA reports belong to the topic
    await asyncio.to_thread(_wipe)
    return {"ok": True}


@router.get("/folders", response_model=list[FolderResponse])
async def list_folders(kind: str):
    """Folders for the source selection: kind=projekt → projects/, kind=uni → uni/."""
    base = {"projekt": (PROJECTS_DIR, "projects"), "uni": (UNI_DIR, "uni")}.get(kind)
    if base is None:
        raise HTTPException(400, "kind must be 'projekt' or 'uni'")
    root, prefix = base
    if not root.is_dir():
        return []
    return [{"name": e.name, "location": f"{prefix}/{e.name}"} for e in sorted(root.iterdir()) if e.is_dir()]


# --- Blocks ---

@router.get("/blocks/status", response_model=BlocksStatusResponse)
async def get_blocks_status(topic: str):
    return await blocks_status(topic)


@router.get("/blocks/active")
async def get_active_blocks():
    return active_blocks()


@router.post("/blocks")
async def create_blocks(req: BlocksCreateRequest):
    topic = req.topic.strip()
    if (await blocks_status(topic))["generating"]:
        return {"ok": True, "status": "already_generating"}
    await create_topic(topic)
    qp = source_path(topic)
    # Persist the source only the FIRST time; ▶/Resume keeps the existing choice.
    if not qp.exists():
        type, location = req.source_type, req.source_location.strip()
        _validate_source(type, location)
        qp.parent.mkdir(parents=True, exist_ok=True)
        atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()})
    asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider,
                                        research=req.research, qa_force=req.qa_force,
                                        auto_inventory=req.auto_inventory, auto_artefacts=req.auto_artefacts,
                                        auto_guide=req.auto_guide))
    return {"ok": True}


@router.get("/blocks/board")
async def get_blocks_board(topic: str):
    """Live kanban board: columns with counts + newest cards, dead-letter, agents."""
    snap = await board_snapshot(topic)
    status = await blocks_status(topic)
    snap["generating"] = status["generating"]
    snap["progress"] = status["progress"]
    snap["error"] = status["error"]
    snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
                      for a in active_agents(f"blocks-{topic}-")]
    return snap


_qa_laeuft: set[str] = set()


@router.post("/blocks/qa")
async def run_qa_route(req: QaRunRequest):
    """Manual QA run (like the gate: incl. LLM samples); the badge reads the written report."""
    if req.topic in _qa_laeuft:
        return {"status": "läuft bereits"}
    _qa_laeuft.add(req.topic)
    try:
        import qa
        report = await qa.qa_report(req.topic, llm=req.llm)
        if report is None:
            raise HTTPException(status_code=404, detail="keine fertigen Bausteine")
        await qa.write_report(report)
        note_guide = None
        try:  # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
            import guide_qa
            from database import list_guide_cards
            if any(c["stage"] == "done" and (c.get("md") or "").strip()
                   for c in await list_guide_cards(req.topic)):
                grep = await guide_qa.guide_qa_report(req.topic, llm=req.llm)
                if grep:
                    await asyncio.to_thread(guide_qa._write_report, grep)
                    note_guide = grep["note_guide"]
        except Exception:
            logging.getLogger("creator.routes").exception("[%s] Guide-QA im QA-Button fehlgeschlagen", req.topic)
        return {"note": report["note"], "note_artefakte": report["note_artefakte"],
                "note_guide": note_guide}
    finally:
        _qa_laeuft.discard(req.topic)


_repair_laeuft: set[str] = set()


@router.post("/blocks/repair")
async def run_repair_route(req: RepairRequest):
    """Fix the latest QA findings in place: hygiene, confirmed duplicates, foreign/unreal blocks."""
    topic = req.topic.strip()
    if (await blocks_status(topic))["generating"]:
        return {"status": "generating"}
    if topic in _repair_laeuft:
        return {"status": "läuft bereits"}
    _repair_laeuft.add(topic)
    try:
        import repair
        res = await repair.repair_befunde(topic, req.ebene)
        if "fehler" in res:
            raise HTTPException(status_code=404, detail=res["fehler"])
        return res
    finally:
        _repair_laeuft.discard(topic)


@router.post("/blocks/research")
async def add_blocks_research(topic: str, provider: str = DEFAULT_PROVIDER):
    """Attach one more research agent — to the live flow, or attach-or-start."""
    if add_research_agent(topic):
        return {"ok": True, "attached": True}
    if (await blocks_status(topic))["generating"]:
        return {"ok": False, "status": "starting"}  # flow is booting, try again shortly
    asyncio.create_task(generate_blocks(topic, "", provider, research=True))
    return {"ok": True, "attached": False}


@router.post("/blocks/requeue-dead")
async def requeue_blocks_dead(topic: str):
    return {"ok": True, "requeued": await requeue_dead(topic)}


@router.post("/blocks/cancel")
async def cancel_blocks_route(topic: str):
    if not cancel_blocks(topic):
        raise HTTPException(404, "No running generation")
    return {"ok": True}


@router.delete("/blocks")
async def remove_blocks(topic: str):
    reset_blocks(topic)              # Files: crawl + triage + inventory…questions gone; source.json stays
    await delete_topic_pipeline(topic)  # DB: blocks area gone; topic config (source) stays
    await kanban_reset(topic)        # kanban cards + cluster membership gone
    return {"ok": True}


@router.delete("/blocks/artefacts")
async def remove_artefacts(topic: str):
    """Artefakte-Ebene leeren: Board-2-Daten (Subs/Facts/Flashcards) weg, Inventar bleibt.
    Karten gehen zurück auf `generate` — via „Artefakte generieren" (Fortsetzen) neu baubar."""
    topic = topic.strip()
    if (await blocks_status(topic))["generating"]:
        return {"ok": True, "status": "generating"}
    from board_inventory import reset_board_from_stage
    moved = await reset_board_from_stage(topic, "artefacts", "generate", _blocks_files(topic))
    return {"ok": True, "moved": moved}


@router.delete("/blocks/progress")
async def reset_block_progress(topic: str, block: str):
    """Reset learning progress of ONE block to zero (score/streak/flags/open question)."""
    await delete_block_progress(topic, block)
    return {"ok": True}


def _validate_source(type: str, location: str) -> None:
    """Check source input (shared by create + edit)."""
    if type in ("projekt", "uni"):
        folder = safe_folder(location)
        if folder is None or not folder.is_dir():
            raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
    elif type == "link":
        urls = [ln.strip() for ln in location.splitlines() if ln.strip()]
        if not urls:
            raise HTTPException(400, "Enter at least one link.")
        if not all(u.lower().startswith(("http://", "https://")) for u in urls):
            raise HTTPException(400, "Each link must start with http:// or https:// (one per line).")


@router.get("/blocks/source", response_model=BlocksSourceResponse)
async def get_blocks_source(topic: str):
    return load_source(topic)


@router.put("/blocks/source", response_model=BlocksSourceResponse)
async def update_blocks_source(req: BlocksSourceUpdate):
    """Only save — NO regeneration. Overwrite the source/spec choice."""
    topic, type, location = req.topic.strip(), req.type, req.location.strip()
    _validate_source(type, location)
    qp = source_path(topic)
    qp.parent.mkdir(parents=True, exist_ok=True)
    data = {"type": type, "location": location, "spec": req.spec.strip()}
    atomic_write_json(qp, data)
    return data


@router.get("/blocks/completeness")
async def blocks_completeness(topic: str):
    """Beleg der Themen-Zerlegung: Bestand, Filter-Bilanz, Lernziele, Artefakte, Laufzeit."""
    import glob as _glob
    from pathlib import Path as _Path
    from paths import arbeit_dir
    from database import (kanban_stage_counts, list_blocks, list_subblocks, list_lernziele,
                          count_question_pattern_blocks, count_sub_artefakte, event_span)
    counts = await kanban_stage_counts(topic)
    inv = counts.get("inventory", {})
    blocks = await list_blocks(topic, status="consensus")
    # ein Query statt N+1 (pro Block ein list_subblocks) — in Python nach consensus zählen
    consensus_blocks = {b["title_norm"] for b in blocks}
    subs = sum(1 for s in await list_subblocks(topic)
               if s["status"] == "consensus" and s["block_norm"] in consensus_blocks)
    ziele = await list_lernziele(topic)
    dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead")
    degradiert = ueberstimmt = 0
    for p in _glob.glob(str(arbeit_dir(topic) / "inventar-filter*.json")):
        try:
            d = json.loads(_Path(p).read_text(encoding="utf-8"))
            degradiert += d.get("degradiert", 0)
            ueberstimmt += len(d.get("ueberstimmt", []))
        except Exception:
            continue
    status = await blocks_status(topic)
    return {
        "bloecke": len(blocks), "subs": subs,
        "verworfen": inv.get("rejected", 0), "zusammengelegt": inv.get("grouped", 0),
        "degradiert_geprueft": degradiert, "panel_gerettet": ueberstimmt,
        "ziele_total": len(ziele), "ziele_covered": sum(1 for z in ziele if z["covered"]),
        "frage_bloecke": await count_question_pattern_blocks(topic),
        "lernkarten": await count_sub_artefakte(topic),
        "dead": dead, "lauf_minuten": await event_span(topic),
        "vollstaendig": bool(status.get("ready")) and dead == 0,
    }


@router.get("/blocks/overview", response_model=list[BlockOverview])
async def get_blocks_uebersicht(topic: str):
    return await load_overview(topic)


@router.get("/blocks/question-pattern")
async def get_question_pattern(topic: str, block: str):
    """Unlocked question patterns of a block (up to the current level; empty = live)."""
    state = await get_block_progress(topic, block)
    fe = freie_level(state["good_answers"], await subs_per_level(topic, block))
    return {"pattern": await load_question_pattern_free(topic, block, fe)}


# --- Practice deck: Leitner flashcard pool per topic ---

async def build_practice_deck(topic: str) -> dict:
    """ONE stack per topic (spacing beats per-block mini-stacks): due cards first
    (oldest due_at), then up to PRACTICE_NEW_PER_SESSION new ones. Level gate via the
    block's exam score (freie_level) — locked cards are counted for transparency."""
    cards = await get_sub_artefakte(topic, "flashcard")
    levels = await sub_levels_norm(topic)
    n_je = await subs_per_level_norm(topic)
    progress = {_norm_title(p["block"]): p["good_answers"] for p in await list_block_progress(topic)}
    pp = {(p["block_norm"], p["sub_norm"]): p for p in await get_practice_progress(topic)}
    now = datetime.now(timezone.utc).isoformat()
    due, new, future, gesperrt = [], [], [], 0
    for r in cards:
        bn, sn = r["block_norm"], r["sub_norm"]
        n_block = n_je.get(bn)
        if n_block is not None:  # legacy blocks without level data pass unfiltered
            if levels.get((bn, sn), 1) > freie_level(progress.get(bn, 0), n_block):
                gesperrt += 1
                continue
        try:
            data = json.loads(r["data"])
        except (ValueError, TypeError):
            continue
        card = {"block": r["block"], "block_norm": bn, "sub_norm": sn,
                "subblock": r["sub_title"], "question": data.get("question", ""),
                "answer": data.get("answer", "")}
        p = pp.get((bn, sn))
        if p is None:
            card.update(box=None, status="new")
            new.append(card)
        elif p["due_at"] <= now:
            card.update(box=p["box"], status="due", due_at=p["due_at"])
            due.append(card)
        else:
            future.append(p["due_at"])
    due.sort(key=lambda c: c["due_at"])
    new_total = len(new)
    new = new[:PRACTICE_NEW_PER_SESSION]
    return {"cards": due + new,
            "counts": {"due": len(due), "new": len(new), "new_total": new_total,
                       "gesperrt": gesperrt},
            "next_due_at": min(future) if future else None}


@router.get("/practice/deck")
async def practice_deck(topic: str):
    return await build_practice_deck(topic)


@router.post("/practice/answer")
async def practice_answer(req: PracticeAnswerRequest):
    """Book a Leitner step. Deliberately NO existence check against sub_artefakte:
    an answer during regeneration books instead of failing (worst case an orphan row)."""
    pp = {(p["block_norm"], p["sub_norm"]): p for p in await get_practice_progress(req.topic)}
    prev = pp.get((req.block_norm, req.sub_norm))
    box, days = leitner_step(prev["box"] if prev else None, req.correct)
    due_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
    await upsert_practice_progress(req.topic, req.block_norm, req.sub_norm, box, due_at)
    return {"box": box, "due_at": due_at}


# --- Block learning: chat, exam ---

@router.get("/blocks/learnstate", response_model=BlockLearnStateResponse)
async def block_learnstate(topic: str):
    """Exam state per block (raw title as key). cap_final = all subs × 25;
    cap_aktuell + freie_level from the score — for ALL blocks (even unexamined)."""
    progress = {p["block"]: p for p in await list_block_progress(topic)}
    levels = await subs_per_level_raw(topic)

    def _state(score: int, streak: int, n_je_level: dict[int, int]) -> dict:
        return {
            "good_answers": score, "streak": streak,
            "cap": cap_final(n_je_level),
            "cap_aktuell": cap_aktuell(score, n_je_level),
            "freie_level": freie_level(score, n_je_level),
        }

    blocks = {
        b: _state(progress[b]["good_answers"] if b in progress else 0,
                  progress[b]["streak"] if b in progress else 0, n)
        for b, n in levels.items()
    }
    # Legacy blocks with an exam but without subs → empty levels (cap 0).
    for b, p in progress.items():
        if b not in blocks:
            blocks[b] = _state(p["good_answers"], p["streak"], {})
    return {"blocks": blocks}


@router.post("/blocks/chat", response_model=BlockChatResponse)
async def block_chat_route(req: BlockChatRequest):
    reply = await block_chat(
        req.topic, req.block, req.section, req.section_compact,
        [m.model_dump() for m in req.messages], provider=req.provider,
    )
    return {"reply": reply}


# Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
# overwrite the absolute score with a stale base (race).
_check_locks: dict[tuple[str, str], tuple[asyncio.Lock, list]] = {}


@asynccontextmanager
async def _check_lock(topic: str, block: str):
    """Per-(topic,block)-Lock mit Refcount, das den Eintrag nach dem letzten Nutzer
    entfernt — die Map wuchs sonst unbegrenzt (ein Lock pro je geprüftem Block)."""
    key = (topic, block)
    entry = _check_locks.get(key)
    if entry is None:
        entry = _check_locks[key] = (asyncio.Lock(), [0])
    lock, ref = entry
    ref[0] += 1
    try:
        async with lock:
            yield
    finally:
        ref[0] -= 1
        if ref[0] == 0 and _check_locks.get(key) is entry:
            del _check_locks[key]


def _basis(state: dict, question: str) -> tuple[int, bool]:
    """Score base BEFORE the question. Same open question → re-rating on the same base
    (idempotent); otherwise a new question on the current state. → (basis, re_rating)."""
    re_rating = state["offene_question"] == question and state["offene_basis"] is not None
    return (state["offene_basis"] if re_rating else state["good_answers"]), re_rating


def _color(points: int) -> str:
    """Points delta → rough bubble coloring."""
    return "gut" if points > 0 else ("neutral" if points == 0 else "schlecht")


async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int]) -> dict:
    """Book score+streak drift-free (lock + open-question/open-streak anchor). Tier →
    points delta (streak-modulated) or progressive malus on error. cap_aktuell is derived
    from the base (delayed unlock at the level threshold).
    Re-rating of the same question uses the open streak anchor → idempotent."""
    async with _check_lock(req.topic, req.block):
        state = await get_block_progress(req.topic, req.block)
        basis, re_rating = _basis(state, question)
        streak_basis = state["offene_streak"] if re_rating else state["streak"]
        if not re_rating:
            await set_open_question(req.topic, req.block, question, basis, state["streak"])
        s = thresholds(n_je_level)
        cf = s[-1]
        ca = cap_aktuell(basis, n_je_level)
        floor = floor_from_score(basis, cf, s)
        d, new_streak = points_delta(tier, streak_basis, basis, ca)
        score = compute_score(basis, d, floor, ca, cf)
        points = score - basis
        good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak)
        return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf}


@router.post("/blocks/exam", response_model=BlockExamResponse)
async def block_exam_route(req: BlockExamRequest):
    state = await get_block_progress(req.topic, req.block)
    good = state["good_answers"]
    n_je_level = await subs_per_level(req.topic, req.block)
    cap = cap_final(n_je_level)
    tier = level_from_score(good, cap) or "beginner"  # addressee role of the question
    fe = freie_level(good, n_je_level)                  # only check unlocked subs
    compact = req.section_compact
    msgs = [m.model_dump() for m in req.messages]

    if req.action == "question":
        if req.pattern.strip():
            # From a drawn pattern, phrase a concrete question at the tier (no dedup needed).
            question = await exam_question_variant(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider)
        else:
            # Fallback (no pattern sidecar): live generation, focus only on unlocked subs.
            subs = await subblocks_frei(req.topic, req.block, fe)
            question = await exam_question(req.topic, req.block, req.section, compact, msgs, subblocks=subs, avoid=req.avoid, tier=tier, provider=req.provider)
        if question is None:
            raise HTTPException(502, "Question failed — please try again")
        return {"question": question, "good_answers": good, "cap": cap}

    if req.action == "discussion":
        if not req.question.strip():
            raise HTTPException(400, "Discussion needs an active question")
        reply = await block_discussion(
            req.topic, req.block, req.section, compact,
            req.question, req.last_rating or None, msgs, provider=req.provider,
        )
        if reply is None:
            raise HTTPException(502, "Discussion failed — please try again")
        return {"reply": reply, "good_answers": good, "cap": cap}

    # --- Quiz: easy (1 of 4) +1/−1 · hard (x of 4) +3/−1 — deterministic ---
    if req.action == "quiz_question":
        if not req.pattern.strip():
            raise HTTPException(400, "Quiz needs a pattern")
        distractors = await hurdles_distractor_block(req.topic, req.block)
        quiz = await generate_quiz(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider, distractor_block=distractors)
        if quiz is None:
            raise HTTPException(502, "Quiz question failed — please try again")
        return {"question": quiz["question"], "options": quiz["options"],
                "good_answers": good, "cap": cap}

    if req.action == "quiz_answer":
        if not req.question.strip():
            raise HTTPException(400, "Quiz answer needs a question")
        hit = set(req.selection) == set(req.correct)  # exactly the correct set
        res = await _book_score(req, req.question, "strong" if hit else "barely", n_je_level)
        res["feedback"] = "Correct — all correct ones hit." if hit else "Not quite — the marked ones were correct."
        return res

    # --- Gap text: easy (term from 4) +1/−1 · hard (free typing) +3/−1 ---
    if req.action == "gap_question":
        if not req.pattern.strip():
            raise HTTPException(400, "Gap text needs a pattern")
        if req.schwer:
            lt = await generate_gaptext(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider)
            if lt is None:
                raise HTTPException(502, "Gap text failed — please try again")
            return {"sentence": lt["sentence"], "solution": lt["solution"], "alternatives": lt["alternatives"],
                    "good_answers": good, "cap": cap}
        distractors = await hurdles_distractor_block(req.topic, req.block)
        lw = await generate_gapchoice(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider, distractor_block=distractors)
        if lw is None:
            raise HTTPException(502, "Gap text failed — please try again")
        return {"sentence": lw["sentence"], "options": lw["options"],
                "good_answers": good, "cap": cap}

    if req.action == "gap_answer":
        if not req.question.strip():
            raise HTTPException(400, "Gap-text answer needs a sentence")
        if req.schwer:  # free typed → synonym-tolerant AI check
            ok = await check_gaptext(req.topic, req.block, req.question, req.solution, req.alternatives, req.input, provider=req.provider)
            feedback = "Correct!" if ok else f"Not quite — expected „{req.solution}“."
        else:  # term chosen from 4 → deterministic
            ok = set(req.selection) == set(req.correct)
            feedback = "Correct!" if ok else "Not quite — the marked term was correct."
        res = await _book_score(req, req.question, "strong" if ok else "barely", n_je_level)
        res["feedback"] = feedback
        return res

    # action "answer" (Agent 1 fast) / "answer_check" (Agent 2 thorough).
    if not any(m.get("role") == "user" for m in msgs):
        raise HTTPException(400, "Answer needs a user answer")
    if not req.question.strip():
        raise HTTPException(400, "Answer needs an active question")

    if req.action == "answer":
        # Agent 1: preview only — tier + expected points, persist NOTHING, no anchor.
        data = await exam_rating_fast(
            req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider,
        )
        if data is None:
            raise HTTPException(502, "Rating failed — please try again")
        basis, re_rating = _basis(state, req.question)
        streak_basis = state["offene_streak"] if re_rating else state["streak"]
        s = thresholds(n_je_level)
        ca = cap_aktuell(basis, n_je_level)
        floor = floor_from_score(basis, s[-1], s)
        tier = cap_followup(data["tier"], req.asked_again)
        d, _ = points_delta(tier, streak_basis, basis, ca)
        score = compute_score(basis, d, floor, ca, s[-1])
        points = score - basis
        return {"feedback": data["feedback"], "points": points, "rating": _color(points),
                "good_answers": good, "cap": cap}

    # action "answer_check" (Agent 2 thorough): binding, persisted. ONLY here does the score change.
    # The LLM runs WITHOUT a lock; booking is done briefly via _book_score (anchor + score), as with quiz/gap.
    # This way the long AI rating doesn't block a following (deterministic) answer of the same block.
    data = await exam_rating(
        req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider,
        role="guide" if req.thorough else "judge", reason=req.reason,
    )
    if data is None:
        raise HTTPException(502, "Rating failed — please try again")
    tier = cap_followup(data["tier"], req.asked_again)
    res = await _book_score(req, req.question, tier, n_je_level)  # short lock: drift-free base via anchor
    res["feedback"] = data["feedback"]
    return res


# --- Guides ---

@router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest):
    guides, levels = await load_learnstate()
    reason = guide_lock(req.topic.strip(), req.format, guides, levels)
    if reason:
        raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason)  # string matches rules.py contract
    await create_topic(req.topic.strip())
    now = datetime.now(timezone.utc).isoformat()
    guide = {
        "id": str(uuid.uuid4()),
        "topic": req.topic.strip(),
        "format": req.format,
        "instructions": req.instructions.strip(),
        "status": "queued",
        "progress": None,
        "created_at": now,
        "updated_at": now,
    }
    await create_guide(guide)
    asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider))
    return guide


@router.get("/guides", response_model=list[GuideResponse])
async def list_all():
    return await list_guides()


@router.get("/guides/board")
async def get_guide_board(topic: str, format: str = "Guide"):
    """Live guide board: columns with counts + cards (rounds, covered objectives), agents."""
    import guide_board
    snap = await guide_board.board_snapshot(topic, format)
    guide = next((g for g in await list_guides()
                  if g["topic"] == topic and g["format"] == format), None)
    snap["generating"] = bool(guide and guide["status"] in ("queued", "generating"))
    snap["guide_id"] = guide["id"] if guide else None
    snap["progress"] = guide.get("progress") if guide else None
    snap["error"] = guide.get("error_msg") if guide else None
    prefix = f"{guide['id']}-" if guide else "-"
    snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(prefix), "runtime": a["runtime"]}
                      for a in active_agents(prefix)]
    return snap


@router.post("/guides/board/repair")
async def repair_guide_board(req: GuideFormatRequest):
    """Befunde beheben fürs Guide-Board: Karten mit QA-Befunden zurück auf `pruefer`,
    dann resumt generate_guide die offenen Karten (Prüfer+Fix) und misst neu."""
    import guide_board
    topic = req.topic.strip()
    guide = next((g for g in await list_guides()
                  if g["topic"] == topic and g["format"] == req.format), None)
    if guide is None:
        raise HTTPException(404, "Kein Guide für dieses Topic/Format")
    if guide["status"] in ("queued", "generating"):
        return {"ok": True, "status": "generating", "betroffen": []}
    betroffen = await guide_board.repair_karten(topic, req.format)
    if betroffen:
        now = datetime.now(timezone.utc).isoformat()
        await update_guide(guide["id"], status="queued", progress="Repair…", updated_at=now)
        asyncio.create_task(generate_guide(guide["id"], topic, req.format,
                                           guide.get("instructions") or "", DEFAULT_PROVIDER))
    return {"ok": True, "betroffen": betroffen}


@router.get("/guides/{guide_id}/content")
async def guide_content(guide_id: str, level: int = 4):
    """Guide content. `level` (1=A · 2=F · 3=E · 4=V) filters to subblocks up to this
    level; 4 = full version (raw, unchanged)."""
    guide = await get_guide(guide_id)
    if guide is None:
        raise HTTPException(404, "Guide not found")
    if guide["status"] != "done":
        raise HTTPException(404, "Content not available")
    stored = await get_guide_content(guide["topic"], guide["format"])  # DB-first
    if stored is None:
        path = guide_content_path(guide["topic"], guide["format"])  # fallback: file (legacy topics)
        if not path.exists():
            raise HTTPException(404, "File not found")
        stored = path.read_text(encoding="utf-8")
    if level >= 4:
        return Response(content=stored, media_type="application/json")  # full version, raw
    try:
        return content_fuer_level(json.loads(stored), level)
    except ValueError:
        return Response(content=stored, media_type="application/json")


@router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse)
async def guide_chat(guide_id: str, req: GuideChatRequest):
    guide = await get_guide(guide_id)
    if guide is None:
        raise HTTPException(404, "Guide not found")
    reply = await chat_with_guide(
        guide["topic"], guide["format"], req.section, req.outline,
        [m.model_dump() for m in req.messages],
        provider=req.provider,
    )
    return {"reply": reply}


async def _guide_tf(guide_id: str) -> tuple[str, str]:
    guide = await get_guide(guide_id)
    if guide is None:
        raise HTTPException(404, "Guide not found")
    return guide["topic"], guide["format"]


@router.post("/guides/{guide_id}/block/pruefen", response_model=BlockPruefenResponse)
async def block_pruefen_route(guide_id: str, req: BlockPruefenRequest):
    topic, fmt = await _guide_tf(guide_id)
    new = await block_pruefen(topic, fmt, req.block, req.spot, req.snippet, req.hint, provider=req.provider)
    if new is None:
        raise HTTPException(502, "Check failed — please try again")
    return {"revised": new}


@router.post("/guides/{guide_id}/block/uebernehmen", response_model=BlockUebernehmenResponse)
async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest):
    topic, fmt = await _guide_tf(guide_id)
    res = await block_adopt(topic, fmt, req.block, req.spot, req.alt, req.revised)
    if res is None:
        raise HTTPException(404, "Section not found")
    return res


@router.post("/guides/{guide_id}/cancel")
async def cancel(guide_id: str):
    cancelled = await cancel_guide(guide_id)
    if not cancelled:
        raise HTTPException(404, "No active process found")
    return {"ok": True}


@router.post("/guides/board/remove")
async def remove_guide_format(req: GuideFormatRequest):
    """Board-Remove: discard ALL runs of topic+format — old error rows pile up, and the
    per-guide delete keeps the board cards until the LAST row is gone (measured: 8 rows)."""
    doomed = [g for g in await list_guides() if g["topic"] == req.topic and g["format"] == req.format]
    if any(g["status"] in ("queued", "generating") for g in doomed):
        return {"ok": True, "status": "generating"}
    for g in doomed:
        await delete_guide(g["id"])
    await delete_guide_content(req.topic, req.format)
    await delete_guide_board(req.topic, req.format)
    content = guide_content_path(req.topic, req.format)
    for p in guide_slot_files(content):
        p.unlink(missing_ok=True)
    content.unlink(missing_ok=True)
    import qa  # Guide-QA-Report mitlöschen — sonst zeigt board_snapshot die alte Note weiter
    for p in qa.report_paths(req.topic, guide=True):
        p.unlink(missing_ok=True)
    return {"ok": True, "removed": len(doomed)}


@router.delete("/guides/{guide_id}")
async def remove(guide_id: str, slots: bool = False):
    guide = await get_guide(guide_id)
    if guide is None:
        raise HTTPException(404, "Guide not found")
    await delete_guide(guide_id)
    # Content/step files are shared by all runs of a topic+format — only delete them
    # once no entry needs them anymore. Partial progress (step files without finished
    # content) is kept for resume, unless explicitly requested (slots=1).
    rest = [g for g in await list_guides() if g["topic"] == guide["topic"] and g["format"] == guide["format"]]
    if not rest:
        await delete_guide_content(guide["topic"], guide["format"])
        await delete_guide_board(guide["topic"], guide["format"])  # board cards + lernziele
        content = guide_content_path(guide["topic"], guide["format"])
        if slots or content.exists():
            for p in guide_slot_files(content):
                p.unlink(missing_ok=True)
            content.unlink(missing_ok=True)
    return {"ok": True}
