#!/usr/bin/env python3
"""oc-rpc — drive a persistent opencode session over its HTTP API.

The bullpen lurker runs `opencode run` = a fresh, stateless session per poke, re-poked by every
reply to its own dispatches -> a cascade of amnesiac agents. This is the alternative primitive:
one long-lived session that any rich agent (or a human) STEERS with continuity, and can inspect
and abort. Talks straight to the opencode server (set OC_URL / BULLPEN_OC_URL), no sic hop.

  oc-rpc new [--title T] [--agent A] [--model prov/model]   create a session -> prints id + share URL
  oc-rpc say <ses> <text...>                                send a prompt (async; returns at once)
  oc-rpc wait <ses> [--secs N]                              block until the current turn goes idle
  oc-rpc read <ses> [--tail N]                              transcript (role + text/tool summaries)
  oc-rpc todo <ses>                                         the agent's current plan/todos
  oc-rpc diff <ses>                                         files changed this session
  oc-rpc abort <ses>                                        stop a runaway turn
  oc-rpc share <ses> | unshare <ses>                        mint / revoke a watch URL
  oc-rpc list [--n N]                                       recent sessions

Env: OC_URL or BULLPEN_OC_URL (default http://localhost:4096). Exit 0 ok, 1 error.
"""
import argparse, json, os, sys, time, urllib.request, urllib.error

BASE = (os.environ.get("OC_URL") or os.environ.get("BULLPEN_OC_URL") or "http://localhost:4096").rstrip("/")


def api(method, path, body=None, timeout=60):
    req = urllib.request.Request(
        BASE + path, method=method,
        headers={"Content-Type": "application/json"},
        data=json.dumps(body).encode() if body is not None else None)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            raw = r.read().decode()
    except urllib.error.HTTPError as e:
        sys.exit(f"oc-rpc: {method} {path} -> HTTP {e.code}: {e.read().decode()[:200]}")
    except Exception as e:
        sys.exit(f"oc-rpc: {method} {path} -> {type(e).__name__}: {e}")
    return json.loads(raw) if raw.strip() else {}


def _sid(o):
    return o.get("id") or (o.get("info") or {}).get("id")


def cmd_new(a):
    body = {}
    if a.title:  body["title"] = a.title
    if a.agent:  body["agent"] = a.agent
    if a.model:
        prov, _, mid = a.model.partition("/")
        body["model"] = {"providerID": prov, "modelID": mid}
    o = api("POST", "/session", body)
    sid = _sid(o)
    print(sid)
    if sid:
        try:
            s = api("POST", f"/session/{sid}/share", {})
            url = s.get("url") or s.get("share", {}).get("url")
            if url: print("share:", url)
        except SystemExit:
            pass


def cmd_say(a):
    text = " ".join(a.text)
    parts = [{"type": "text", "text": text}]
    body = {"parts": parts}
    if a.agent: body["agent"] = a.agent
    # async: fire and return, so a long turn never blocks the driver's curl.
    try:
        api("POST", f"/session/{a.ses}/prompt_async", body, timeout=30)
    except SystemExit:
        # fall back to the v1 blocking prompt if async is unavailable
        api("POST", f"/session/{a.ses}/message", body, timeout=a.secs)
    print(f"sent to {a.ses}; poll: oc-rpc read {a.ses}")


def _idle(ses):
    st = api("GET", f"/session/{ses}")
    info = st.get("info", st)
    # opencode marks a live turn via time.completed missing / a 'running' revert flag
    t = info.get("time", {})
    return bool(t.get("completed")) or info.get("idle", True)


def cmd_wait(a):
    t0 = time.time()
    while time.time() - t0 < a.secs:
        if _idle(a.ses):
            print("idle"); return
        time.sleep(3)
    print("timeout (still running)"); sys.exit(1)


def _summ_part(p):
    t = p.get("type")
    if t == "text":      return p.get("text", "")[:500]
    if t == "reasoning": return "[reasoning]"
    if t == "tool":
        st = (p.get("state") or {}).get("status", "")
        return f"[tool {p.get('tool','')}: {st}]"
    if t in ("step-start", "step-finish"): return None
    return f"[{t}]"


def cmd_read(a):
    msgs = api("GET", f"/session/{a.ses}/message", timeout=60)
    if not isinstance(msgs, list):
        msgs = msgs.get("messages", [])
    for m in msgs[-a.tail:]:
        info = m.get("info", m)
        role = info.get("role", "?")
        chunks = [s for s in (_summ_part(p) for p in m.get("parts", [])) if s]
        if chunks:
            print(f"--- {role} ---")
            for c in chunks: print(c)


def cmd_todo(a):
    for t in api("GET", f"/session/{a.ses}/todo") or []:
        print(f"[{t.get('status','?'):11}] {t.get('content', t.get('title',''))}")


def cmd_diff(a):
    d = api("GET", f"/session/{a.ses}/diff")
    print(json.dumps(d, indent=2)[:4000] if not isinstance(d, str) else d[:4000])


def cmd_abort(a):
    api("POST", f"/session/{a.ses}/abort", {}); print("aborted")


def cmd_share(a):
    s = api("POST", f"/session/{a.ses}/share", {})
    print(s.get("url") or json.dumps(s)[:300])


def cmd_unshare(a):
    api("DELETE", f"/session/{a.ses}/share"); print("unshared")


def cmd_list(a):
    d = api("GET", "/session")
    d = sorted(d, key=lambda s: (s.get("time") or {}).get("created", 0), reverse=True)
    for s in d[:a.n]:
        print(f"{s.get('id')}  {(s.get('title') or '')[:60]}")


def main():
    p = argparse.ArgumentParser(prog="oc-rpc")
    sub = p.add_subparsers(dest="cmd", required=True)
    n = sub.add_parser("new");   n.add_argument("--title"); n.add_argument("--agent"); n.add_argument("--model"); n.set_defaults(fn=cmd_new)
    s = sub.add_parser("say");   s.add_argument("ses"); s.add_argument("text", nargs="+"); s.add_argument("--agent"); s.add_argument("--secs", type=int, default=900); s.set_defaults(fn=cmd_say)
    w = sub.add_parser("wait");  w.add_argument("ses"); w.add_argument("--secs", type=int, default=900); w.set_defaults(fn=cmd_wait)
    r = sub.add_parser("read");  r.add_argument("ses"); r.add_argument("--tail", type=int, default=6); r.set_defaults(fn=cmd_read)
    for name, fn in (("todo", cmd_todo), ("diff", cmd_diff), ("abort", cmd_abort), ("share", cmd_share), ("unshare", cmd_unshare)):
        q = sub.add_parser(name); q.add_argument("ses"); q.set_defaults(fn=fn)
    l = sub.add_parser("list");  l.add_argument("--n", type=int, default=10); l.set_defaults(fn=cmd_list)
    a = p.parse_args()
    a.fn(a)


if __name__ == "__main__":
    main()
