"""Guide generation as a consensus pipeline.

Outline: select the blocks (deterministic per format) → 3 proposals
(Grace) that order the blocks into chapters by NUMBER → a judge merges the
proposals into one coherent order.
Writing: one writer per block. Reading exam: Check→Fix (one round),
follow-up rounds check only replaced sections; remaining complaints then stand.
Step files are kept → an abort preserves progress, ▶ resumes at the open step.
"""

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

import uuid

from agents import run_agent
from blocks import _convert_pdfs, source_folder
from config import DEFAULT_PROVIDER, TEMPLATES_DIR
from database import (list_guides, update_guide, list_blocks, list_subblocks, set_guide_content,
                      get_guide_content, get_outline, guide_stage_counts, delete_guide_board)
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from paths import blocks_path, guide_content_path, subblocks_path
from pipeline import _fail, _prompt, _semaphore, clear_guide_cancelled, is_guide_cancelled
from textkit import _unique_title, _load_blocks, _norm_title, _title, parse_facts

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


# Content/Content-Check/Reading-Exam run in packets of ~GUIDE_CHUNK blocks per agent.
# Only the writer (Writing) stays at 1 agent per block (variable lengths, no trimming, no
# length alignment between blocks).

# Check steps as a panel: CHECK_PANEL judges per chunk, section flagged on a majority.
# A single judge is bias/sampling prone; a small panel is more stable.

# Reading exam: only ONE round (Check + Fix). Follow-up rounds added little value
# (1 agent per block checks finely anyway) but cost extra agents.


# Valid level values: new (learning path) + old (difficulty) backward-compatible.
_LEVELS_OK = ("beginner", "advanced", "expert", "easy", "medium", "hard")


async def _load_subblocks(topic: str) -> dict[str, list[dict]]:
    """Subblocks per block — DB-first ({title, level, relevance}), fallback sidecar file.
    Both missing → {} (guide takes everything). A missing/invalid level defaults to
    'advanced' instead of dropping the row: re-run resume left 25 consensus subs
    level-less, the writer silently lost them while the guide QA still counted them."""
    out: dict[str, list[dict]] = {}
    for r in await list_subblocks(topic):
        if r["status"] == "consensus" and r["sub_title"]:
            facts = parse_facts(r.get("facts"))
            level = r["level"] if r["level"] in _LEVELS_OK else "advanced"
            out.setdefault(r["block"], []).append(
                {"title": r["sub_title"], "level": level, "relevance": r["relevance"], "facts": facts})
    if out:
        return out
    data = _json_file(subblocks_path(topic))
    if not isinstance(data, dict):
        return {}
    for title, subs in data.items():
        if not isinstance(subs, list):
            continue
        good = [s for s in subs if isinstance(s, dict) and str(s.get("title", "")).strip()
                and s.get("level") in _LEVELS_OK]
        if good:
            out[title] = good
    return out


def _level_label(s: dict) -> str:
    """View level of a subblock: peripheral → 'peripheral' (level 4), otherwise the level (1–3)."""
    return "peripheral" if s.get("relevance") == "peripheral" else (s.get("level") or "beginner")






def guide_slot_files(content_path: Path) -> list[Path]:
    """All step files of a guide (for a fresh start)."""
    return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path]










# Slot-file globs per step (index = GUIDE_STEPS). Stem-anchored, collision-free.










def _fallback_outline(entries: dict[int, str]) -> list[dict]:
    """Deterministic outline when the agents deliver none: one chapter with
    all selected blocks in order. Guarantees full coverage."""
    return [{"title": "Contents", "nums": list(entries)}]


def _with_remainder(plan: list[dict], entries: dict[int, str]) -> list[dict]:
    """Ensures that EVERY selected block is in the plan — missing ones land in
    an "Additional" chapter (against agents/judges that drop blocks)."""
    present = {num for ch in plan for num in ch.get("nums", [])}
    missing = [num for num in entries if num not in present]
    return [*plan, {"title": "Additional", "nums": missing}] if missing else plan


def _facts_grounding(subs_raw: dict[str, list[dict]]) -> str:
    """Verified sub-facts (extract-once from the blocks phase) as a grounding block for the
    content agent. Empty if no facts are stored (legacy data → fallback to a source hint)."""
    blocks = []
    for title, subs in subs_raw.items():
        lines = []
        for s in subs:
            fk = s.get("facts") if isinstance(s.get("facts"), dict) else None
            if not fk:
                continue
            parts = []
            if fk.get("key_points"):
                parts.append("Core: " + " · ".join(fk["key_points"]))
            for bf in fk.get("cited_facts", []):
                parts.append(f"FACT[{bf.get('source', '?')}]: {bf.get('text', '')}")
            if fk.get("prerequisites"):
                parts.append("Prerequisite: " + fk["prerequisites"])
            if fk.get("hurdles"):
                parts.append("Hurdle: " + fk["hurdles"])
            if fk.get("example_idea"):
                parts.append("Example: " + fk["example_idea"])
            if parts:
                lines.append(f"- {s['title']}: " + " | ".join(parts))
        if lines:
            blocks.append(f"BLOCK: {title}\n" + "\n".join(lines))
    if not blocks:
        return ""
    return ("VERIFIED FACTS per subblock — binding basis. Quote cited facts (FACT[Source]) "
            "VERBATIM, invent nothing extra, do NOT re-research. Use examples as examples, "
            "never as fact.\n\n" + "\n\n".join(blocks))


async def _outline_from_db(topic: str, sel_entries: dict[int, str]) -> list[dict] | None:
    """Read the outline from the blocks artifact (DB) and map it onto the selected blocks.
    Title-based (robust against number drift): blocks outside the selection are ignored
    (format filter), missing ones are added later by _with_remainder. None → no artifact (legacy)."""
    raw = await get_outline(topic)
    if not raw:
        return None
    try:
        data = json.loads(raw)
    except (ValueError, TypeError):
        return None
    chapters = data.get("chapters") if isinstance(data, dict) else None
    if not isinstance(chapters, list):
        return None
    norm_to_num = {_norm_title(_title(t)): num for num, t in sel_entries.items()}
    plan, seen = [], set()
    for ch in chapters:
        if not isinstance(ch, dict):
            continue
        nums = []
        for bt in ch.get("blocks", []):
            num = norm_to_num.get(_norm_title(str(bt)))
            if num is not None and num not in seen:
                seen.add(num)
                nums.append(num)
        if nums:
            plan.append({"title": str(ch.get("title", "")).strip() or "Chapter", "nums": nums})
    return plan or None




_LEVEL_RANK = {"beginner": 1, "advanced": 2, "expert": 3, "peripheral": 4,
               "easy": 1, "medium": 2, "hard": 3}  # old values backward-compatible


def _section_for_level(sec: dict, level: int) -> dict:
    """Reconstruct md/compact of a section from subblocks up to the level (anchor stays)."""
    subs = sec.get("subs") or []
    if not subs:
        return sec  # no sub tags (legacy) → unchanged, visible
    visible = [s for s in subs if _LEVEL_RANK.get(s.get("level"), 1) <= level]
    md = "\n\n".join(t for t in [sec.get("anchor", ""), *(s.get("md", "") for s in visible)] if t).strip()
    compact = "\n".join(t for t in [sec.get("anker_compact", ""), *(s.get("compact", "") for s in visible)] if t).strip()
    return {**sec, "md": md, "compact": compact, "leer": not visible}


def content_fuer_level(content: dict, level: int) -> dict:
    """Filter guide content to a view level (1=A · 2=F · 3=E · 4=V). Level 4 = full version.
    Sections without visible subs are hidden, empty chapters drop out."""
    if not isinstance(content, dict) or level >= 4:
        return content
    chapters = []
    for ch in content.get("chapters", []):
        secs = [s for s in (_section_for_level(x, level) for x in ch.get("sections", [])) if not s.get("leer")]
        if secs:
            chapters.append({**ch, "sections": secs})
    return {**content, "chapters": chapters}


async def reconcile_guides() -> None:
    """Reconcile DB↔filesystem: status=done without content file → error.

    Runs at server start (after init_db) — catches crashes between
    file write and status update.
    """
    for g in await list_guides():
        if g["status"] != "done":
            continue
        # DB-first wie die Content-Route — die Datei ist nur Legacy-Fallback
        if await get_guide_content(g["topic"], g["format"]) is not None:
            continue
        if guide_content_path(g["topic"], g["format"]).exists():
            continue
        log.warning("[%s] Guide %s: done without content — set to error", g["topic"], g["id"])
        now = datetime.now(timezone.utc).isoformat()
        await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now)


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

        content_path = guide_content_path(topic, format_name)
        content_path.parent.mkdir(parents=True, exist_ok=True)
        project = source_folder(topic)  # folder source (project/uni/link) → path, else None

        try:
            if is_guide_cancelled(guide_id):
                return

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

            import guide_board  # lazy — guide_board imports helpers from this module
            # A FINISHED guide → complete fresh start (board + slots wiped). Otherwise the cards
            # are leftovers of an abort/error/repair → resume at their stored stage.
            if content_path.exists():
                counts = await guide_stage_counts(topic, format_name)
                if not counts or set(counts) == {"done"}:
                    await delete_guide_board(topic, format_name)
                    for p_alt in guide_slot_files(content_path):
                        p_alt.unlink(missing_ok=True)

            bs = await list_blocks(topic, status="consensus")
            if bs:
                alle = {i: (f"{b['title']} — {b['description']}" if b["description"] else b["title"])
                        for i, b in enumerate(bs, 1)}
            else:  # fallback: blocks.md (legacy topics)
                bp = blocks_path(topic)
                alle = _load_blocks(bp.read_text(encoding="utf-8")) if bp.exists() else {}
            if not alle:
                await _fail(guide_id, "No blocks found")
                return
            entries = _unique_title(alle)
            chapters = await guide_board.run_guide_board(
                guide_id, topic, format_name, entries, instructions, provider, content_path,
            )
            if is_guide_cancelled(guide_id):
                return
            if chapters is None:
                await _fail(guide_id, "No finished sections (see board — cards with errors)")
                return
            content = {"topic": topic, "format": format_name, "chapters": chapters}

            atomic_write_json(content_path, content, indent=1)  # bridge (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 during generation")
        except FileNotFoundError:
            await _fail(guide_id, "Blocks missing")
        except Exception as e:
            log.exception("[%s] Guide generation failed (%s)", topic, guide_id)
            await _fail(guide_id, str(e)[:2000])
        finally:
            clear_guide_cancelled(guide_id)


# --- On-demand: check / fix / rewrite one section (focus, interactive) ---

SECTION_CHECK_TIMEOUT = 300


def _section_spec() -> str:
    return (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")


def _section_facts(topic: str) -> str:
    project = source_folder(topic)
    return _prompt("Guide-Facts-Projekt", project=project) if project else _prompt("Guide-Facts-Thema")


def _hint_block(hint: str) -> str:
    hint = (hint or "").strip()
    return f"NOTE FROM THE USER (pay special attention):\n{hint}" if hint else ""


async def _subs_text(topic: str, block: str) -> str:
    """Relevant subblocks of a block with level — as a checklist for the agents."""
    subs_raw = await _load_subblocks(topic)
    subs = [s for s in subs_raw.get(_title(block), []) if s.get("relevance") != "peripheral"]
    if not subs:
        return "(no subblocks recorded — cover 3–7 concise points)"
    return "\n".join(f"- [{s['level']}] {s['title']}" for s in subs)


async def _load_guide_content(topic: str, format_name: str) -> dict | None:
    js = await get_guide_content(topic, format_name)
    if not js:
        return None
    try:
        return json.loads(js)
    except ValueError:
        return None


def _find_section(content: dict, block: str) -> dict | None:
    for ch in content.get("chapters", []):
        for s in ch.get("sections", []):
            if s.get("title") == block:
                return s
    return None


async def block_pruefen(topic: str, format_name: str, block: str, spot: str, snippet: str, hint: str = "", provider: str = DEFAULT_PROVIDER) -> str | None:
    """Check one section (Markdown block) against the guide rules → corrected
    block version as Markdown. None = error/section missing."""
    content = await _load_guide_content(topic, format_name)
    sec = _find_section(content, block) if content else None
    if sec is None:
        return None
    whole = sec.get("compact", "") if str(spot).startswith("compact") else sec.get("md", "")
    prompt = _prompt(
        "Block-Pruefen", topic=topic, spec=_section_spec(), facts=_section_facts(topic),
        subblocks=await _subs_text(topic, block), context=whole, snippet=snippet, hint=_hint_block(hint),
    )
    rc, stdout, _ = await run_agent(
        f"block-pruefen-{uuid.uuid4()}", prompt, SECTION_CHECK_TIMEOUT,
        provider=provider, role="judge", capabilities="none", lane="interactive",
    )
    new = stdout.strip() if rc == 0 else ""
    return new or None


async def block_adopt(topic: str, format_name: str, block: str, spot: str, old: str, new: str) -> dict | None:
    """Replace one block (old→new) in the compact/detailed field + persist.
    → {compact, md, found}; None = section missing."""
    content = await _load_guide_content(topic, format_name)
    sec = _find_section(content, block) if content else None
    if sec is None:
        return None
    is_compact = str(spot).startswith("compact")
    field = "compact" if is_compact else "md"
    current = sec.get(field, "") or ""
    found = old in current
    if found:
        sec[field] = current.replace(old, new, 1)
        # Also replace in anchor + subs (sources of the filtered E/M/S view), otherwise the
        # leveled view keeps showing the old block.
        anchor_field = "anker_compact" if is_compact else "anchor"
        if old in (sec.get(anchor_field) or ""):
            sec[anchor_field] = sec[anchor_field].replace(old, new, 1)
        for sub in sec.get("subs", []):
            if old in (sub.get(field, "") or ""):
                sub[field] = sub[field].replace(old, new, 1)
                break
        js = json.dumps(content, ensure_ascii=False)
        await set_guide_content(topic, format_name, js)
        atomic_write_json(guide_content_path(topic, format_name), content, indent=1)
    return {"compact": sec.get("compact", ""), "md": sec.get("md", ""), "found": found}


# --- Tutor chat (moved from the removed elements module) ---

def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
    transcript = "\n".join(
        f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
        for m in messages
    )
    return _prompt(
        "Chat",
        topic=topic, format_name=format_name,
        outline_block=outline.strip() or "(none)",
        section_block=section.strip() or "(no section detected)",
        transcript=transcript,
    )


async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
    try:
        prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
        returncode, stdout, stderr = await run_agent(
            "chat-" + str(uuid.uuid4()), prompt, 240, 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] Guide chat failed", topic, exc_info=True)
        return "Sorry, that didn't work. Please try again."
