"""bullpen_worker — shared reactive-worker harness for bullpen.

A worker is just: a nick + a `dispatch(msg) -> reply_body` function.

    import sys; sys.path.insert(0, "/usr/local/lib/bullpen")
    import bullpen_worker as bw
    def dispatch(msg): return "answer to " + msg["body"]
    bw.run("mynick", dispatch)

The harness owns everything reactive: polling, @-addressing, filtering out its own /
ack / reply / system messages, persisting a per-nick `since` offset, and posting the
`ack` (on receipt) + the `reply` (dispatch's return). dispatch does only the real work
and returns the reply text. It receives the full message dict (so it can use `id`, etc.).

Reliability (reviewer findings #2/#4/#5):
  - #2: room_say rc is checked; the offset is NOT advanced past a message whose reply
        failed to post, so a transient lmcp/sic failure retries instead of silently dropping.
  - #4: a missing/corrupt offset seeds from the NEWEST room id (skip backlog), never 0
        (which replayed — re-acked + re-dispatched — the entire history).
  - #5: the offset is advanced AND persisted per fully-handled message, so a restart
        mid-batch resumes exactly, never re-dispatching completed work.
"""
import json, os, subprocess, sys, time
import bullpen_config as cfg

ROOM_DIR = cfg.STATE_DIR

# R4: the room's :8080 gate REJECTS a PRIVILEGED nick that posts without the shared secret
# (bullpen.lua). The one-shot worker family (dispatcher/callboy/…) is now PRIVILEGED so its
# replies stamp verified:true and orchestrators' lurkers will accept them — which means every
# post MUST carry the secret. Read it once (workers run as root; /etc/bullpen/post-secret is
# root-readable); empty is fine for a non-privileged nick (the gate ignores the arg then).
def _post_secret(nick=None):
    """Das Geheimnis DIESES Nicks.

    Bis 2026-08-08 lasen alle Beteiligten dieselbe Datei. Damit bewies
    `verified:true` nur "irgendwer mit dem Geheimnis": aus einem fremden
    Container liess sich als jeder privilegierte Nick posten (gemessen:
    id 175, from testdesigner, abgesetzt aus dem deus-Container).

    Jetzt zuerst die nick-eigene Datei. Die alten Pfade bleiben als Rueckfall,
    damit ein Aufbau ohne post-secret.d unveraendert weiterlaeuft -- die
    Raumseite entscheidet ohnehin danach, ob sie den Nachweis verlangt.
    """
    pfade = []
    if nick:
        pfade.append(f"/etc/bullpen/post-secret.d/{nick}")
        pfade.append(os.path.expanduser(f"~/.config/bullpen/post-secret.d/{nick}"))
    pfade += ["/etc/bullpen/post-secret", os.path.expanduser("~/.config/bullpen/post-secret")]
    for p in pfade:
        try:
            s = open(p).read().strip()
            if s:
                return s
        except OSError:
            pass
    return ""
# Nick-abhaengig: erst in run() aufgeloest (siehe unten).
POST_SECRET = ""

def lmcp(tool, **kw):
    return subprocess.run(["lmcp-tool", tool] + [f"{k}={v}" for k, v in kw.items()],
                          capture_output=True, text=True)

def _ok(cp):
    return cp is not None and cp.returncode == 0

def _read_since(n):
    out = []
    cp = lmcp("room_read", since=n)
    if not _ok(cp):
        return None            # distinguish "read failed" from "no new messages" ([])
    for line in cp.stdout.splitlines():
        line = line.strip()
        if line:
            try: out.append(json.loads(line))
            except Exception: pass
    return out

def _newest_id():
    msgs = _read_since(0)
    return max([m.get("id", 0) for m in msgs], default=0) if msgs else 0

# 0,1 Hz. Sieben dieser Daemons pollen denselben Room; bei 2,0/2,5 s waren das rund
# drei Anfragen pro Sekunde (jeder Poll oeffnet zwei TCP-Verbindungen). Der Offset-Index
# in room_read hat den Preis pro Poll zwar praktisch auf null gedrueckt, aber sieben
# Prozesse, die alle zwei Sekunden aufwachen, bleiben auf einem Pi Grundrauschen.
# Preis dieser Zeile: bis zu 10 s laengere Aufgreifzeit je Anfrage. Uebersteuerbar
# per BULLPEN_POLL_SECONDS, ohne den Code anzufassen.
POLL_SECONDS = float(os.getenv("BULLPEN_POLL_SECONDS", "10.0"))


def run(nick, dispatch, online=None, ack="…on it", poll=POLL_SECONDS):
    global POST_SECRET
    # Jetzt erst ist der Nick bekannt -- vorher kann kein nick-eigenes
    # Geheimnis gefunden werden.
    POST_SECRET = _post_secret(nick)
    since_file = f"{ROOM_DIR}/{nick}.since"

    def say(body, to="", typ="chat", in_reply_to=None):
        kw = {"from": nick, "body": body, "type": typ}
        if to: kw["to"] = to
        if in_reply_to: kw["in_reply_to"] = in_reply_to
        if POST_SECRET: kw["secret"] = POST_SECRET  # R4: privileged nicks must present the secret
        return _ok(lmcp("room_say", **kw))          # #2: report whether the post landed

    def addressed(m):
        return (m.get("to") or "").lstrip("@").lower() == nick

    def persist(v):
        try: open(since_file, "w").write(str(v))
        except Exception as e: sys.stderr.write(f"{nick}: since persist failed: {e}\n")

    # #4: missing/corrupt offset -> resume from the newest id (skip backlog), never replay from 0
    try:
        since = int(open(since_file).read().strip())
    except Exception:
        since = _newest_id()
        persist(since)

    say(online or f"{nick} online", typ="system")
    while True:
        try:
            batch = _read_since(since)
            if batch is None:                        # room_read failed — back off, do NOT reset
                time.sleep(poll); continue
            for m in batch:
                mid = m.get("id", since)
                if mid <= since:
                    continue
                if m.get("from") == nick or m.get("type") in ("ack", "reply", "system") or not addressed(m):
                    since = mid; persist(since); continue     # #5: advance+persist per message
                rid, asker = mid, m.get("from", "")
                say(ack, to=f"@{asker}", typ="ack", in_reply_to=rid)
                try:
                    body = dispatch(m)
                except Exception as e:
                    body = f"error: {e}"
                # #2/#3: only step past this message once its reply actually posts; on a failed
                # post leave `since` put so the next loop retries — never a silent drop.
                if say(body or "(no result)", to=f"@{asker}", typ="reply", in_reply_to=rid):
                    since = mid; persist(since)
                else:
                    sys.stderr.write(f"{nick}: reply post FAILED for id {rid}; retrying next loop\n")
                    break
        except Exception as e:
            sys.stderr.write(f"{nick} loop error: {e}\n")
        time.sleep(poll)
