"""Block learning: deep-dive, block chat and exam for individual guide sections.

All calls are interactive (stdout response, lane "interactive") and stateless —
the chat/exam history comes from the frontend; only the exam counter (DB) and
the deep-dive (DB) are persisted.
"""

import logging
import random
import re
import uuid
from datetime import datetime, timezone

from agents import run_agent
from config import DEFAULT_PROVIDER
from database import get_block_hurdles
from jsonio import parse_json_text as _parse_json_text
from pipeline import _prompt, _problems_schema
from textkit import _norm_title

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

# Learning levels per block — relative to the cap (floor as % of the max score):
# green=beginner 20% · blue=advanced 40% · purple=expert 60% · gold=master 100%.
# Exam form is always random (5 forms); the cap scales with the amount of material.
LEVELS = (("beginner", 0.2), ("advanced", 0.4), ("expert", 0.6), ("master", 1.0))


POINTS_BASE = 25  # Points per subblock. Master cap = (all subs) × 25.

# Leitner boxes for the flashcard practice deck: roughly doubling intervals cover
# session → day → week → month. Box 1 with interval 0 = a failed card stays due in
# the running session. Absolute UTC times, no day-boundary semantics (timezone-free).
LEITNER_INTERVALS = {1: 0, 2: 1, 3: 3, 4: 7, 5: 21}  # days per box
LEITNER_MAX_BOX = 5
PRACTICE_NEW_PER_SESSION = 10  # new cards offered per deck fetch


def leitner_step(box: int | None, correct: bool) -> tuple[int, int]:
    """(new box, interval in days). New card + correct → box 2; wrong → box 1 (due now);
    correct → one box up, capped at LEITNER_MAX_BOX."""
    if not correct:
        new = 1
    elif box is None:
        new = 2
    else:
        new = min(box + 1, LEITNER_MAX_BOX)
    return new, LEITNER_INTERVALS[new]


def _levels(n_je_level: dict[int, int]) -> list[int]:
    return [n_je_level.get(k, 0) for k in (1, 2, 3, 4)]


def thresholds(n_je_level: dict[int, int]) -> list[int]:
    """Cumulative sub-level thresholds [S_1, S_2, S_3, S_4] = (n_1+…+n_k) × 25.
    S_k is the score at which sub-level k+1 unlocks; S_4 = cap_final."""
    out, acc = [], 0
    for n in _levels(n_je_level):
        acc += n
        out.append(acc * POINTS_BASE)
    return out


def cap_final(n_je_level: dict[int, int]) -> int:
    """Max score (master) = all subblocks × 25."""
    return thresholds(n_je_level)[-1]


def freie_level(score: int, n_je_level: dict[int, int]) -> int:
    """Highest unlocked sub-level 1–4. Level k+1 unlocks once score ≥ S_k.
    Empty levels (n_k=0) are skipped automatically (S_k == S_{k-1})."""
    s = thresholds(n_je_level)
    e = 1
    for k in range(3):  # S_1..S_3 unlock levels 2..4
        if score >= s[k]:
            e = k + 2
    return e


def cap_aktuell(score: int, n_je_level: dict[int, int]) -> int:
    """Reachable cap of the currently unlocked level = unlocked subs × 25."""
    return thresholds(n_je_level)[freie_level(score, n_je_level) - 1]


def _threshold(p: float, cap: int) -> int:
    return round(p * cap)


def level_from_score(score: int, cap_final_value: int) -> str | None:
    """Highest reached learning level (None below 20%), relative to cap_final."""
    reached = None
    for key, p in LEVELS:
        if score >= _threshold(p, cap_final_value):
            reached = key
    return reached


def progressive_malus(basis: int, cap_akt: int) -> int:
    """Error penalty by progress within the current level (against cap_aktuell):
    ≤25%→−5 · ≤50%→−10 · ≤75%→−15 · >75%→−20."""
    pct = (basis / cap_akt) if cap_akt else 0.0
    if pct <= 0.25:
        return -5
    if pct <= 0.5:
        return -10
    if pct <= 0.75:
        return -15
    return -20
CHAT_TIMEOUT = 240
EXAM_TIMEOUT = 120  # short JSON turns; caps the serial latency per exam step
THOROUGH_TIMEOUT = 600  # "thorough check": strong model (role guide) takes longer
CRITIC_MAX_ROUNDS = 2   # Generator → Critic → maybe Regenerate, at most this many times

# Question types for active recall — one per question, chosen at random. Creates variety.
QUESTION_TYPES = {
    "abruf": "Free Recall: have the learner explain the core idea freely from memory (open comprehension question).",
    "punkt": "Cued Recall: ask for ONE specific detail or distinction.",
    "warum": "Why-question: ask for the reason/mechanism — why does this work or hold?",
    "anwendung": "Application: have the concept applied to ONE short, new example/scenario.",
    "pruefen": "For code/tool topics: show a small snippet — predict the output OR find the bug. No code topic → an application question instead.",
}


# Answer tier → base points (new 25-scale). "barely" = −1 is only the signal for the
# progressive malus (the real value comes from progressive_malus). Positive values are
# modulated up on a streak and clamped to [10, 40].
TIERS = {
    "unanswerable": 0,  # question itself broken → no change
    "barely": -1,           # < 25% correct → malus
    "partial": 0,       # 25–49% → neutral
    "solid": 16,         # 50–74%
    "strong": 24,          # 75–99% (quiz/gap hit)
    "complete": 30,       # 100% (only reachable by free explanation)
}

# Order weak→strong (for the follow-up cap).
_TIER_RANK = ("barely", "partial", "solid", "strong", "complete")


def cap_followup(tier: str, asked_again: bool) -> str:
    """With a follow-up (hint received) at most "solid" — no full score by cheating."""
    if asked_again and tier in ("strong", "complete"):
        return "solid"
    return tier


def streak_points(basis_delta: int, streak_basis: int) -> int:
    """Modulate a positive base delta up by streak, clamped to [10, 40]."""
    factor = min(1.33, 1 + 0.066 * min(streak_basis, 5))
    return max(10, min(40, round(basis_delta * factor)))


def points_delta(tier: str, streak_basis: int, basis: int, cap_akt: int) -> tuple[int, int]:
    """Answer tier → (points delta, new streak). Positive: streak-modulated, streak +1.
    Neutral (0): no change, streak stays. Negative: progressive malus, streak reset to 0."""
    basis_delta = TIERS.get(tier, 0)
    if basis_delta > 0:
        return streak_points(basis_delta, streak_basis), streak_basis + 1
    if basis_delta == 0:
        return 0, streak_basis
    return progressive_malus(basis, cap_akt), 0


def compute_score(basis: int, delta: int, floor: int, cap_akt: int, cap_fin: int) -> int:
    """New score · drift-free from the base. Clamps up against `cap_akt` (cap of the
    currently unlocked level) and down against `floor`. Frozen ONLY at the absolute
    maximum (`basis ≥ cap_fin`) — otherwise it would block at every level threshold."""
    if basis >= cap_fin:
        return basis
    return max(floor, min(cap_akt, basis + delta))


def floor_from_score(basis: int, cap_fin: int, s_thresholds: list[int]) -> int:
    """Lower bound (no fallback): highest reached learning-level threshold (over cap_final)
    AND highest reached level-unlock threshold S_k. max of both axes."""
    floor = 0
    for _, p in LEVELS:
        s = _threshold(p, cap_fin)
        if basis >= s:
            floor = max(floor, s)
    for s in s_thresholds:
        if basis >= s:
            floor = max(floor, s)
    return floor


def _transcript(messages: list[dict]) -> str:
    return "\n".join(
        f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
        for m in messages
    ) or "(empty)"


async def block_chat(topic: str, block: str, section: str, compact: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
    try:
        prompt = _prompt(
            "Block-Chat",
            topic=topic, block=block,
            section_block=section.strip() or "(no guide version provided)",
            compact_block=(compact or "").strip() or "(none)",
            transcript=_transcript(messages),
        )
        returncode, stdout, _ = await run_agent(
            "blockchat-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
            provider=provider, role="fast", capabilities="none", lane="interactive",
        )
        if returncode != 0:
            return "Sorry, that didn't work. Please try again."
        reply = stdout.strip()
        return reply or "Sorry, I didn't get a response."
    except Exception:
        log.warning("[%s] Block chat failed (%s)", topic, block, exc_info=True)
        return "Sorry, that didn't work. Please try again."


def _question_schema(data) -> dict | None:
    """{"question": str} · else None."""
    if not isinstance(data, dict):
        return None
    question = str(data.get("question", "")).strip()
    return {"question": question} if question else None


def _rating_schema(data) -> dict | None:
    """{"feedback": str, "tier": ∈ TIERS} · else None."""
    if not isinstance(data, dict):
        return None
    feedback = str(data.get("feedback", "")).strip()
    tier = data.get("tier")
    if not feedback or tier not in TIERS:
        return None
    return {"feedback": feedback, "tier": tier}


async def _gen_call(name: str, role: str, schema, provider: str, timeout: int = EXAM_TIMEOUT, lane: str = "interactive", **kwargs) -> dict | None:
    """Generator agent: fill the template, run it, parse via schema · None on error.
    lane="batch" for background (preloading, thorough rating) → its own slot queue."""
    returncode, stdout, _ = await run_agent(
        name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout,
        provider=provider, role=role, capabilities="none", lane=lane,
    )
    return schema(_parse_json_text(stdout)) if returncode == 0 else None


async def _critique_call(name: str, provider: str, role: str = "judge", timeout: int = EXAM_TIMEOUT, lane: str = "interactive", **kwargs) -> list[str]:
    """Critic agent (default role judge): empty list = fine. Fail-open: a critic failure
    must not block the turn, so it returns an empty list then as well."""
    returncode, stdout, _ = await run_agent(
        name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout,
        provider=provider, role=role, capabilities="none", lane=lane,
    )
    if returncode != 0:
        return []
    return _problems_schema(_parse_json_text(stdout)) or []


def _critique_block(prev_version: str, problems: list[str]) -> str:
    points = "\n".join(f"- {p}" for p in problems)
    return (
        f"Your previous version was:\n«{prev_version}»\n\n"
        f"The examiner objects:\n{points}\n\nFix these points."
    )


def _rating_text(rating: dict) -> str:
    return f"Tier: {rating['tier']}\nFeedback: {rating['feedback']}"


# Deterministic guard against double questions — the AI critic misses "…, and which…".
_QUESTION_WORD = r"(was|welche[rsnm]?|wie|wieso|warum|wofür|wozu|wann|wo|wer|wem|wen|nenne)"
_DOUBLE_RE = re.compile(r"[,;]?\s+(und|sowie|außerdem|bzw\.?)\s+" + _QUESTION_WORD + r"\b", re.IGNORECASE)


def _double_question_flaw(question: str) -> str | None:
    """Detects two chained questions. None = ok. Flags ONLY 'und/sowie' + question word."""
    if question.count("?") > 1:
        return "More than one question mark — ask EXACTLY ONE question."
    if _DOUBLE_RE.search(question):
        return "Two questions chained with 'und'/'sowie' — ask EXACTLY ONE question, one thing."
    return None


async def _question_with_critique(
    topic: str, block: str, section_block: str, compact_block: str,
    transcript: str, avoid_block: str, type_block: str, fokus_block: str,
    tier_block: str, provider: str,
) -> str | None:
    """Generate a question, have the critic check it, regenerate on flaws (max CRITIC_MAX_ROUNDS)."""
    kritik_block = "(none)"
    question = None
    for _ in range(CRITIC_MAX_ROUNDS):
        data = await _gen_call(
            "Block-Question", "guide", _question_schema, provider, lane="batch",
            topic=topic, block=block, section_block=section_block,
            compact_block=compact_block, transcript=transcript, avoid_block=avoid_block,
            type_block=type_block, fokus_block=fokus_block, tier_block=tier_block, kritik_block=kritik_block,
        )
        if data is None:
            return None
        question = data["question"]
        problems = await _critique_call(
            "Block-Question-Critique", provider, role="guide", lane="batch",  # strong AI checks the rules
            topic=topic, block=block, section_block=section_block,
            compact_block=compact_block, transcript=transcript, avoid_block=avoid_block,
            type_block=type_block, fokus_block=fokus_block, question=question,
        )
        hard = _double_question_flaw(question)  # forces regeneration even if the AI critic missed it
        if hard:
            problems = [hard, *(problems or [])]
        if not problems:
            return question
        kritik_block = _critique_block(question, problems)
    return question  # best-effort after the last round


async def _rating_with_critique(
    topic: str, block: str, section_block: str, compact_block: str,
    question: str, transcript: str, reason_block: str, provider: str, role: str = "judge",
) -> dict | None:
    """Rate an answer (tier), have the critic check it, redo on misjudgment.

    `question` anchors the checked question; the dialog (transcript) provides answer + discussion.
    `reason_block` = optional learner dissatisfaction (only for "thorough check").
    `role` = "judge" (fast) or "guide" (thorough, strong model with thinking).
    """
    timeout = THOROUGH_TIMEOUT if role == "guide" else EXAM_TIMEOUT
    # Thorough (role guide) = user is waiting → interactive. Background-thorough (judge) → batch.
    lane = "interactive" if role == "guide" else "batch"
    kritik_block = "(none)"
    rating = None
    for _ in range(CRITIC_MAX_ROUNDS):
        rating = await _gen_call(
            "Block-Rating", role, _rating_schema, provider, timeout, lane=lane,
            topic=topic, block=block, section_block=section_block,
            compact_block=compact_block, question=question, transcript=transcript,
            reason_block=reason_block, kritik_block=kritik_block,
        )
        if rating is None:
            return None
        problems = await _critique_call(
            "Block-Rating-Critique", provider, role=role, timeout=timeout, lane=lane,
            topic=topic, block=block, section_block=section_block,
            compact_block=compact_block, question=question, transcript=transcript,
            rating_block=_rating_text(rating),
        )
        if not problems:
            return rating
        kritik_block = _critique_block(_rating_text(rating), problems)
    return rating  # best-effort after the last round


def _section_blocks(section: str, compact: str | None) -> tuple[str, str]:
    return (
        section.strip() or "(no guide version provided)",
        (compact or "").strip() or "(none)",
    )


def _avoid_block(avoid: list[str] | None) -> str:
    entries = [f.strip() for f in (avoid or []) if f and f.strip()]
    return "\n".join(f"- {f}" for f in entries) or "(none)"


# Learner tier (derived from the score) → addressee role for the question. This is how the
# difficulty arises: not "make it extra hard", but "ask questions for a beginner/expert".
# Per level: addressee role + cognitive demand (Bloom) + "ask like this" cue. Without explicit levels
# the model takes the easy path (mere recall) — the cues lift higher tiers to apply/analyze/transfer.
TIER_ROLE = {
    "beginner": "The learner is a BEGINNER. Cognitive: REMEMBER/UNDERSTAND. Ask about the basic understanding — the core concept, simple and direct.",
    "advanced": "The learner is ADVANCED. Cognitive: APPLY. Pose a small concrete situation and have the concept applied to it — don't just ask for the definition.",
    "expert": "The learner is an EXPERT. Cognitive: ANALYZE. Have them distinguish/compare, classify a special case or uncover a typical pitfall (hurdle) — don't quiz textbook knowledge.",
    "master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have the concept transferred to a NEW problem, justify a decision or weigh a trade-off.",
}


def _tier_block(tier: str | None) -> str:
    return TIER_ROLE.get(tier or "", TIER_ROLE["beginner"])


async def exam_question(
    topic: str, block: str, section: str, compact: str | None,
    messages: list[dict], subblocks: list[str] | None = None,
    avoid: list[str] | None = None, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
) -> str | None:
    """Action 'question': generate a question — random type for a random subblock,
    in the addressee role of the tier, then critic (sequential) · None on error."""
    try:
        section_block, compact_block = _section_blocks(section, compact)
        transcript = _transcript(messages) if messages else "(empty)"
        type_block = QUESTION_TYPES[random.choice(list(QUESTION_TYPES))]
        subs = [s for s in (subblocks or []) if s and s.strip()]
        focus = random.choice(subs) if subs else ""
        fokus_block = (
            f"Focus the question on this subblock: „{focus}\"" if focus
            else "(whole block — no specific subblock)"
        )
        return await _question_with_critique(
            topic, block, section_block, compact_block, transcript,
            _avoid_block(avoid), type_block, fokus_block, _tier_block(tier), provider,
        )
    except Exception:
        log.warning("[%s] Question failed (%s)", topic, block, exc_info=True)
        return None


async def exam_question_variant(
    topic: str, block: str, section: str, compact: str | None,
    pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
) -> str | None:
    """Action 'question' with a pattern: from a predefined pattern, phrase a concrete question in
    the addressee role of the tier. No critic (the pattern is build-checked).
    The style guard stays as a cheap protection against double questions · None on error."""
    try:
        section_block, compact_block = _section_blocks(section, compact)
        data = await _gen_call(
            "Block-Question-Variante", "guide", _question_schema, provider, lane="batch",
            topic=topic, block=block, section_block=section_block,
            compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
        )
        if data is None:
            return None
        return data["question"]
    except Exception:
        log.warning("[%s] Question variant failed (%s)", topic, block, exc_info=True)
        return None


def _options_schema(opts) -> list[dict] | None:
    """[{text, correct}]×4 → validated list · else None."""
    if not isinstance(opts, list) or len(opts) != 4:
        return None
    out = []
    for o in opts:
        if not isinstance(o, dict):
            return None
        text = str(o.get("text", "")).strip()
        correct = o.get("correct")
        if not text or not isinstance(correct, bool):
            return None
        out.append({"text": text, "correct": correct})
    return out


def _quiz_schema(data) -> dict | None:
    """{"question": str, "options": [{text, correct}]×4} → validated · else None.
    Single choice: exactly 1 correct. The difficulty is in the tier, not in the count."""
    if not isinstance(data, dict):
        return None
    question = str(data.get("question", "")).strip()
    out = _options_schema(data.get("options"))
    if not question or out is None:
        return None
    if sum(o["correct"] for o in out) != 1:
        return None
    return {"question": question, "options": out}


def _gapchoice_schema(data) -> dict | None:
    """{"sentence": str (with ___), "options": [{text, correct}]×4} → exactly 1 correct · else None."""
    if not isinstance(data, dict):
        return None
    sentence = str(data.get("sentence", "")).strip()
    out = _options_schema(data.get("options"))
    if not sentence or "___" not in sentence or out is None or sum(o["correct"] for o in out) != 1:
        return None
    return {"sentence": sentence, "options": out}


async def hurdles_distractor_block(topic: str, block: str) -> str:
    """Typical misconceptions (facts hurdles) of the block as a distractor source for quiz/gap choice.
    Empty if none exist (legacy) → the prompt placeholder disappears without a trace."""
    try:
        hurdles = await get_block_hurdles(topic, _norm_title(block))
    except Exception:
        return ""
    if not hurdles:
        return ""
    lines = "\n".join(f"- {h}" for h in hurdles[:8])
    return ("TYPICAL MISCONCEPTIONS for this block (use them as distractors when they fit the question):\n"
            + lines + "\n")


async def generate_quiz(
    topic: str, block: str, section: str, compact: str | None,
    pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
    distractor_block: str = "",
) -> dict | None:
    """From a pattern, a single-choice question (exactly 1 correct), at the tier's level.
    Strong model (role guide) for correct flags. → {question, options} · None on error.
    distractor_block: optional typical misconceptions (from the facts hurdles) as a distractor source."""
    try:
        section_block, compact_block = _section_blocks(section, compact)
        return await _gen_call(
            "Block-Quiz", "guide", _quiz_schema, provider, lane="batch",
            topic=topic, block=block, section_block=section_block,
            compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
            distractor_block=distractor_block,
        )
    except Exception:
        log.warning("[%s] Quiz question failed (%s)", topic, block, exc_info=True)
        return None


async def generate_gapchoice(
    topic: str, block: str, section: str, compact: str | None,
    pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
    distractor_block: str = "",
) -> dict | None:
    """Gap text with choices: sentence with ___ + 4 terms, exactly 1 correct — at the tier's level.
    → {sentence, options:[{text,correct}]} · None on error.
    distractor_block: optional typical misconceptions (from the facts hurdles) as a distractor source."""
    try:
        section_block, compact_block = _section_blocks(section, compact)
        return await _gen_call(
            "Block-Gapchoice", "guide", _gapchoice_schema, provider, lane="batch",
            topic=topic, block=block, section_block=section_block,
            compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
            distractor_block=distractor_block,
        )
    except Exception:
        log.warning("[%s] Gap-text choice failed (%s)", topic, block, exc_info=True)
        return None


def _gap_schema(data) -> dict | None:
    """{"sentence": str (with ___), "solution": str, "alternatives": [str]} → validated · else None."""
    if not isinstance(data, dict):
        return None
    sentence = str(data.get("sentence", "")).strip()
    solution = str(data.get("solution", "")).strip()
    alt = data.get("alternatives", [])
    if not sentence or "___" not in sentence or not solution:
        return None
    alternatives = [str(a).strip() for a in alt if isinstance(a, str) and str(a).strip()] if isinstance(alt, list) else []
    return {"sentence": sentence, "solution": solution, "alternatives": alternatives}


async def generate_gaptext(
    topic: str, block: str, section: str, compact: str | None,
    pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
) -> dict | None:
    """From a pattern, a gap-text task (sentence with ___, solution, synonyms), at the
    tier's level. → {sentence, solution, alternatives} · None on error."""
    try:
        section_block, compact_block = _section_blocks(section, compact)
        return await _gen_call(
            "Block-Gaptext", "guide", _gap_schema, provider, lane="batch",
            topic=topic, block=block, section_block=section_block,
            compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
        )
    except Exception:
        log.warning("[%s] Gap-text question failed (%s)", topic, block, exc_info=True)
        return None


def _norm_term(t: str) -> str:
    return re.sub(r"[^\wäöüß]", "", str(t or "").lower())


def _correct_schema(data) -> dict | None:
    if not isinstance(data, dict) or not isinstance(data.get("correct"), bool):
        return None
    return {"correct": data["correct"]}


async def check_gaptext(
    topic: str, block: str, sentence: str, solution: str, alternatives: list[str],
    input: str, provider: str = DEFAULT_PROVIDER,
) -> bool:
    """Check a gap-text answer: first a normalized comparison (solution + synonyms),
    otherwise 1 AI call for synonym tolerance. Fail-open to CORRECT only on an exact match."""
    if not input.strip():
        return False
    norm = _norm_term(input)
    if norm and norm in {_norm_term(solution), *(_norm_term(a) for a in alternatives)}:
        return True
    data = await _gen_call(
        "Block-Gaptext-Exam", "fast", _correct_schema, provider,
        topic=topic, block=block, sentence=sentence, solution=solution,
        alternatives=", ".join(alternatives) or "(none)", input=input,
    )
    return bool(data and data["correct"])


async def exam_rating_fast(
    topic: str, block: str, section: str, compact: str | None,
    question: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
) -> dict | None:
    """Action 'answer' (Agent 1, fast): evaluator only, no critic. → {feedback, tier}."""
    try:
        section_block, compact_block = _section_blocks(section, compact)
        transcript = _transcript(messages) if messages else "(empty)"
        return await _gen_call(
            "Block-Rating", "judge", _rating_schema, provider,
            topic=topic, block=block, section_block=section_block, compact_block=compact_block,
            question=question.strip() or "(no question provided)", transcript=transcript,
            reason_block="(none)", kritik_block="(none)",
        )
    except Exception:
        log.warning("[%s] Fast rating failed (%s)", topic, block, exc_info=True)
        return None


async def exam_rating(
    topic: str, block: str, section: str, compact: str | None,
    question: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
    role: str = "judge", reason: str = "",
) -> dict | None:
    """Action 'answer_check' (Agent 2, thorough): evaluator + critic. → {feedback, tier}.

    `role` = "guide" for "thorough check" (strong model). `reason` = optional
    learner dissatisfaction with an earlier rating.
    """
    try:
        section_block, compact_block = _section_blocks(section, compact)
        transcript = _transcript(messages) if messages else "(empty)"
        reason_block = reason.strip() or "(none)"
        return await _rating_with_critique(
            topic, block, section_block, compact_block,
            question.strip() or "(no question provided)", transcript, reason_block, provider, role,
        )
    except Exception:
        log.warning("[%s] Rating failed (%s)", topic, block, exc_info=True)
        return None


async def block_discussion(
    topic: str, block: str, section: str, compact: str | None,
    question: str, last_rating: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER,
) -> str | None:
    """Action 'discussion': tutor explains/discusses the question or a rating.

    No rating, no critic — here the human is the examiner. None on error.
    """
    try:
        section_block, compact_block = _section_blocks(section, compact)
        prompt = _prompt(
            "Block-Exam-Discussion",
            topic=topic, block=block,
            section_block=section_block, compact_block=compact_block,
            question=question.strip() or "(no question provided)",
            last_rating_block=(last_rating or "").strip() or "(none yet)",
            transcript=_transcript(messages) if messages else "(empty)",
        )
        returncode, stdout, _ = await run_agent(
            "examdiscussion-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
            provider=provider, role="fast", capabilities="none", lane="interactive",
        )
        if returncode != 0:
            return None
        return stdout.strip() or None
    except Exception:
        log.warning("[%s] Exam discussion failed (%s)", topic, block, exc_info=True)
        return None


