#!/usr/bin/env python3
"""Hermes-Runtime-Patch-Traeger — haelt MC2-eigene Fixes im Hermes-Quellcode
(``~/.hermes/hermes-agent``), damit sie ein ``hermes update`` ueberleben.

Warum: Der Hermes-Agent wird aus git aktualisiert; ein Direkt-Edit der Laufzeit
wuerde beim naechsten Update still ueberschrieben. Dieses Skript wird von
``deploy/deploy.sh`` UND ``deploy/hermes-postcheck.sh`` aufgerufen und traegt die
Patches (re-)ein.

Mechanik: JEDER Patch ist eine idempotente String-Ersetzung (robust gegen
Zeilenverschiebungen durch Updates, anders als ``git apply``):
  * ``marker`` schon im Quelltext  -> Patch bereits drin, ueberspringen.
  * ``old`` genau 1x vorhanden      -> ersetzen, dann ``py_compile`` pruefen.
  * ``old`` 0x oder >1x             -> Kontext hat sich durch ein Update
                                       geaendert -> LAUT scheitern (Exit 1), damit
                                       der Postcheck rot wird und niemand denkt,
                                       der Fix sei noch aktiv.

Bei Fehlschlag: Exit 1 (der Deploy/Postcheck behandelt das als Fehler).
"""
import os
import py_compile
import shutil
import sys
from pathlib import Path

HA = Path(os.environ.get("HERMES_AGENT_DIR", str(Path.home() / ".hermes" / "hermes-agent")))
HERE = Path(__file__).resolve().parent

# --- Zusatzmodule, die in die Hermes-Laufzeit kopiert werden (update-fest) ---
# (source relativ zu diesem Skript, dest relativ zum Hermes-Agent-Dir)
COPIES = [
    ("mc2_kanban_reaper.py", "hermes_cli/mc2_kanban_reaper.py"),
]

# --- Patch-Definitionen (chronologisch, Fan-out-/Muenz-Schleifen-Fixes 20.07.2026) ---
PATCHES = [
    {
        "name": "0001-needs-input-wait",
        "file": "hermes_cli/kanban_db.py",
        "marker": 'kind not in ("needs_input", "capability")',
        "old": "        if recurrences >= BLOCK_RECURRENCE_LIMIT:\n",
        "new": (
            "        # MC2-Patch (20.07.2026): needs_input/capability sind genuin\n"
            "        # human-only — sie muessen in ``blocked`` auf den Commander warten\n"
            "        # (Antwort via Auftragsbuch), NICHT nach triage eskalieren. Sonst\n"
            "        # zerlegt der Auto-Decomposer sie neu = Duplikat-Karten-Schleife\n"
            '        # ("Muenz-Schleife", Fan-out-Vorfall 20.07.). Der Loop-Breaker bleibt\n'
            "        # fuer transient/untyped Blocks aktiv.\n"
            '        if recurrences >= BLOCK_RECURRENCE_LIMIT and kind not in ("needs_input", "capability"):\n'
        ),
    },
    {
        "name": "0002-decompose-at-readiness",
        "file": "hermes_cli/kanban_decompose.py",
        "marker": "Startreife-Gate",
        "old": (
            "    cfg = _load_config()\n"
            "    orchestrator = _resolve_orchestrator_profile(cfg)\n"
        ),
        "new": (
            "    # MC2-Patch (20.07.2026): Startreife-Gate. Eine triage-Karte mit noch\n"
            "    # OFFENEN Vorgaengern NICHT zerlegen — sonst raet der Decomposer die\n"
            "    # Schnittstellen der Vor-Etappe ins Blaue, bevor die gebaut hat\n"
            "    # (Fan-out-Vorfall 20.07.). Billiger Parent-Check VOR dem teuren\n"
            "    # LLM-Aufruf; erst zerlegen, wenn alle Eltern done/archived.\n"
            "    with kb.connect_closing() as _pconn:\n"
            "        _parents = [r[0] for r in _pconn.execute(\n"
            '            "SELECT parent_id FROM task_links WHERE child_id = ?", (task_id,))]\n'
            "        if _parents:\n"
            '            _ph = ",".join("?" * len(_parents))\n'
            "            _open = _pconn.execute(\n"
            '                "SELECT COUNT(*) FROM tasks WHERE id IN (%s) "\n'
            "                \"AND status NOT IN ('done', 'archived')\" % _ph, _parents\n"
            "            ).fetchone()[0]\n"
            "            if _open:\n"
            "                return DecomposeOutcome(\n"
            "                    task_id, False,\n"
            '                    f"defer: {_open} unfinished parent(s) — decompose when start-ready",\n'
            "                )\n"
            "    cfg = _load_config()\n"
            "    orchestrator = _resolve_orchestrator_profile(cfg)\n"
        ),
    },
    {
        "name": "0003-orphan-guard",
        "file": "gateway/kanban_watchers.py",
        "marker": "mc2_kanban_reaper",
        "old": (
            "                pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
            "                if pids:\n"
            "                    logger.info(\n"
            '                        "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
            "                        len(pids),\n"
            "                        pids,\n"
            "                    )\n"
        ),
        "new": (
            "                pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
            "                if pids:\n"
            "                    logger.info(\n"
            '                        "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
            "                        len(pids),\n"
            "                        pids,\n"
            "                    )\n"
            "                # MC2-Patch (20.07.2026): Orphan-Guard. Beende lebende\n"
            "                # Worker, deren Task nicht mehr 'running' ist (extern\n"
            "                # blockiert/reset) — sonst unterlaufen sie max_in_progress\n"
            "                # und verbrennen Rechenzeit.\n"
            "                try:\n"
            "                    from hermes_cli import mc2_kanban_reaper as _mc2reaper\n"
            "                    _orph = await asyncio.to_thread(\n"
            "                        _mc2reaper.reap_orphaned_workers, _kb.connect_closing\n"
            "                    )\n"
            "                    if _orph:\n"
            "                        logger.info(\n"
            '                            "kanban dispatcher: reaped %d orphaned worker(s): %s",\n'
            "                            len(_orph), _orph,\n"
            "                        )\n"
            "                except Exception:\n"
            '                    logger.exception("kanban dispatcher: orphan reaper failed")\n'
        ),
    },
    {
        "name": "0004a-context-inherit-compute",
        "file": "hermes_cli/kanban_decompose.py",
        "marker": '_parent_ctx = ""',
        "old": (
            "    children: list[dict] = []\n"
            "    for idx, entry in enumerate(raw_tasks):\n"
        ),
        "new": (
            "    children: list[dict] = []\n"
            "    # MC2-Patch (20.07.2026): Projekt-/Repo-Kontext vererben. Der Parent-Body\n"
            '    # traegt oft harte Randbedingungen (Ziel-Repo, Tabus, "eigenes Projekt"),\n'
            "    # die bei der Zerlegung sonst verloren gehen (Worker klonte das falsche\n"
            "    # Repo, Fan-out-Vorfall 20.07.). Jeder Kind-Body bekommt den voran.\n"
            '    _parent_ctx = ""\n'
            '    if getattr(task, "body", None) and task.body.strip():\n'
            "        _parent_ctx = (\n"
            '            "## PROJEKT-KONTEXT (von der uebergeordneten Aufgabe — gilt fuer dich)\\n"\n'
            "            + task.body.strip()[:1500]\n"
            '            + "\\n\\n## DEIN TEILAUFTRAG\\n"\n'
            "        )\n"
            "    for idx, entry in enumerate(raw_tasks):\n"
        ),
    },
    {
        "name": "0004b-context-inherit-apply",
        "file": "hermes_cli/kanban_decompose.py",
        "marker": "_parent_ctx + body",
        "old": (
            '        body = entry.get("body")\n'
            "        if not isinstance(body, str):\n"
            '            body = ""\n'
        ),
        "new": (
            '        body = entry.get("body")\n'
            "        if not isinstance(body, str):\n"
            '            body = ""\n'
            "        body = _parent_ctx + body  # MC2 (20.07.): Projekt-Kontext vererben\n"
        ),
    },
    {
        "name": "0005-create-chain-guard",
        "file": "tools/kanban_tools.py",
        "marker": "_MC2_RECENT_ROOTS",
        "old": (
            "            new_task = kb.get_task(conn, new_tid)\n"
            "            subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
        ),
        "new": (
            "            # MC2-Patch (20.07.2026): Etappen-Auto-Kette. Legt dieselbe\n"
            "            # Session mehrere ROOT-triage-Karten (ohne parents) im selben\n"
            "            # 120s-Fenster an, verkette sie — so greift das Startreife-Gate\n"
            "            # (Karte 2 wartet auf Karte 1) statt alle sofort zu zerlegen\n"
            "            # (Fan-out 20.07.). Unter max_in_progress:1 ist eine Fehl-\n"
            "            # Verkettung harmlos (laeuft eh seriell).\n"
            "            try:\n"
            "                if triage and not parents and session_id:\n"
            "                    import time as _mc2t\n"
            '                    _roots = globals().setdefault("_MC2_RECENT_ROOTS", {})\n'
            "                    _prev = _roots.get(session_id)\n"
            "                    _now = _mc2t.monotonic()\n"
            "                    if _prev and (_now - _prev[1]) <= 120 and _prev[0] != new_tid:\n"
            "                        try:\n"
            "                            kb.link_tasks(conn, _prev[0], new_tid)\n"
            "                        except Exception:\n"
            "                            pass\n"
            "                    _roots[session_id] = (new_tid, _now)\n"
            "                    if len(_roots) > 64:\n"
            "                        _roots.clear()\n"
            "            except Exception:\n"
            "                pass\n"
            "            new_task = kb.get_task(conn, new_tid)\n"
            "            subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
        ),
    },
]


def main() -> int:
    if not HA.is_dir():
        print(f"hermes-patches: Hermes-Agent-Dir fehlt ({HA}) — nichts zu tun.")
        return 0
    applied, skipped, failed = [], [], []

    # --- Zusatzmodule kopieren (idempotent: nur wenn Inhalt abweicht) ---
    for src_rel, dst_rel in COPIES:
        src, dst = HERE / src_rel, HA / dst_rel
        if not src.is_file():
            failed.append((src_rel, f"Quelle fehlt: {src}"))
            continue
        try:
            same = dst.is_file() and dst.read_bytes() == src.read_bytes()
            if same:
                skipped.append(f"copy:{dst_rel}")
                continue
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(src, dst)
            if dst.suffix == ".py":
                py_compile.compile(str(dst), doraise=True)
            applied.append(f"copy:{dst_rel}")
        except Exception as exc:
            failed.append((src_rel, f"Kopie/Compile fehlgeschlagen: {exc}"))
    for p in PATCHES:
        f = HA / p["file"]
        if not f.is_file():
            failed.append((p["name"], f"Datei fehlt: {f}"))
            continue
        src = f.read_text(encoding="utf-8")
        if p["marker"] in src:
            skipped.append(p["name"])
            continue
        n = src.count(p["old"])
        if n != 1:
            failed.append((p["name"], f"erwartete 1 Vorkommen von old, fand {n} "
                                      "(Update hat den Kontext geaendert?)"))
            continue
        f.write_text(src.replace(p["old"], p["new"]), encoding="utf-8")
        try:
            py_compile.compile(str(f), doraise=True)
        except Exception as exc:
            failed.append((p["name"], f"py_compile fehlgeschlagen: {exc}"))
            continue
        applied.append(p["name"])

    print(f"hermes-patches: applied={applied} skipped={skipped} failed={[n for n, _ in failed]}")
    for name, why in failed:
        print(f"  FEHLER {name}: {why}", file=sys.stderr)
    if failed:
        return 1        # Fehler → Deploy/Postcheck rot
    if applied:
        return 10       # etwas geaendert → Aufrufer sollte hermes-gateway neu starten
    return 0            # nichts zu tun (alles schon drin)


if __name__ == "__main__":
    sys.exit(main())
