#!/usr/bin/env python3
"""bullpen-verify — den laufenden bullpen gegen deploy/roster.yaml halten.

WOZU. Das Architekturmanifest nennt als staerkstes Argument fuer einen
deklarativen Erbauer nicht die Bequemlichkeit, sondern die Sichtbarkeit: zwei
stille Drifts (der Kollaps der Zwei-Endpunkt-Architektur und das abgeklemmte
Lease-Tor) blieben monatelang unbemerkt, weil nichts sie ANGEZEIGT hat. Dieses
Werkzeug macht Drift zu einem Diff.

Es AENDERT NICHTS. Wer den Aufbau herstellen will, benutzt `bullpen-up`; dies
hier sagt nur, wo Soll und Ist auseinanderlaufen — und ist damit auch die
Abnahme fuer jeden kuenftigen Erbauer-Lauf.

Rueckgabe: 0 wenn Soll == Ist, sonst 1. Damit ist es in einer Unit oder einem
Timer benutzbar, ohne dass jemand die Ausgabe lesen muss.

Aufruf:  pen-verify [--roster deploy/roster.yaml] [--host hertz]
"""
import argparse
import json
import os
import re
import subprocess
import sys

try:
    import yaml
except ImportError:
    sys.exit("pen-verify braucht python3-yaml")


def lauf(host, argv, timeout=60):
    """Ein Kommando auf dem Wirt, ueber sic. Gibt (rc, stdout) zurueck."""
    p = subprocess.run(["sic", host, "sh", "-c", argv],
                       capture_output=True, text=True, timeout=timeout)
    return p.returncode, (p.stdout or "").strip()


class Bericht:
    def __init__(self):
        self.abweichungen = []
        self.geprueft = 0

    def pruefe(self, gegenstand, soll, ist, hinweis=""):
        self.geprueft += 1
        if str(soll) == str(ist):
            print(f"  [ok ] {gegenstand:<38} {ist}")
        else:
            self.abweichungen.append((gegenstand, soll, ist, hinweis))
            zusatz = f"  ({hinweis})" if hinweis else ""
            print(f"  [!! ] {gegenstand:<38} soll={soll}  ist={ist}{zusatz}")



def vorflug(b, P, eltern, kinder, host):
    """Pre-flight: is the pen able to CARRY a campaign right now?

    Distinct from the roster comparison above, which asks whether the plant
    matches its description. This asks whether the plant can work — and every
    check below is a cause that killed a Phase B run on 2026-08-08:

      * a checkout 21 commits behind, so agents followed instructions that
        named paths abolished hours earlier;
      * LMCP_PROBE_URL unset, so a correct implementation went red on 14 of 15
        checks passing;
      * bullpen-attest present but on no PATH, so a tester reported the tool as
        missing (a correct measurement, a wrong conclusion);
      * eleven worker daemons still running the pre-migration library, so they
        accepted a dispatch, worked, and were refused on every post — the
        cursor advancing all the same. No ack, no reply, no error.

    That last one is why the service/file comparison exists: a file on disk
    being current says nothing about the process that read it at startup.
    """
    kopf = lauf(host, "git -C ~/src/bullpen rev-parse HEAD")[1]
    namen = [k["name"] for k in kinder]

    print()
    print("== Vorflug: traegt die Anlage eine Kampagne? ==")

    # 1. checkouts
    for name in namen:
        rc, h = lauf(host, f"{P} exec {name} -- git -C /opt/bullpen-src rev-parse HEAD")
        b.pruefe(f"{name}: checkout auf main", kopf[:8], (h or "-")[:8],
                 "git bundle hinein und --ff-only mergen")

    # 2./3. the anchor: set, and actually answering
    for k in kinder:
        if not k.get("braucht_anker", True):
            continue          # declared exemption, reason lives in the roster
        rc, treffer = lauf(host, f"{P} exec {k['name']} -- sh -c "
                                 "'grep -c LMCP_PROBE_URL /etc/bullpen-room.env 2>/dev/null || echo 0'")
        b.pruefe(f"{k['name']}: LMCP_PROBE_URL gesetzt", "1", treffer or "0",
                 "ohne Verankerung sind 14/15 gruen und die Suite trotzdem rot")

    probe = ("import json,pathlib,urllib.request;"
             "e=dict(l.split('=',1) for l in "
             "pathlib.Path('/etc/bullpen-room.env').read_text().split() if '=' in l);"
             "b=json.dumps({'jsonrpc':'2.0','id':1,'method':'initialize','params':"
             "{'protocolVersion':'2025-06-18','capabilities':{},"
             "'clientInfo':{'name':'preflight','version':'0'}}}).encode();"
             "r=urllib.request.Request(e['LMCP_PROBE_URL'],data=b,headers={"
             "'Content-Type':'application/json','Accept':'application/json, text/event-stream',"
             "'Authorization':'Bearer '+e.get('LMCP_PROBE_TOKEN','')});"
             "raw=urllib.request.urlopen(r,timeout=20).read().decode();"
             "raw=[l[6:] for l in raw.splitlines() if l.startswith('data: ')][0] "
             "if 'data: ' in raw else raw;"
             "print(json.loads(raw)['result']['protocolVersion'])")
    ziel = next((n for n in namen if n.startswith("testdesign")), namen[0])
    rc, v = lauf(host, f"{P} exec {ziel} -- python3 -c \"{probe}\"", timeout=90)
    # Compare the SHAPE, not a prose placeholder: any YYYY-MM-DD is an answer.
    # The first version of this line asserted the literal string "eine Fassung"
    # and therefore flagged a perfectly working anchor.
    geantwortet = bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", (v or "").strip()))
    b.pruefe("Verankerung antwortet", "ja", "ja" if geantwortet else "NEIN",
             f"aus {ziel} gegen den Raum: {v or 'stumm'}")

    # 4. the tools reachable BY NAME, not just present
    for prog in ("bullpen-attest", "bullpen-verify"):
        for name in namen:
            rc, wo = lauf(host, f"{P} exec {name} -- sh -c "
                                f"'command -v {prog} || echo -'")
            b.pruefe(f"{name}: {prog} im PATH", "gefunden",
                     "gefunden" if wo != "-" else "NICHT im PATH",
                     "Datei da reicht nicht — der Agent ruft sie beim Namen")

    # 5. per-nick secrets: each container its own, the room all of them
    for k in kinder:
        erwartet = k.get("nicks") or []
        if not erwartet:
            continue
        rc, da = lauf(host, f"{P} exec {k['name']} -- sh -c "
                            "'ls /etc/bullpen/post-secret.d/ 2>/dev/null | tr \"\\n\" \" \"'")
        fehlend = [n for n in erwartet if n not in (da or "").split()]
        b.pruefe(f"{k['name']}: eigene Geheimnisse", "vollstaendig",
                 "vollstaendig" if not fehlend else f"fehlt: {' '.join(fehlend)}")

    # 6. THE ONE THAT WAS MISSING: process older than the code it should run
    print()
    print("== Vorflug: laeuft irgendwo Code von gestern? ==")
    for name in namen:
        rc, lib = lauf(host, f"{P} exec {name} -- sh -c "
                             "'stat -c %Y /opt/bullpen-src/lib/bullpen_worker.py 2>/dev/null || echo 0'")
        rc, units = lauf(host, f"{P} exec {name} -- sh -c "
                               "'systemctl list-units --type=service --state=running "
                               "--no-legend \"bullpen-*\" 2>/dev/null | awk \"{print \\$1}\"'")
        for u in [z for z in (units or "").split() if z]:
            rc, ts = lauf(host, f"{P} exec {name} -- sh -c "
                                f"'date -d \"$(systemctl show {u} -p ActiveEnterTimestamp --value)\" +%s "
                                "2>/dev/null || echo 0'")
            alt = ts.isdigit() and lib.isdigit() and int(ts) < int(lib) and int(ts) > 0
            b.pruefe(f"{name}/{u.replace('.service','')}", "Code aktuell",
                     "VOR der Bibliothek gestartet" if alt else "Code aktuell",
                     "neu starten — sonst laeuft die Datei neu und der Prozess alt")


def main():
    ap = argparse.ArgumentParser()
    # Im Paket liegt das Roster unter /usr/share/bullpen/deploy, in einer
    # Arbeitskopie daneben. Beides ohne Argument finden — ein Werkzeug, das
    # man mit einem Pfad fuettern muss, benutzt im Zweifel niemand.
    vorgabe = next((p for p in ("deploy/roster.yaml",
                                "/usr/share/bullpen/deploy/roster.yaml")
                    if os.path.isfile(p)), "deploy/roster.yaml")
    ap.add_argument("--roster", default=vorgabe)
    ap.add_argument("--host", default="hertz")
    # Der Vorflug ist NICHT vorgabemaessig an: er stellt Fragen an jeden
    # laufenden Dienst in jedem Container und dauert entsprechend. Vor einer
    # Kampagne ist er die billigste Minute des Tages.
    ap.add_argument("--preflight", action="store_true",
                    help="zusaetzlich pruefen, ob die Anlage eine Kampagne traegt")
    a = ap.parse_args()

    r = yaml.safe_load(open(a.roster, encoding="utf-8"))
    eltern, kinder = r["eltern"], r["kinder"]
    b = Bericht()
    P = f"incus exec {eltern['name']} -- incus"

    print(f"== Eltern: {eltern['name']} auf {eltern['wirt']} ==")
    for schluessel, feld in (("limits.memory", "limits_memory"),
                             ("security.nesting", "security_nesting"),
                             ("security.idmap.size", "security_idmap_size"),
                             ("boot.autostart", "boot_autostart")):
        rc, ist = lauf(a.host, f"incus config get {eltern['name']} {schluessel}")
        soll = eltern[feld]
        b.pruefe(f"eltern {schluessel}", str(soll).lower(), (ist or "-").lower())

    rc, pool = lauf(a.host, f"incus config show {eltern['name']} | sed -n 's/^ *pool: //p' | head -1")
    b.pruefe("eltern pool", eltern["pool"], pool or "-")

    print()
    print("== Kinder ==")
    rc, vorhanden = lauf(a.host, f"{P} list -c ns --format csv")
    ist_zustand = dict(z.split(",", 1) for z in vorhanden.splitlines() if "," in z)
    b.pruefe("anzahl kinder", len(kinder), len(ist_zustand))

    for k in kinder:
        name = k["name"]
        print(f"  -- {name} --")
        b.pruefe(f"{name}: existiert und laeuft", "RUNNING", ist_zustand.get(name, "FEHLT"))
        if name not in ist_zustand:
            continue
        rc, mem = lauf(a.host, f"{P} config get {name} limits.memory")
        b.pruefe(f"{name}: limits.memory", k["limits_memory"], mem or "-")
        rc, auto = lauf(a.host, f"{P} config get {name} boot.autostart")
        b.pruefe(f"{name}: boot.autostart", str(k["boot_autostart"]).lower(),
                 (auto or "-").lower(), k.get("abweichung", "")[:60])

        # Geheimnisse: genau die Nicks, die das Roster diesem Container gibt.
        soll_nicks = sorted(k.get("nicks", []))
        if k.get("geheimnisse") == "alle":
            # Plus the senders that live OUTSIDE the pen but must be verifiable
            # inside it (the operator). They have no container, so they are not
            # anybody's child nick — but the room has to hold their secret or
            # their posts go unstamped and every lurker discards them (R4).
            soll_nicks = sorted([n for kk in kinder for n in kk.get("nicks", [])]
                                + list(r.get("externe_nicks", [])))
        rc, ist_nicks = lauf(a.host, f"{P} exec {name} -- sh -c 'ls /etc/bullpen/post-secret.d 2>/dev/null'")
        b.pruefe(f"{name}: geheimnisse", ",".join(soll_nicks),
                 ",".join(sorted(ist_nicks.split())) or "-")

        # Units: aktiviert, nicht bloss vorhanden.
        soll_units = sorted(k.get("units", []))
        if soll_units:
            rc, ist_units = lauf(
                a.host,
                f"{P} exec {name} -- sh -c \"systemctl list-unit-files --state=enabled --no-legend 2>/dev/null "
                f"| awk '{{print \\$1}}' | grep -E '^(bullpen|bullseye|pi-web|lmcp|caddy)' | sort | tr '\\n' ' '\"")
            fehlend = [u for u in soll_units if u not in ist_units.split()]
            b.pruefe(f"{name}: units aktiviert", "keine fehlen",
                     "keine fehlen" if not fehlend else f"fehlt: {' '.join(fehlend)}")

    # Der Raum ist der einzige Ort, an dem eine Invariante messbar ist, die
    # sich nicht aus der Konfiguration ergibt: welche Werkzeuge er anbietet.
    raum = next((k for k in kinder if k.get("lmcp_tool_allow")), None)
    if raum:
        print()
        print("== Raum: Werkzeugliste (die Invariante hinter B6) ==")
        rc, allow = lauf(a.host, f"{P} exec {raum['name']} -- sh -c "
                                 f"'systemctl show lmcp -p Environment --value | tr \" \" \"\\n\" "
                                 f"| sed -n \"s/^LMCP_TOOL_ALLOW=//p\"'")
        b.pruefe("room: LMCP_TOOL_ALLOW",
                 ",".join(raum["lmcp_tool_allow"]), allow or "NICHT GESETZT",
                 "ohne die Liste hat jeder Token-Halter eine Wurzelschale im Raum")

    # Speicherdruck: GEMESSEN, nicht aus Deckeln addiert. Deckel sind
    # Sicherungen je Container; ihre Summe ist eine Buchhaltungsgroesse, weil
    # neun Container auf einem Wirt Seitencache, Bibliotheken und COW-Seiten
    # teilen. Entschieden wurde gegen eine Zulassungssteuerung -- dafuer muss
    # der Druck sichtbar sein.
    print()
    print("== Speicher: belegt gegen Deckel (gemessen, nicht addiert) ==")
    for k in kinder:
        rc, roh = lauf(a.host, f"{P} info {k['name']} 2>/dev/null | awk '/Memory .current./{{print $3$4}}'")
        print(f"  {k['name']:<12} belegt={roh or '?':<12} Deckel={k['limits_memory']}")
    rc, eltern_ist = lauf(a.host, f"incus info {eltern['name']} 2>/dev/null "
                                  f"| awk '/Memory .current./{{print $3$4}}'")
    print(f"  {'ELTERN':<12} belegt={eltern_ist or '?':<12} Deckel={eltern['limits_memory']}")
    rc, frei = lauf(a.host, "free -m | awk '/^Mem:/{print $7\" MB verfuegbar\"}'")
    print(f"  {'WIRT':<12} {frei}")

    if a.preflight:
        vorflug(b, P, eltern, kinder, a.host)

    print()
    print(f"{len(b.abweichungen)} Abweichungen bei {b.geprueft} Pruefungen")
    if b.abweichungen:
        print()
        print("Soll und Ist laufen auseinander. Das ist der Befund, nicht der Fehler —")
        print("entweder das Roster luegt oder der Aufbau ist gedriftet. Beides gehoert")
        print("entschieden, nicht weggeklickt.")
    return 1 if b.abweichungen else 0


sys.exit(main())
