#!/usr/bin/env python3
"""@dispatcher — bullpen worker: routes a request to the right worker (reply-only, no relay).

Rule-based keyword match first (fast, no LLM, no admission slot); an LLM tie-break
through the proxy only when the keywords are ambiguous; on weak/no signal it just
returns the full roster. It NEVER answers the request itself and NEVER forwards it —
it tells you who to ask and hands you the exact room-ask command to run.

  bullpen-dispatcher                     # room loop (systemd)
  bullpen-dispatcher --once "fetch https://example.com"
  bullpen-dispatcher --roster            # print the roster manifest
"""
import json, os, re, sys, 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

# Names of this fleet's machines, so "what's installed on <box>" routes to @herder. It is
# real fleet knowledge, not a target address — on another fleet these are just words, which
# is why the list is configurable (BULLPEN_FLEET_HOSTS) instead of baked into the regex.
_ON_A_FLEET_HOST = r"\bon (?:%s)\b" % "|".join(re.escape(h) for h in cfg.FLEET_HOSTS)

# --- roster manifest: the single source of truth for who does what ---
# nick -> (one-liner, [trigger-keyword regexes])
ROSTER = [
    ("callboy",    "tool-hands: fetch/read a web page OR write text to a fetchable file",
        [r"\bfetch\b", r"https?://", r"\burl\b", r"\bdownload\b", r"\bscrape\b",
         r"\bweb ?page\b", r"\bwebsite\b", r"\bthis (?:link|page)\b",
         r"\bwrite\b.*\bfile\b", r"\bsave\b.*\bfile\b", r"\bsave (?:it|this|the)\b",
         r"\bwrite (?:it|this|the) (?:to|out)\b",
         r"\b(?:save|write)\b.*\b\S+\.(?:md|txt|csv|json|log|html?|conf|ini|ya?ml)\b"]),
    ("researcher", "open web research: search + multiple sources + a CITED synthesis",
        [r"\bresearch\b", r"\bcompare\b", r"\btrade-?off", r"\bcost vs\b", r"\bsources?\b",
         r"\bcited?\b", r"\binvestigate\b", r"\bwhy (?:do|does|is|are)\b",
         r"\bwhat is the best\b", r"\bevaluate\b"]),
    ("coder",      "write a Lua script from a task and run it sandboxed (returns code+output)",
        [r"\bwrite\b.*\b(?:code|script|lua|function|parser?|program)\b", r"\blua\b",
         r"\bscript\b", r"\bimplement\b", r"\bparse\b", r"\bfunction\b",
         r"\balgorithm\b", r"\brun (?:it|this|the) (?:code|script)\b"]),
    ("librarian",  "recall the FLEET's own ops/kernel/infra memory + past fetched pages (no web)",
        [r"\brecall\b", r"\bremember\b", r"\bmemory\b", r"\bfleet\b", r"\bwhat did we\b",
         r"\bearlier\b", r"\bwhich host\b", r"\binfra(?:structure)?\b",
         r"\bwe (?:did|built|set up|configured)\b"]),
    # @herder — the heavyweight: a full infra-specialist AGENT (a headless Claude Code that can
    # reach hosts; "herds" the fleet, matches /opt/herding). Route here for LIVE host work
    # (assess/audit/reach/wake/set up/configure/fix a host, its packages/containers/services),
    # NOT for memory recall (that's @librarian).
    ("herder",     "home-infra specialist AGENT: assess/audit/reach/wake/set up/configure a fleet HOST + its packages/containers/services — live work (heavyweight)",
        [r"\bassess\b", r"\baudit\b", r"\binventory\b", r"\bmarfrit-packages?\b",
         r"\bwake\b", r"\bset ?up\b", r"\bprovision\b", r"\bconfigure\b", r"\breach\b",
         r"\bmissing\b.*\bpackages?\b", r"\bpackages?\b.*\b(?:missing|installed)\b",
         r"\bwhat'?s (?:installed|running) on\b",
         _ON_A_FLEET_HOST]),
    # @reviewer — an outside-look code/design review by the Fable model. Route here for
    # "review/critique/assess-the-implementation" of code or a design, NOT host ops (@herder).
    ("reviewer",   "code/design REVIEW by an outside model (Fable): assess an implementation/architecture for correctness, simplification, risk, gaps (heavyweight)",
        [r"\breview\b", r"\bcritique\b", r"\bimplementation\b", r"\barchitecture\b",
         r"\bcode review\b", r"\bdesign review\b", r"\bsecond opinion\b",
         r"\bfind (?:bugs|issues|problems)\b", r"\bsanity[- ]check\b",
         r"\bassess\b.*\b(?:code|design|implementation|architecture|repo)\b"]),
    # the test-driven handoff pipeline: @testdesigner (strong) writes the spec, @py (lesser) grinds
    # an implementation until it is green. Route "write tests/spec" -> testdesigner; "implement/fix
    # until tests pass" -> py.
    ("testdesigner","designs the executable SPEC: writes a pytest suite that FAILS on the bug and passes only when correct — the contract a lesser model then implements against",
        [r"\btest suite\b", r"\bspec\b", r"\bacceptance\b", r"\bpytest\b", r"\btest[- ]?driven\b",
         r"\b(?:write|design)\b.*\btests?\b", r"\bfailing test\b", r"\breproduce .* bug\b"]),
    ("py",         "the Python GRINDER (lesser model): implement/fix code until a given pytest suite goes green — test-driven, no judgment calls, leaves the diff for review",
        [r"\bmake (?:the )?tests? pass\b", r"\bgo green\b", r"\bpass the tests?\b", r"\bgrind\b",
         r"\bimplement\b.*\btests?\b", r"\bfix\b.*\btests?\b", r"\btest[- ]?driven\b.*\b(?:fix|implement)\b"]),
    ("doctor",     "herd HEALTH monitor: is the herd busy-and-healthy, idle, or stalled/passed-out? host vitals + active-job liveness (deterministic, no LLM)",
        [r"\bdoctor\b", r"\bhealth\b", r"\bbusy or\b", r"\bpassed out\b", r"\bstall(?:ed|ing)?\b",
         r"\bvitals\b", r"\bcheckup\b", r"\bhow'?s the herd\b", r"\balive\b", r"\bhung\b", r"\bwedged\b"]),
]
# conversants — turn-driven personas, NOT one-shot workers; for an open design debate.
# Only STRONG debate markers route here, and they take priority over an incidental
# worker-keyword hit ("debate the memory design" must not land on @librarian).
CONV_DESC = "turn-driven personas for a DESIGN DEBATE (not a one-shot answer)"
# finding #8: "should we" removed — it hijacked ordinary questions ("should we wake data?",
# "should we fetch X?") to the debate personas because CONV_PATS is checked before keyword
# scoring. Route to conversants only on an explicit debate marker, not a common phrasing.
CONV_PATS = [r"\bdebate\b", r"\bargue\b",
             r"\bpros? and cons?\b", r"\bdesign question\b"]

ENGINE = os.environ.get("BULLPEN_DISPATCH_ENGINE", cfg.PROXY)
# The tie-break model must track whatever local backend the proxy is actually serving.
# A model swap that retires the old backend (e.g. the 4b-npu -> qwen3.6-coding switch)
# silently orphans this call unless it's repointed — so default to the current standing
# local model and keep it env-overridable for the next swap.
MODEL = os.environ.get("BULLPEN_DISPATCH_MODEL", "qwen3.6-coding")


def _show(body):
    body = (body or "").replace("\n", " ").strip()
    # The suggested command wraps this in double quotes, so make it shell-safe INSIDE "…":
    # escape backslash first, then the double-quote-context metacharacters (` $ ") so no command
    # substitution, escape, or quote-breakout survives a copy-paste (reviewer finding #7).
    for ch in ("\\", "`", "$", '"'):
        body = body.replace(ch, "\\" + ch)
    return body

def _score(body):
    b = body.lower()
    return sorted(((sum(1 for p in pats if re.search(p, b)), nick, desc)
                   for nick, desc, pats in ROSTER), reverse=True)

def _route_line(nick, desc, asker, body, model_tag=None):
    tag = f" [routed by {model_tag}]" if model_tag else ""   # only the LLM tie-break used a model
    return (f"→ @{nick} — {desc}.{tag}\n"
            f"  sic {cfg.ROOM_HOST} room-ask --from {asker or '<you>'} @{nick} \"{_show(body)}\"")

def _roster_text(asker):
    lines = ["not sure which one — here's who's on call:"]
    for nick, desc, _ in ROSTER:
        lines.append(f"  @{nick} — {desc}")
    lines.append(f"  @architect / @skeptic — {CONV_DESC}")
    lines.append(f"then: sic {cfg.ROOM_HOST} room-ask --from {asker or '<you>'} @<worker> \"<your request>\"")
    return "\n".join(lines)

def _llm_pick(body):
    """One cheap tie-break call through the gated proxy. Returns (nick, served_model), either
    of which may be None. served_model is the proxy's actual serving model — it may fail
    over from MODEL."""
    names = [n for n, _, _ in ROSTER]
    sys_p = ("You are a router. Pick the single best worker for the user's request. "
             "Reply with ONLY one word — one of: " + ", ".join(names) + ", none.\n"
             + "\n".join(f"{n}: {d}" for n, d, _ in ROSTER))
    payload = json.dumps({"model": MODEL, "max_tokens": 6, "temperature": 0,
        "messages": [{"role": "system", "content": sys_p},
                     {"role": "user", "content": body}]}).encode()
    try:
        req = urllib.request.Request(ENGINE, data=payload,
                                     headers={"Content-Type": "application/json"})
        d = json.loads(urllib.request.urlopen(req, timeout=45).read())
        served = d.get("model")
        txt = d["choices"][0]["message"]["content"].strip().lower()
        for n, _, _ in ROSTER:
            if re.search(rf"\b{n}\b", txt):
                return n, served
        return None, served
    except Exception:
        pass
    return None, None

def route(body, asker=""):
    body = (body or "").strip()
    if not body:
        return _roster_text(asker)
    # a strong, explicit debate intent wins outright — even over an incidental
    # worker-keyword hit (the intent to *discuss* beats a stray noun match)
    if any(re.search(p, body.lower()) for p in CONV_PATS):
        return (f"→ @architect / @skeptic — {CONV_DESC}.\n"
                f"  sic {cfg.ROOM_HOST} lmcp-tool room_say from={asker or '<you>'} to=@architect "
                f"type=chat body=\"{_show(body)}\"")
    scores = _score(body)
    top_s, top_nick, top_desc = scores[0]
    second_s = scores[1][0]
    # a clear, unique keyword winner routes immediately (no LLM, no slot burned)
    if top_s >= 1 and top_s > second_s:
        return _route_line(top_nick, top_desc, asker, body)
    # ambiguous (tie or no keyword hit): one LLM tie-break, else the full roster
    pick, served = _llm_pick(body)
    if pick:
        desc = next(d for n, d, _ in ROSTER if n == pick)
        return _route_line(pick, desc, asker, body, model_tag=served or MODEL)
    return _roster_text(asker)

def dispatch(msg):
    return route(msg.get("body", ""), msg.get("from", ""))


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "--roster":
        print(_roster_text(""))
    elif len(sys.argv) > 2 and sys.argv[1] == "--once":
        print(route(sys.argv[2], "you"))
    else:
        bw.run("dispatcher", dispatch,
               online=("dispatcher online — tells you WHICH worker to ask "
                       "(routes, never answers). @dispatcher <what you need>"),
               ack="…routing")
