#!/usr/bin/env python3
"""@callboy — bullpen worker: LLM-dispatched, one tool from a mechanical allowlist.
The "tool-hands" of the room: it fetches web pages and writes text to fetchable files.
  bullpen-callboy            # room loop (systemd)
  bullpen-callboy --once "fetch https://example.com"
"""
import json, os, re, subprocess, sys, time, urllib.request
sys.path[:0] = [p for p in (os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "lib"), "/usr/local/lib/bullpen") if os.path.isdir(p)]
import bullpen_worker as bw
import bullpen_config as cfg
import bullpen_mem as mem

ART_DIR   = cfg.ART_DIR
HANDS_DIR = cfg.HANDS_DIR        # tool-hands file writes land here (fetchable, GC'd)
ENGINE  = cfg.PROXY  # gated proxy (admission control)
MODEL   = cfg.CALLBOY_MODEL  # local floor; track the served backend
MAXBODY = 600

# ---- mechanical allowlist: the ONLY tools callboy may run ----
def do_fetch_url(args):
    url = (args or {}).get("url", "")
    if not (url.startswith("http://") or url.startswith("https://")):
        return None, "fetch_url needs an http(s) url"
    r = subprocess.run(["degater", "fetch_url", f"url={url}"],
                       capture_output=True, text=True, timeout=90)
    if r.returncode != 0:
        return None, "fetch failed: " + (r.stderr.strip() or f"rc {r.returncode}")
    return r.stdout, None

def do_write_file(args):
    """Write text to a fetchable file, jailed to HANDS_DIR. basename-only + charset
    scrub means a caller can never escape the jail (no dirs, no traversal)."""
    a = args or {}
    raw = a.get("name") or a.get("path") or ""
    name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(str(raw)).strip())[:80]
    if not name or name in (".", ".."):
        name = f"note-{int(time.time())}.txt"
    content = a.get("content", "")
    if not isinstance(content, str):
        content = json.dumps(content, ensure_ascii=False)
    try:
        os.makedirs(HANDS_DIR, exist_ok=True)
        path = os.path.join(HANDS_DIR, name)
        with open(path, "w") as f:
            f.write(content)
    except Exception as e:
        return None, f"write failed: {e}"
    return (path, len(content)), None          # (path, nbytes) — dispatch formats the handle

ALLOW = {"fetch_url": do_fetch_url, "write_file": do_write_file}

SYS = ('Output ONLY one JSON object choosing a tool.\n'
       'Tools:\n'
       ' fetch_url(url): read the web page at an http(s) URL.\n'
       ' write_file(name, content): save text to a file that can be fetched afterwards.\n'
       'Reply {"tool":"fetch_url","args":{"url":"<url>"}} '
       'or {"tool":"write_file","args":{"name":"<file>","content":"<text>"}} '
       'or {"tool":null}.')

def ask_engine(request):
    """Returns (choice, served_model). The proxy may fail over, so `d.get("model")` — not
    MODEL — is what actually served this request."""
    payload = json.dumps({
        "model": MODEL,
        "messages": [{"role": "system", "content": SYS}, {"role": "user", "content": request}],
        "temperature": 0, "max_tokens": 1200,   # headroom for write_file content (small files)
    }).encode()
    req = urllib.request.Request(ENGINE, payload, {"Content-Type": "application/json"})
    d = None
    for attempt in range(4):
        try:
            with urllib.request.urlopen(req, timeout=120) as resp:
                d = json.load(resp); break
        except urllib.error.HTTPError as e:
            if e.code == 503 and attempt < 3:      # gate busy -> back off and retry
                time.sleep(3 * (attempt + 1)); continue
            raise
    if d is None:
        return None, None
    served = d.get("model")
    txt = d["choices"][0]["message"]["content"]
    i, j = txt.find("{"), txt.rfind("}")
    if i < 0 or j < 0:
        return None, served
    try: return json.loads(txt[i:j + 1]), served
    except Exception: return None, served

def remember(text):
    """Persist a one-line fetch-artifact pointer to mneme under /bullpen/artifacts
    (raw fetch cache, distinct from curated /bullpen/ops or /bullpen/research). True on success."""
    return mem.save(text.replace("\n", " ").strip(), ns="/bullpen/artifacts") is not None

def dispatch(msg):
    request, rid = msg.get("body", ""), msg.get("id")
    choice, served = ask_engine(request)
    model_tag = f"asked {MODEL}, served by {served}" if served and served != MODEL else f"model {served or MODEL}"
    if not choice or not choice.get("tool"):
        return f"can't help ({model_tag}): {(choice or {}).get('reason', 'no tool matched')}"
    tool, args = choice.get("tool"), choice.get("args", {})
    if tool not in ALLOW:                                  # MECHANICAL gate
        return f"refused: '{tool}' not in allowlist ({', '.join(ALLOW)})"
    out, err = ALLOW[tool](args)
    if err:
        return f"{tool} error: {err}"
    if tool == "write_file":                               # fetchable handle, not the content
        path, n = out
        base = os.path.basename(path)
        saved = remember(f"bullpen file {base} (callboy-written): fetch sic {cfg.ROOM_HOST} cat {path}")
        loc = f"sic {cfg.ROOM_HOST} cat {path}" + (" · mneme /bullpen" if saved else "")
        return f"wrote {base} ({n} bytes, {model_tag}) — fetch: {loc}"
    out = out or ""
    if len(out) > MAXBODY:
        os.makedirs(ART_DIR, exist_ok=True)
        path = f"{ART_DIR}/{rid}.txt"; open(path, "w").write(out)
        abstract = " ".join(out[:MAXBODY].split())
        url = (args or {}).get("url", "")
        saved = remember(f"bullpen artifact {rid}: {url} — {abstract}. Full text: sic {cfg.ROOM_HOST} cat {path}")
        loc = f"sic {cfg.ROOM_HOST} cat {path}" + (" · mneme /bullpen" if saved else "")
        return f"{tool} ok ({len(out)} chars, {model_tag}) — {abstract}… [full: {loc}]"
    return f"{tool} ok ({model_tag}) — {out.strip()}"

if __name__ == "__main__":
    if len(sys.argv) > 2 and sys.argv[1] == "--once":
        print(dispatch({"body": sys.argv[2], "id": 0}))
    else:
        bw.run("callboy", dispatch, online="callboy online — tool-hands (tools: " + ", ".join(ALLOW) + ")")
