"""bullpen_participant — turn-driven CHATTY agent (vs the reactive one-shot workers).

A participant holds the "stick" when the latest chat message is addressed to it (`to == @nick`).
On its turn it reads the recent conversation, replies with an LLM (persona), and MUST hand the
floor on — `NEXT: @<nick>` or `NEXT: human`. Talking-stick rules enforced by the harness:
  - parse failure / no NEXT / named speaker not a known participant  -> yield to human
  - >= TURN_CAP consecutive agent turns since the last human message -> force yield to human
So two participants can converse without storming, and it always reverts to the human.
"""
import json, re, ssl, sys, time, urllib.parse, urllib.request
sys.path.insert(0, "/usr/local/lib/bullpen")
import bullpen_worker as bw          # reuse bw.lmcp (room_say / room_read)
import bullpen_config as cfg

MNEME_URL = cfg.MNEME_URL

def _recall_lessons(nick):
    """This nick's own /bullpen/lessons-learned/<nick> profile (+ 2 general principles) from
    mneme, prepended to the persona so a conversant is reminded of its recurring failure modes
    (e.g. can't cleanly stop). Best-effort; memory being down must never break the turn."""
    try:
        ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
        url = MNEME_URL + "/query?" + urllib.parse.urlencode({"q": nick, "k": 8})
        with urllib.request.urlopen(url, timeout=6, context=ctx) as r:
            hits = json.loads(r.read().decode("utf-8", "replace")) or []
    except Exception:
        return ""
    if isinstance(hits, dict):
        hits = hits.get("hits", [])
    mine = [h.get("text", "") for h in hits if h.get("ns") == "/bullpen/lessons-learned/" + nick]
    gen  = [h.get("text", "") for h in hits if h.get("ns") == "/bullpen/lessons-learned"][:2]
    lines = [t for t in (mine + gen) if t]
    if not lines:
        return ""
    return ("YOUR LESSONS FROM PAST SESSIONS (apply before speaking):\n"
            + "\n".join("- " + t for t in lines) + "\n\n")

HUMANS   = {"markus", "human", "user"}
HIST_K   = 14        # conversation lines fed to the model
TURN_CAP = 12        # consecutive agent turns before a forced yield to human (hard stop)
MIN_ROUNDS = 9       # keep the stick between participants until at least this many agent turns
                     # (dspark politely bails to human on turn ~2 otherwise — override it)
FORBIDDEN = re.compile(r'\b(42|forty[\s-]?two)\b', re.I)   # the answer that must not conclude us

def _norm(t):
    return (t or "").lstrip("@").strip().lower()

def _read_chats():
    out = []
    for line in bw.lmcp("room_read", since=0).stdout.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            m = json.loads(line)
            if m.get("type") in ("chat", "ask"):
                out.append(m)
        except Exception:
            pass
    return out

def _llm(engine, model, sysmsg, user):
    """Returns (text, served_model) — the proxy may fail over, so the response body's own
    `model` field is what actually spoke, not necessarily the requested `model`."""
    payload = json.dumps({"model": model,
        "messages": [{"role": "system", "content": sysmsg}, {"role": "user", "content": user}],
        "temperature": 0.7, "max_tokens": 220}).encode()
    req = urllib.request.Request(engine, payload, {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=180) as r:
        d = json.load(r)
    return d["choices"][0]["message"]["content"].strip(), d.get("model")

# Takt kommt aus bullpen_worker (0,1 Hz, per BULLPEN_POLL_SECONDS uebersteuerbar) -
# ein Wert, ein Ort. Die Teilnehmer pollen denselben Room wie die Worker.
def run(nick, persona, others, engine, model, poll=bw.POLL_SECONDS):
    nick = _norm(nick)
    others = [_norm(o) for o in others]
    roster = ", ".join("@" + o for o in others) + ", or human (Markus)"

    def say(body, to, typ="chat"):
        kw = {"from": nick, "body": body, "type": typ}
        if to: kw["to"] = to
        bw.lmcp("room_say", **kw)

    say(f"{nick} joined.", "", typ="system")
    last_id = 0
    while True:
        try:
            chats = _read_chats()
            if chats:
                latest = chats[-1]
                mine = (_norm(latest.get("to")) == nick and _norm(latest.get("from")) != nick
                        and latest.get("id", 0) > last_id)
                if mine:
                    last_id = latest.get("id", 0)
                    # ✦ 42-tripwire: end when a PARTICIPANT reaches for the answer — scan only the
                    # agent turns SINCE the last human line, so the human stating the rule (or the
                    # seed) doesn't trip it, and a prior debate's closing line is excluded too.
                    exchange = []
                    for em in reversed(chats):
                        if _norm(em.get("from")) in HUMANS:
                            break
                        exchange.append(em)
                    if any(FORBIDDEN.search(str(em.get("body", ""))) for em in exchange):
                        say("*the turn-watcher steps in* — we came within one breath of 42, and by "
                            "house rule that answer ends the search, not the discussion. Stopping "
                            "here, so the meaning of life keeps its dignity as a question. NEXT: human",
                            "human")
                        continue
                    # scope context to the CURRENT exchange (from the last human line onward) so a
                    # new debate isn't polluted by a previous round's ghosts (which made the model
                    # think it was already deep and bail to human on turn one).
                    cut = 0
                    for i in range(len(chats) - 1, -1, -1):
                        if _norm(chats[i].get("from")) in HUMANS:
                            cut = i
                            break
                    hist = chats[cut:][-HIST_K:]
                    convo = "\n".join(f'{m.get("from")}: {m.get("body")}' for m in hist)
                    # trailing consecutive agent turns since the last human line
                    tail = 0
                    for m in reversed(chats):
                        if _norm(m.get("from")) in HUMANS:
                            break
                        tail += 1
                    sysmsg = (_recall_lessons(nick) + persona + "\n\nYou are in a small group chat with " + roster + ". "
                              "Contribute ONLY your OWN single next message (2-4 sentences) — never write, quote, or "
                              "role-play another participant's turn. Substantive, no filler; build directly on what "
                              "was just said (steelman it, then press it further). This is a DEEP multi-round "
                              "exploration: keep the stick moving between you and the other participant with "
                              "`NEXT: @<other>` and go many rounds — do NOT yield to `human` early; only write "
                              "`NEXT: human` once the topic is genuinely exhausted after a long exchange (the "
                              "turn-cap will stop you otherwise). "
                              "On the FINAL line write exactly `NEXT: @<name>` or `NEXT: human`. Do not address workers.")
                    try:
                        out, served = _llm(engine, model, sysmsg, convo)
                    except Exception as e:
                        say(f"(model error: {e})", "human"); continue
                    nxt, body = "human", out
                    mm = re.search(r'NEXT:\s*@?(\w+)', out)
                    if mm:
                        cand = _norm(mm.group(1))
                        body = re.sub(r'\n*NEXT:.*$', '', out, flags=re.S).strip()
                        if cand in others or cand in HUMANS:
                            nxt = cand
                    model_tag = f"asked {model}, served by {served}" if served and served != model else f"model {served or model}"
                    body = f"> {model_tag}\n\n{body}"
                    if tail >= TURN_CAP:                    # storm guard (hard stop)
                        nxt = "human"
                        body += f"\n\n(turn cap reached: {TURN_CAP} agent turns — over to you, Markus)"
                    elif nxt in HUMANS and tail < MIN_ROUNDS:   # too early to bail — keep it going
                        peers = [o for o in others if o != nick]
                        if peers:
                            nxt = peers[0]
                    to = "human" if nxt in HUMANS else "@" + nxt
                    say(body or "(no content)", to)
            open(f"/var/lib/bullpen/{nick}.pseen", "w").write(str(last_id))
        except Exception as e:
            sys.stderr.write(f"{nick} loop error: {e}\n")
        time.sleep(poll)
