"""Befund-Repair: arbeitet den jüngsten QA-Report gezielt ab — ohne Flow, ohne Board-Rebuild.

Blindes Re-Filtern reproduziert die blinden Flecken der Pipeline (sie hat die Befunde ja
durchgelassen). Hier fließen die QA-BEFUNDE als Input in gezielte Aktionen: Hygiene
deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur nach
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""

import logging
import re

import database as db
import qa
from blocks import _blocks_files, _evidence_pack, source_folder
from config import EVIDENCE_PER_BLOCK
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from textkit import _norm_title, _title, clean_title, parse_facts

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


async def repair_befunde(topic: str, ebene: str | None = None) -> dict:
    """ebene=None → alle Fixes; „inventory" → nur Inventar (Hygiene/Merges/Fremd);
    „artefacts" → nur Artefakte (Sub-Merges/Waisen). Misst am Ende IMMER beide Noten neu."""
    reports = qa.report_paths(topic)
    report = _json_file(reports[-1]) if reports else None
    if not report:
        return {"fehler": "kein QA-Report — erst QA laufen lassen"}
    files = _blocks_files(topic)
    cards = await db.kanban_cards(topic, board="inventory", stage="done_block")
    by_norm = {_norm_title(c["payload"].get("title", "")): c for c in cards}
    inv = ebene in (None, "inventory")
    art = ebene in (None, "artefacts")

    hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else []
    merges = await _merge_dubletten(topic, report, by_norm, files) if inv else []
    sub_merges, frei_subs = (await _merge_sub_dubletten(topic, report, files)) if art else ([], [])
    entfernt, frei_bloecke = (await _entferne_fremd_unecht(topic, report, by_norm, files)) if inv else ([], [])
    aufgeraeumt = (await _raeume_waisen(topic)) if art else 0

    # llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report
    # blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln
    neu = await qa.qa_report(topic, llm=True)
    if neu:
        await qa.write_report(neu)
    na = neu.get("note_artefakte") if neu else None
    return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
            "aufgeraeumt": aufgeraeumt, "freigesprochen": frei_subs + frei_bloecke,
            "braucht_research": len(report.get("luecken", [])),
            "note": float(neu["note"]) if neu else 10.0,          # neu gemessene Inventar-Note
            "note_artefakte": float(na) if na is not None else None}  # None solange Board 2 leer


async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]:
    """No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten)."""
    return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair")


def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None:
    d = qa.lade_freispruch(topic)
    alt = set(d.get(kategorie) or [])
    d[kategorie] = sorted(alt | set(keys))
    qa.freispruch_pfad(topic).parent.mkdir(parents=True, exist_ok=True)
    atomic_write_json(qa.freispruch_pfad(topic), d, indent=1)


async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
                              lines: list[str], befund: str, kategorie: str = "",
                              ids: list[str] | None = None) -> tuple[dict[int, str], list[str]]:
    """Zweitmeinung + Stichentscheid: Der Repair-Judge kann den QA-Befund kippen — bei
    Dissens (QA sagt Befund, Judge sagt behalten) entscheidet ein DRITTER Judge nur über
    die strittigen Items, Mehrheit 2/3 (Muster Crossblock-Tiebreaker). Ohne ihn pendelte
    die Note dauerhaft unter 10 ohne Fix-Pfad (gemessen: aak-fremd 9.2, kanban-smoke-
    Dublette 9.4 — „keine behebbaren Befunde" trotz Befund).
    Explizites 2:1-„behalten" wird als FREISPRUCH persistiert (kategorie+ids) — die QA
    zählt das Item ab dann nicht mehr (qa.lade_freispruch). j3-AUSFALL persistiert nicht
    (fail-open ist kein Urteil). → (verdicts, freigesprochene Zeilen)."""
    v = await _judge(template, topic, key, slot, lines)
    strittig = [i for i in range(1, len(lines) + 1) if v.get(i) != befund]
    frei: list[str] = []
    if strittig:
        v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig])
        gegen = "nein" if befund == "ja" else "ja"
        frei_keys: list[str] = []
        for pos, i in enumerate(strittig, 1):
            if v3.get(pos) == befund:
                v[i] = befund  # 2:1 für den QA-Befund → handeln
            elif v3.get(pos) == gegen and kategorie and ids:
                frei_keys.append(ids[i - 1])
                frei.append(lines[i - 1].splitlines()[0][:80])
        if frei_keys:
            _speichere_freispruch(topic, kategorie, frei_keys)
            log.info("[%s] Repair %s: %d Befund(e) per 2:1 freigesprochen", topic, kategorie, len(frei_keys))
    return v, frei


async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
    """Nur der norm-invariante Teil (`**`/Backticks); `(n)`-Suffix und leere Beschreibung
    ändern die Norm bzw. brauchen Inhalt — bleiben Befund."""
    fixed = []
    for h in report.get("hygiene", []):
        alt = h.get("titel", "")
        neu = clean_title(alt)
        if neu == alt or _norm_title(neu) != _norm_title(alt):
            continue
        norm = _norm_title(alt)
        card = by_norm.get(norm)
        if not card:
            continue
        p = dict(card["payload"])
        p["title"] = neu
        await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
        await db.set_block_status(topic, norm, "consensus", title=neu)
        _rename_in_files(files, norm, neu)
        fixed.append(f"{alt} → {neu}")
    return fixed


async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
    """Nur QA-bestätigte Paare (llm=ja); eine Zweitmeinung, Merge nur bei erneut ja.
    Merge spiegelt die dedup-Stage: Union ins Gewinner-Payload, Verlierer → grouped."""
    paare = [p for p in report.get("dubletten", []) if p.get("llm") == "ja"
             and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
    if not paare:
        return []
    v, _frei = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs",
                                         [f"A: {p['a']}\nB: {p['b']}" for p in paare], "ja")
    merged = []
    for i, p in enumerate(paare, 1):
        a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
        if v.get(i) != "ja" or not a or not b or a["card_id"] == b["card_id"]:
            continue
        win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""),
                                                  len(c["payload"].get("title") or "")), reverse=True)
        wp, lp = dict(win["payload"]), dict(lose["payload"])
        wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or []))
        wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or []))
        lp.update(reason="merged", merged_into=wp.get("title", ""))
        await db.kanban_set_payload(topic, "inventory", win["card_id"], wp)
        await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp)
        await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped")
        await _purge_block(topic, lp.get("title", ""), files)
        by_norm.pop(_norm_title(lp.get("title", "")), None)
        merged.append(f"{lp.get('title')} → {wp.get('title')}")
    return merged


_SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S)


def _sub_gewinner(a: dict, b: dict) -> tuple[dict, dict]:
    """Gewinner = mehr key_points im facts-Feld, dann längerer Titel (Muster Konsolidierung)."""
    def score(r):
        kp = len(parse_facts(r.get("facts")).get("key_points") or [])
        return (kp, len(r.get("sub_title") or ""))
    return (a, b) if score(a) >= score(b) else (b, a)


async def falte_sub(topic: str, files: dict, win: dict, lose: dict) -> None:
    """Verlierer-Sub falten: Status variant, Fragen/Artefakte zum Gewinner umhängen (oder
    löschen, wenn der Typ dort existiert), Sidecar-Dateien bereinigen. Gemeinsamer Kern
    von QA-Repair und Cross-Block-Dedup (Board 2, Run-Ende) — win/lose sind subblocks-Rows."""
    await db.set_subblock_fields(topic, lose["block_norm"], lose["sub_norm"], status="variant")
    w_fragen = {r["sub_norm"] for r in await db.list_question_pattern(topic, win["block_norm"])}
    for r in await db.list_question_pattern(topic, lose["block_norm"]):
        if r["sub_norm"] != lose["sub_norm"]:
            continue
        if win["sub_norm"] not in w_fragen:
            await db.upsert_question_pattern(topic, win["block_norm"], win["sub_norm"],
                                             win["block"], win["sub_title"], r["question"])
        await db.delete_frage_row(topic, lose["block_norm"], lose["sub_norm"])
    w_typen = {r["type"] for r in await db.get_sub_artefakte(topic, block_norm=win["block_norm"])
               if r["sub_norm"] == win["sub_norm"]}
    for r in await db.get_sub_artefakte(topic, block_norm=lose["block_norm"]):
        if r["sub_norm"] != lose["sub_norm"]:
            continue
        if r["type"] not in w_typen:
            await db.put_sub_artifact(topic, win["block_norm"], win["sub_norm"], r["type"],
                                      r["data"], win["block"], win["sub_title"])
        await db.delete_artefakt_row(topic, lose["block_norm"], lose["sub_norm"], r["type"])
    _entferne_sub_in_files(files, lose["block_norm"], lose["sub_norm"])


async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> tuple[list[str], list[str]]:
    """QA-bestätigte Sub-Paare (llm=ja) nach Zweitmeinung falten: Verlierer → variant,
    seine Fragen/Artefakte wandern zum Gewinner (oder fallen weg, wenn er den Typ hat).
    Repair hatte dafür keinen Handler — die Paare überlebten jeden Repair-Zyklus."""
    rows = {(r["block_norm"], r["sub_norm"]): r for r in await db.list_subblocks(topic)
            if r["status"] == "consensus"}

    def _row(eintrag: str):
        m = _SUB_PAAR.match(eintrag or "")
        return rows.get((_norm_title(m.group(1)), _norm_title(m.group(2)))) if m else None

    paare = [(a, b) for p in report.get("sub_dubletten", []) if p.get("llm") == "ja"
             and (a := _row(p.get("a"))) and (b := _row(p.get("b")))
             and (a["block_norm"], a["sub_norm"]) != (b["block_norm"], b["sub_norm"])]
    if not paare:
        return [], []
    v, frei = await _mit_stichentscheid(
        "QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
        [f"A: [{a['block']}] {a['sub_title']}\nB: [{b['block']}] {b['sub_title']}" for a, b in paare],
        "ja", kategorie="sub_dubletten",
        ids=[qa._paar_key(f"[{a['block']}] {a['sub_title']}", f"[{b['block']}] {b['sub_title']}")
             for a, b in paare])
    merged: list[str] = []
    gone: set[tuple] = set()
    for i, (a, b) in enumerate(paare, 1):
        win, lose = _sub_gewinner(a, b)
        wk, lk = (win["block_norm"], win["sub_norm"]), (lose["block_norm"], lose["sub_norm"])
        if v.get(i) != "ja" or wk in gone or lk in gone:
            continue
        await falte_sub(topic, files, win, lose)
        gone.add(lk)
        merged.append(f"{lose['sub_title'][:40]} → {win['sub_title'][:40]}")
    return merged, frei


def _entferne_sub_in_files(files: dict, bnorm: str, sub_norm: str) -> None:
    """Verlierer-Sub aus den Sidecar-JSONs nehmen (Legacy-Lesepfad von Guide/Frontend);
    die DB trägt die umgehängten Fragen/Artefakte."""
    for key, feld in (("sidecar", "title"), ("sub_roh", None), ("question_pattern", "subblock")):
        d = _json_file(files[key])
        if not isinstance(d, dict):
            continue
        changed = False
        for bt, eintraege in d.items():
            if _norm_title(bt) != bnorm or not isinstance(eintraege, list):
                continue
            neu = [e for e in eintraege
                   if _norm_title(e if feld is None else str((e or {}).get(feld, ""))) != sub_norm]
            if len(neu) != len(eintraege):
                d[bt] = neu
                changed = True
        if changed:
            atomic_write_json(files[key], d, indent=1)
    art = _json_file(files["artefakte"])
    if isinstance(art, dict):
        neu = {t: [e for e in (es if isinstance(es, list) else [])
                   if not (_norm_title(_title(str(e.get("block", "")))) == bnorm
                           and _norm_title(str(e.get("subblock", ""))) == sub_norm)]
               for t, es in art.items()}
        if neu != art:
            atomic_write_json(files["artefakte"], neu, indent=1)


async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]:
    out = []
    frei_alle: list[str] = []
    fremd = [t for t in report.get("fremd", []) if _norm_title(t) in by_norm]
    if fremd:
        folder = source_folder(topic)
        lines = []
        for t in fremd:
            srcs = by_norm[_norm_title(t)]["payload"].get("sources") or None
            ev = _evidence_pack(folder, srcs, [t], budget=EVIDENCE_PER_BLOCK) if folder else ""
            lines.append(f"{t}\n{ev or '(keine Treffer im Material)'}")
        v, frei = await _mit_stichentscheid("QA-Repair-Beleg", topic, "fremd", "blocks", lines,
                                            "nein", kategorie="fremd",
                                            ids=[_norm_title(t) for t in fremd])
        frei_alle += frei
        for i, t in enumerate(fremd, 1):
            if v.get(i) == "nein":
                await _reject(topic, t, by_norm, files, "qa-fremd")
                out.append(t)
    unecht = [t for t in report.get("unecht", []) if _norm_title(t) in by_norm]
    if unecht:
        lines = [f"{t} — {by_norm[_norm_title(t)]['payload'].get('description') or '(ohne Beschreibung)'}"
                 for t in unecht]
        v, frei = await _mit_stichentscheid("QA-Bausteine", topic, "unecht", "blocks", lines,
                                            "nein", kategorie="unecht",
                                            ids=[_norm_title(t) for t in unecht])
        frei_alle += frei
        for i, t in enumerate(unecht, 1):
            if v.get(i) == "nein":
                await _reject(topic, t, by_norm, files, "qa-unecht")
                out.append(t)
    return out, frei_alle


async def _raeume_waisen(topic: str) -> int:
    """Artefakte/Fragen mit totem Ziel löschen (Sub verworfen oder weg) — inert, der
    Übungs-Join spielt sie nie aus, aber sie drücken die Artefakt-Note. Mehrdeutige
    Präfix-Treffer bleiben (könnten lebend sein — Löschen wäre riskanter als behalten)."""
    lebt = {(r["block_norm"], r["sub_norm"]) for r in await db.list_subblocks(topic)
            if r["status"] != "discarded"}

    def tot(bn: str, sn: str) -> bool:
        if (bn, sn) in lebt:
            return False
        return not any(b == bn and s.startswith(sn + ":") for b, s in lebt)

    n = 0
    for r in await db.get_sub_artefakte(topic):
        if tot(r["block_norm"], r["sub_norm"]):
            await db.delete_artefakt_row(topic, r["block_norm"], r["sub_norm"], r["type"])
            n += 1
    for r in await db.list_question_pattern(topic):
        if tot(r["block_norm"], r["sub_norm"]):
            await db.delete_frage_row(topic, r["block_norm"], r["sub_norm"])
            n += 1
    return n


async def _reject(topic: str, title: str, by_norm: dict, files: dict, grund: str) -> None:
    norm = _norm_title(title)
    card = by_norm.pop(norm, None)
    if not card:
        return
    p = dict(card["payload"])
    p["reason"] = grund
    await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
    await db.kanban_advance(topic, "inventory", card["card_id"], "rejected")
    await _purge_block(topic, title, files)


async def _purge_block(topic: str, title: str, files: dict) -> None:
    """Abgeleitete Daten eines Blocks gezielt entfernen (DB-Spiegel, Board-2-Karte, Sidecars)."""
    norm = _norm_title(title)
    await db.set_block_status(topic, norm, "discarded")
    await db.delete_subblocks(topic, norm)
    await db.delete_question_pattern(topic, norm)
    await db.delete_sub_artefakte(topic, norm)
    await db.kanban_delete_card(topic, "artefacts", norm)
    for key in ("sidecar", "facts", "question_pattern", "sub_roh"):
        d = _json_file(files[key])
        if isinstance(d, dict):
            hits = [k for k in d if _norm_title(k) == norm]
            if hits:
                for k in hits:
                    d.pop(k)
                atomic_write_json(files[key], d, indent=1)
    art = _json_file(files["artefakte"])
    if isinstance(art, dict):
        neu = {t: [e for e in (es if isinstance(es, list) else [])
                   if _norm_title(_title(str(e.get("block", "")))) != norm]
               for t, es in art.items()}
        if neu != art:
            atomic_write_json(files["artefakte"], neu, indent=1)


def _rename_in_files(files: dict, norm: str, neu: str) -> None:
    """Titel-Keys der Sidecar-JSONs + artefakte-`block`-Felder auf den bereinigten Titel."""
    for key in ("sidecar", "facts", "question_pattern", "sub_roh"):
        d = _json_file(files[key])
        if isinstance(d, dict):
            hits = [k for k in d if _norm_title(k) == norm and k != neu]
            if hits:
                for k in hits:
                    d[neu] = d.pop(k)
                atomic_write_json(files[key], d, indent=1)
    art = _json_file(files["artefakte"])
    if isinstance(art, dict):
        changed = False
        for es in art.values():
            for e in es if isinstance(es, list) else []:
                if _norm_title(_title(str(e.get("block", "")))) == norm and e.get("block") != neu:
                    e["block"] = neu
                    changed = True
        if changed:
            atomic_write_json(files["artefakte"], art, indent=1)
