"""Scan-Board: zerlegt ein Projekt-Repo in Einheiten und Sichten (.planer/-Dateien).

Stages (Engine: kanban.py):
  Producer (Chunker, mechanisch) → schneiden (LLM je Chunk) → nachfass (LLM, nur bei
  Abdeckungslücken) → sammeln (mechanisch, drain: Fragmente je Quelldatei mergen,
  Sicht-Karten anlegen) → sichten (LLM je Sicht-Art) → schreiben (mechanisch, drain:
  .planer/ ins Ziel-Repo).

Payload-Disziplin: Chunk-Text und Fragmente bleiben in der Karte (~15 KB), ab `sammeln`
tragen Karten nur noch Staging-Pfade (storage/laeufe/<run>/).
"""
import asyncio
import logging
import time
from pathlib import Path

import artefakte
import chunker
import config
import database as db
import gates
from agents import AgentInfraError, run_agent
from kanban import Flow, Stage, chain_stages, run_flow

log = logging.getLogger("planer.scan")

BOARD = "scan"
SICHT_ARTEN = ("features", "architektur", "flows")
MAX_NACHFASS_SYMBOLE = 40  # Notbremse gegen endlos wachsende Nachfass-Prompts


def _template(name: str) -> str:
    return (config.TEMPLATES_DIR / f"{name}.md").read_text(encoding="utf-8")


def _prompt(name: str, **ersetzungen: str) -> str:
    text = _template(name)
    for k, v in ersetzungen.items():
        text = text.replace(f"<{k}>", v)
    return text


async def _llm(flow: Flow, stufe: str, card_id: str, rolle: str, prompt: str, label: str) -> str:
    """Ein Gate-gesicherter LLM-Call: rc!=0 → ValueError (Engine-Backoff);
    AgentInfraError propagiert (Lauf pausiert)."""
    rc, out, err = await run_agent(
        f"scan-{flow.topic}-{stufe}-{card_id}", prompt, config.AGENT_TIMEOUT,
        role=rolle, scope=flow.topic, label=label)
    if rc != 0:
        raise ValueError(f"{stufe} rc={rc}: {(err or out)[:200]}")
    return out


class ScanKontext:
    def __init__(self, wurzel: Path, stand: str, lauf_dir: Path):
        self.wurzel = wurzel
        self.stand = stand              # HEAD-Hash des Ziel-Repos beim Scan-Start
        self.lauf_dir = lauf_dir        # Staging: storage/laeufe/<run>/
        self.quelle_cache: dict = {}    # Datei → Text (Gates lesen viel)
        self.bestand: dict[str, str] = {}  # unveränderte Einheiten-Dateien (Re-Scan)


# --- Prozessoren ---------------------------------------------------------------------

async def _je_karte(flow: Flow, cards: list[dict], verarbeite) -> None:
    """Karten eines Pakets parallel verarbeiten; Erfolge advancen, dann ersten Fehler
    werfen (nur unadvancte Karten gehen in den Backoff). AgentInfraError hat Vorrang."""
    ergebnisse = await asyncio.gather(*(verarbeite(c) for c in cards), return_exceptions=True)
    moves = [(c["card_id"], ziel) for c, ziel in zip(cards, ergebnisse) if isinstance(ziel, str)]
    if moves:
        await db.kanban_advance_many(flow.topic, BOARD, moves)
        flow.wake.set()
    fehler = [e for e in ergebnisse if isinstance(e, BaseException)]
    for e in fehler:
        if isinstance(e, AgentInfraError):
            raise e
    if fehler:
        raise fehler[0]


def _mit_befunden(prompt: str, payload: dict) -> str:
    """Repair-Muster (Creator-Lehre): der Retry bekommt die Gate-Befunde des letzten
    Versuchs — blinde Wiederholung heilt systematische Fehler nicht."""
    befunde = payload.get("befunde")
    if not befunde:
        return prompt
    liste = "\n".join(f"- {b}" for b in befunde[:6])
    return (prompt + "\n\n## WICHTIG: Korrektur\n\nDein vorheriger Versuch scheiterte an "
            "diesen Prüf-Befunden. Behebe GENAU diese Punkte (Zitate müssen wörtlich aus "
            "der Quelle stammen — kopiere sie zeichengenau):\n" + liste + "\n")


async def _befunde_merken(flow: Flow, card: dict, fehler: list[str]) -> None:
    card["payload"]["befunde"] = fehler[:6]
    await db.kanban_set_payload(flow.topic, BOARD, card["card_id"], card["payload"])


async def _proc_schneiden(ctx: ScanKontext, flow: Flow, cards: list[dict]) -> None:
    async def verarbeite(c: dict) -> str:
        p = c["payload"]
        prompt = _mit_befunden(
            _prompt("einheiten", DATEI=p["datei"], QUELLE=p["text"],
                    SYMBOLE="\n".join(f"- {s}" for s in p["symbole"]) or "- (keine)"), p)
        out = await _llm(flow, "schneiden", c["card_id"], "schneiden", prompt, p["datei"])
        out, gestrichen = gates.repariere_kanten(out, ctx.wurzel, p["datei"], ctx.quelle_cache)
        out, korrigiert = gates.repariere_belege(out, ctx.wurzel, p["datei"], ctx.quelle_cache)
        fehler, warnungen, einheiten = gates.pruefe_fragment(out, ctx.wurzel, p["datei"],
                                                             ctx.quelle_cache)
        if fehler:
            await _befunde_merken(flow, c, fehler)
            raise ValueError(f"Gates: {'; '.join(fehler[:3])}")
        warnungen += [f"Kante gestrichen: {z}" for z in gestrichen]
        warnungen += [f"Beleg geschnappt: {z}" for z in korrigiert]
        _, zusatz, _ = gates.parse_einheiten(out)
        fehlend = gates.fehlende_symbole(p["symbole"], einheiten, zusatz)
        p["fragmente"] = [out]
        p["fehlend"] = fehlend
        p["warnungen"] = warnungen
        p.pop("befunde", None)
        await db.kanban_set_payload(flow.topic, BOARD, c["card_id"], p)
        return "nachfass" if fehlend else "sammeln"
    await _je_karte(flow, cards, verarbeite)


async def _proc_nachfass(ctx: ScanKontext, flow: Flow, cards: list[dict]) -> None:
    async def verarbeite(c: dict) -> str:
        p = c["payload"]
        fehlend = p["fehlend"][:MAX_NACHFASS_SYMBOLE]
        prompt = _mit_befunden(
            _prompt("nachfass", DATEI=p["datei"], QUELLE=p["text"],
                    FEHLEND="\n".join(f"- {s}" for s in fehlend),
                    VORHANDEN="\n\n".join(p["fragmente"])), p)
        out = await _llm(flow, "nachfass", c["card_id"], "nachfass", prompt, p["datei"])
        out, _ = gates.repariere_kanten(out, ctx.wurzel, p["datei"], ctx.quelle_cache)
        out, _ = gates.repariere_belege(out, ctx.wurzel, p["datei"], ctx.quelle_cache)
        fehler, _, _ = gates.pruefe_fragment(out, ctx.wurzel, p["datei"], ctx.quelle_cache) \
            if out.strip().startswith("## ") else ([], [], [])
        if fehler:
            await _befunde_merken(flow, c, fehler)
            raise ValueError(f"Gates (Nachfass): {'; '.join(fehler[:3])}")
        # Abdeckung über ALLE Fragmente des Chunks prüfen
        gesamt = "\n\n".join(p["fragmente"] + [out])
        einheiten, zusatz, _ = gates.parse_einheiten(gesamt)
        immer_noch = gates.fehlende_symbole(p["symbole"], einheiten, zusatz)
        if immer_noch:
            raise ValueError(f"Nachfass ließ Symbole offen: {', '.join(immer_noch[:5])}")
        p["fragmente"].append(out)
        p["fehlend"] = []
        p.pop("befunde", None)
        await db.kanban_set_payload(flow.topic, BOARD, c["card_id"], p)
        return "sammeln"
    await _je_karte(flow, cards, verarbeite)


async def _proc_sammeln(ctx: ScanKontext, flow: Flow, cards: list[dict]) -> None:
    """Drain: alle Chunk-Karten liegen vor. Fragmente je Quelldatei mergen, Staging
    schreiben, Übersichten bauen, eine Karte je Sicht-Art anlegen."""
    je_datei: dict[str, list[dict]] = {}
    for c in cards:
        je_datei.setdefault(c["payload"]["datei"], []).append(c)
    (ctx.lauf_dir / "einheiten").mkdir(parents=True, exist_ok=True)
    ids: set[str] = set()
    kurz_zeilen: list[str] = []    # ID + beschreibung (features)
    kanten_zeilen: list[str] = []  # ID + beschreibung + kanten (architektur/flows)
    for datei, karten in sorted(je_datei.items()):
        karten.sort(key=lambda c: c["payload"]["start"])
        rumpf = artefakte.merge_fragmente([f for c in karten for f in c["payload"]["fragmente"]])
        text = artefakte.einheiten_datei(datei, rumpf, ctx.stand)
        (ctx.lauf_dir / artefakte.datei_pfad(datei)).write_text(text, encoding="utf-8")
        ids |= artefakte.alle_ids(text)
        einheiten, _, _ = gates.parse_einheiten(text)
        for e in einheiten:
            kurz_zeilen.append(f"- {e['id']}: {e['felder'].get('beschreibung', '')}")
            kanten = "; ".join(f"{typ} {ziel}" for _, typ, ziel in e["kanten"])
            kanten_zeilen.append(kurz_zeilen[-1] + (f"\n  kanten: {kanten}" if kanten else ""))
    for datei, text in ctx.bestand.items():  # Re-Scan: unveränderte Dateien zählen mit
        ids |= artefakte.alle_ids(text)
        einheiten, _, _ = gates.parse_einheiten(text)
        for e in einheiten:
            kurz_zeilen.append(f"- {e['id']}: {e['felder'].get('beschreibung', '')}")
            kanten = "; ".join(f"{typ} {ziel}" for _, typ, ziel in e["kanten"])
            kanten_zeilen.append(kurz_zeilen[-1] + (f"\n  kanten: {kanten}" if kanten else ""))
    (ctx.lauf_dir / "uebersicht-kurz.md").write_text("\n".join(sorted(kurz_zeilen)), encoding="utf-8")
    (ctx.lauf_dir / "uebersicht-kanten.md").write_text("\n".join(sorted(kanten_zeilen)), encoding="utf-8")
    (ctx.lauf_dir / "ids.txt").write_text("\n".join(sorted(ids)), encoding="utf-8")
    for art in SICHT_ARTEN:
        await db.kanban_upsert_card(flow.topic, BOARD, f"sicht-{art}", "sicht", "sichten",
                                    {"art": art})
    await db.kanban_advance_many(flow.topic, BOARD,
                                 [(c["card_id"], "chunk-fertig") for c in cards])
    flow.wake.set()


async def _proc_sichten(ctx: ScanKontext, flow: Flow, cards: list[dict]) -> None:
    ids = set((ctx.lauf_dir / "ids.txt").read_text(encoding="utf-8").splitlines())

    async def verarbeite(c: dict) -> str:
        art = c["payload"]["art"]
        uebersicht = "uebersicht-kurz.md" if art == "features" else "uebersicht-kanten.md"
        prompt = _mit_befunden(
            _prompt(f"sichten-{art}", PROJEKT=flow.topic,
                    EINHEITEN=(ctx.lauf_dir / uebersicht).read_text(encoding="utf-8")), c["payload"])
        out = await _llm(flow, "sichten", c["card_id"], "sichten", prompt, art)
        out, gestrichen = gates.repariere_referenzen(out, ids)
        if gestrichen:
            log.info("scan %s: %s — %d unbekannte Referenzen gestrichen", flow.topic, art,
                     len(gestrichen))
        fehler = gates.pruefe_referenzen(out, ids)
        if fehler:
            await _befunde_merken(flow, c, fehler)
            raise ValueError(f"Referenzen ({art}): {'; '.join(fehler[:3])}")
        (ctx.lauf_dir / f"sicht-{art}.md").write_text(out, encoding="utf-8")
        return "schreiben"
    await _je_karte(flow, cards, verarbeite)


async def _proc_schreiben(ctx: ScanKontext, flow: Flow, cards: list[dict]) -> None:
    """Drain nach allen Sichten: .planer/ im Ziel-Repo schreiben."""
    dateien: dict[str, str] = {}
    for p in (ctx.lauf_dir / "einheiten").glob("*.md"):
        dateien[f"einheiten/{p.name}"] = p.read_text(encoding="utf-8")
    for datei, text in ctx.bestand.items():
        dateien.setdefault(artefakte.datei_pfad(datei), text)
    fehlend: list[str] = []  # gescheiterte Sichten: auslassen statt Kaskade (sichtbar degradieren)
    if (ctx.lauf_dir / "sicht-features.md").is_file():
        features = (ctx.lauf_dir / "sicht-features.md").read_text(encoding="utf-8")
        kern, _, bereiche = features.partition("# Bereich:")
        dateien["kern.md"] = kern.strip() + "\n"
        for i, block in enumerate(("# Bereich:" + bereiche).split("# Bereich:")):
            if not block.strip():
                continue
            name = block.splitlines()[0].strip().lower().replace(" ", "-").replace("/", "-")
            dateien[f"features/{name or f'bereich-{i}'}.md"] = "# Bereich:" + block.rstrip() + "\n"
    else:
        fehlend.append("features")
    for art, zieldatei in (("architektur", "architektur.md"), ("flows", "flows.md")):
        if (ctx.lauf_dir / f"sicht-{art}.md").is_file():
            dateien[zieldatei] = (ctx.lauf_dir / f"sicht-{art}.md").read_text(encoding="utf-8")
        else:
            fehlend.append(art)
    if fehlend:
        flow.state["sichten_fehlend"] = fehlend
        log.warning("scan %s: Sichten fehlen (Karten dead): %s", flow.topic, ", ".join(fehlend))
    if not (ctx.wurzel / ".planer" / "pruefung.md").is_file():  # nie Nutzer-Edits überschreiben
        dateien["pruefung.md"] = artefakte.pruefung_geruest(ctx.wurzel, flow.topic)
    artefakte.schreibe_planer(ctx.wurzel, dateien)
    flow.state["geschrieben"] = sorted(dateien)
    await db.kanban_advance_many(flow.topic, BOARD, [(c["card_id"], "fertig") for c in cards])
    flow.wake.set()


# --- Flow-Start ------------------------------------------------------------------------

def scan_stages(ctx: ScanKontext, flow: Flow) -> list[Stage]:
    # gate=research_done an den Barriere-Stufen ist PFLICHT (Abnahme-Befund): die Barriere
    # prüft nur die Upstream-Stufen — ohne Gate drained `sammeln`, während der Producer
    # noch Karten einreiht, und die Sichten sehen eine Teil-Übersicht.
    fertig_produziert = lambda: flow.research_done  # noqa: E731
    return chain_stages([
        Stage(BOARD, "schneiden", lambda cs: _proc_schneiden(ctx, flow, cs)),
        Stage(BOARD, "nachfass", lambda cs: _proc_nachfass(ctx, flow, cs)),
        Stage(BOARD, "sammeln", lambda cs: _proc_sammeln(ctx, flow, cs),
              barrier=True, drain=True, gate=fertig_produziert),
        Stage(BOARD, "sichten", lambda cs: _proc_sichten(ctx, flow, cs)),
        Stage(BOARD, "schreiben", lambda cs: _proc_schreiben(ctx, flow, cs),
              barrier=True, drain=True, gate=fertig_produziert),
    ])


async def run_scan(wurzel: Path, projekt: str, stand: str, set_p=None,
                   nur_dateien: set[str] | None = None,
                   bestand: dict[str, str] | None = None,
                   fortsetzen: bool = False) -> dict:
    """Scan eines Projekt-Repos. `nur_dateien` beschränkt auf geänderte Dateien (Re-Scan);
    `bestand` = unveränderte Einheiten-Dateien {quelldatei: text}. `fortsetzen=True`
    setzt einen abgebrochenen/fehlgeschlagenen Lauf fort: tote Chunks laufen neu durchs
    LLM, fertige nur durchs mechanische Sammeln (Fragmente stecken im Payload) — setzt
    unveränderten HEAD voraus. → {status, geschrieben, dead}."""
    run_id = f"scan-{time.time_ns()}"  # eindeutig auch bei zwei Läufen in derselben Sekunde
    lauf_dir = config.LAEUFE_DIR / projekt / run_id
    lauf_dir.mkdir(parents=True, exist_ok=True)
    ctx = ScanKontext(wurzel, stand, lauf_dir)
    ctx.bestand = bestand or {}
    flow = Flow(projekt)
    db.set_current_run(projekt, run_id)

    async def producer():
        try:
            if fortsetzen:
                n_neu = n_fertig = 0
                for c in await db.kanban_cards(flow.topic, BOARD, kind="chunk"):
                    if c["stage"] == "dead":
                        await db.kanban_upsert_card(flow.topic, BOARD, c["card_id"],
                                                    "chunk", "schneiden", None)
                        n_neu += 1
                    elif c["stage"] == "chunk-fertig":
                        await db.kanban_upsert_card(flow.topic, BOARD, c["card_id"],
                                                    "chunk", "sammeln", None)
                        n_fertig += 1
                log.info("scan %s: Fortsetzung — %d neu, %d nur sammeln", projekt, n_neu, n_fertig)
                return
            chunks = await asyncio.to_thread(chunker.chunks_fuer_repo, wurzel, nur_dateien)
            for i, chunk in enumerate(chunks):
                await db.kanban_upsert_card(flow.topic, BOARD, f"chunk-{i:04d}", "chunk",
                                            "schneiden", chunk)
            log.info("scan %s: %d Chunks angelegt", projekt, len(chunks))
        finally:
            flow.done_producer()

    flow.add_producer()  # SYNCHRON vor create_task (Quieszenz-Race)
    try:
        await run_flow(flow, scan_stages(ctx, flow), [producer()], set_p)
    finally:
        db.set_current_run(projekt, None)
    dead = await db.kanban_dead(projekt)
    status = "pausiert" if flow.state.get("infra_paused") else ("fehler" if dead else "ok")
    return {"status": status, "infra_error": flow.state.get("infra_error", ""),
            "geschrieben": flow.state.get("geschrieben", []),
            "dead": [c["card_id"] for c in dead]}
