#!/usr/bin/env python3
"""
tyler_term.py — TYLER terminal client for Termux (Android) and any POSIX shell.

Talks to a TYLER node over its HTTPS API: the WordPress plugin (/wp-json/tyler/v1) or the
standalone kit (api.php?route=). Pure standard library (no pip installs, no compiled deps).

  python tyler_term.py setup jedi https://jedi-sec.com          # auto-detects WordPress vs standalone
  python tyler_term.py chat [--room main] [--plain]             # curses chat (green-on-black), or line mode
  python tyler_term.py send main "message text"                 # one-shot (scripts, Termux:Widget)
  python tyler_term.py intel [-n 20] [-q ransomware] [--follow] # live intelligence feed
  python tyler_term.py status                                   # agents + source health
  python tyler_term.py bus [--follow] [--say "note"]            # internal agent group chat (admin auth)
  python tyler_term.py run [--agent kompromat] [--source id]    # trigger collection (admin auth)

Admin auth:  WordPress → --wp-user NAME --wp-app-password "xxxx xxxx ..." (Users → Profile → Application Passwords)
             Standalone → --api-key tyl_api_…  (php bin/tyler api-key termux)
Config is stored in ~/.config/tyler/config.json with mode 600.
"""
from __future__ import annotations

import argparse
import base64
import hashlib
import json
import os
import queue
import re
import shutil
import ssl
import stat
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request

VERSION = "1.0.0"
CONFIG_PATH = os.path.expanduser(os.environ.get("TYLER_TERM_CONFIG", "~/.config/tyler/config.json"))
UA = f"tyler-term/{VERSION} (Termux; Python {sys.version_info.major}.{sys.version_info.minor})"

GREEN, BLUE, VIOLET, RED, AMBER, DIM, BOLD, RESET = "\033[38;5;84m", "\033[38;5;39m", "\033[38;5;141m", "\033[38;5;203m", "\033[38;5;221m", "\033[2m", "\033[1m", "\033[0m"
CTRL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f‪-‮⁦-⁩]")


def clean(text: object) -> str:
    """Strip control characters and bidi overrides so server text can never inject terminal escapes."""
    return CTRL_RE.sub("", str(text if text is not None else ""))


# --------------------------------------------------------------------------- config

def load_config() -> dict:
    try:
        with open(CONFIG_PATH, "r", encoding="utf-8") as fh:
            data = json.load(fh)
            return data if isinstance(data, dict) else {}
    except (OSError, ValueError):
        return {}


def save_config(cfg: dict) -> None:
    os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
    tmp = CONFIG_PATH + ".tmp"
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR)
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        json.dump(cfg, fh, indent=2)
    os.replace(tmp, CONFIG_PATH)
    os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)


# ----------------------------------------------------------------------------- API

class ApiError(Exception):
    def __init__(self, status: int, message: str):
        super().__init__(message)
        self.status = status


class Tyler:
    """Minimal TYLER API client. `base` is the API base: …/wp-json/tyler/v1 or …/api.php?route="""

    def __init__(self, base: str, token: str | None = None, auth: dict | None = None, timeout: int = 20):
        if not base.startswith("https://") and not base.startswith("http://127.0.0.1") and not base.startswith("http://localhost"):
            raise ValueError("Refusing non-HTTPS server (tokens would travel in clear text). Use https://")
        self.base = base
        self.token = token
        self.auth = auth or {}
        self.timeout = timeout
        self.ctx = ssl.create_default_context()

    def url(self, path: str, params: dict | None = None) -> str:
        qs = urllib.parse.urlencode({k: v for k, v in (params or {}).items() if v not in (None, "")})
        if self.base.endswith("route="):
            return self.base + urllib.parse.quote(path, safe="/") + ("&" + qs if qs else "")
        return self.base.rstrip("/") + path + ("?" + qs if qs else "")

    def request(self, method: str, path: str, params: dict | None = None, body: dict | None = None) -> dict:
        headers = {"Accept": "application/json", "User-Agent": UA}
        data = None
        if body is not None:
            data = json.dumps(body).encode("utf-8")
            headers["Content-Type"] = "application/json"
        if self.token:
            headers["X-Tyler-Token"] = self.token
        if self.auth.get("type") == "wp":
            raw = f"{self.auth['user']}:{self.auth['password']}".encode("utf-8")
            headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii")
        elif self.auth.get("type") == "bearer":
            headers["Authorization"] = "Bearer " + self.auth["key"]
        req = urllib.request.Request(self.url(path, params), data=data, method=method, headers=headers)
        try:
            with urllib.request.urlopen(req, timeout=self.timeout, context=self.ctx) as resp:
                payload = resp.read(5_000_000)
                return json.loads(payload.decode("utf-8") or "{}")
        except urllib.error.HTTPError as e:
            try:
                info = json.loads(e.read(200_000).decode("utf-8") or "{}")
                msg = info.get("error") or info.get("message") or f"HTTP {e.code}"
            except ValueError:
                msg = f"HTTP {e.code}"
            raise ApiError(e.code, clean(msg)) from None
        except urllib.error.URLError as e:
            raise ApiError(0, f"connection failed: {clean(e.reason)}") from None
        except ValueError:
            raise ApiError(0, "server returned invalid JSON") from None

    def get(self, path: str, **params) -> dict:
        return self.request("GET", path, params)

    def post(self, path: str, body: dict | None = None) -> dict:
        return self.request("POST", path, None, body or {})

    def ensure_identity(self, nick: str | None = None) -> dict:
        if self.token or self.auth.get("type") == "wp":
            try:
                return self.get("/identity/me")["identity"]
            except ApiError as e:
                if e.status != 401:
                    raise
                self.token = None
        r = self.post("/identity", {"nick": nick} if nick else {})
        if r.get("token"):
            self.token = r["token"]
        return r["identity"]


def detect_base(site: str, timeout: int = 15) -> tuple[str, str]:
    """Return (api_base, mode). Accepts a site URL or an explicit API base."""
    site = site.strip().rstrip("/")
    candidates = []
    if "/wp-json/tyler/v1" in site or site.endswith("route="):
        candidates.append((site, "explicit"))
    else:
        candidates += [(site + "/wp-json/tyler/v1", "wordpress"), (site + "/api.php?route=", "standalone"), (site + "/public/api.php?route=", "standalone")]
    last = None
    for base, mode in candidates:
        try:
            r = Tyler(base, timeout=timeout).get("/chat/rooms")
            if isinstance(r, dict) and "rooms" in r:
                return base, ("wordpress" if "/wp-json/" in base else "standalone") if mode == "explicit" else mode
        except (ApiError, ValueError) as e:
            last = e
    raise SystemExit(f"No TYLER API found at {site} ({last})")


def client_from_config(args) -> tuple[Tyler, dict, str]:
    cfg = load_config()
    name = getattr(args, "server", None) or cfg.get("default")
    srv = (cfg.get("servers") or {}).get(name or "")
    if not srv:
        raise SystemExit("No server configured. Run: tyler_term.py setup NAME https://your-site")
    t = Tyler(srv["base"], token=srv.get("token"), auth=srv.get("auth"))
    return t, cfg, name


def persist_token(cfg: dict, name: str, t: Tyler) -> None:
    srv = cfg["servers"][name]
    if t.token and srv.get("token") != t.token:
        srv["token"] = t.token
        save_config(cfg)


# --------------------------------------------------------------------- formatting

def nick_color(nick: str) -> str:
    palette = ["\033[38;5;84m", "\033[38;5;39m", "\033[38;5;141m", "\033[38;5;214m", "\033[38;5;45m", "\033[38;5;119m"]
    return palette[int(hashlib.sha1(nick.encode("utf-8")).hexdigest(), 16) % len(palette)]


def format_message(m: dict, color: bool = True) -> str:
    ts = clean(m.get("ts") or "")[11:16]
    text = clean(m.get("text"))
    nick = clean(m.get("nick"))
    badge = ("★" if m.get("mod") else "") + ("✓" if m.get("member") else "")
    kind = m.get("kind")
    if not color:
        if kind == "system":
            return f"{ts} -- {text}"
        if kind == "me":
            return f"{ts} * {nick}{badge} {text}"
        return f"{ts} <{nick}{badge}> {text}"
    if kind == "system":
        return f"{DIM}{ts} -- {text}{RESET}"
    if kind == "me":
        return f"{DIM}{ts}{RESET} {VIOLET}* {nick}{badge} {text}{RESET}"
    return f"{DIM}{ts}{RESET} {nick_color(nick)}{nick}{RESET}{GREEN}{badge}{RESET}: {text}"


def format_intel(r: dict, color: bool = True) -> str:
    v = clean(r.get("verification") or "unreviewed")
    when = clean(r.get("published_at") or r.get("collected_at") or "")[:16].replace("T", " ")
    src = clean(r.get("source"))
    title = clean(r.get("title"))
    url = clean(r.get("url"))
    vcol = {"verified": GREEN, "corroborated": BLUE, "disputed": AMBER, "retracted": RED, "opinion": VIOLET}.get(v, DIM)
    if not color:
        return f"[{when}] {src} | {title} [{v}]\n    {url}"
    return f"{DIM}[{when}]{RESET} {VIOLET}{src}{RESET} {BOLD}{title}{RESET} {vcol}[{v}]{RESET}\n    {BLUE}{url}{RESET}"


def format_bus(m: dict, color: bool = True) -> str:
    ts = clean(m.get("ts") or "")[5:19].replace("T", " ")
    frm, kind, msg = clean(m.get("from")), clean(m.get("kind")), clean(m.get("msg"))
    to = clean(m.get("to") or "*")
    arrow = f" → {to}" if to != "*" else ""
    if not color:
        return f"{ts} {frm}{arrow} [{kind}] {msg}"
    kcol = {"error": RED, "retry": AMBER, "operator": GREEN, "discovery": BLUE}.get(kind, DIM)
    return f"{DIM}{ts}{RESET} {nick_color(frm)}{frm}{RESET}{DIM}{arrow}{RESET} {kcol}[{kind}]{RESET} {msg}"


# ------------------------------------------------------------------- chat engine

class ChatState:
    """Room/DM state shared by the curses and line-mode front ends. Mutated only on the UI thread."""

    def __init__(self, t: Tyler, room: str):
        self.t = t
        self.room = room
        self.rooms: list[dict] = []
        self.since: dict[str, int] = {}
        self.lines: dict[str, list[dict]] = {}
        self.presence: list[dict] = []
        self.dms: dict[str, dict] = {}
        self.you: dict = {}
        self.poll_ms = 3000
        self.notices: list[str] = []

    def apply_poll(self, room: str, d: dict) -> list[dict]:
        new = []
        buf = self.lines.setdefault(room, [])
        for m in d.get("messages", []):
            if m.get("kind") == "del":
                buf[:] = [x for x in buf if x.get("id") != m.get("target")]
                continue
            buf.append(m)
            new.append(m)
        del buf[:-500]
        self.since[room] = int(d.get("seq") or self.since.get(room, 0))
        if room == self.room:
            self.presence = d.get("presence") or self.presence
        if d.get("you"):
            self.you = d["you"]
        for x in d.get("dms") or []:
            cur = self.dms.get(x["uid"])
            unread = (cur is not None and x["seq"] > cur["seq"]) and self.room != "dm:" + x["uid"]
            self.dms[x["uid"]] = {"nick": clean(x["nick"]), "seq": x["seq"], "unread": (cur or {}).get("unread", False) or unread}
        return new

    def find_uid(self, nick: str) -> str | None:
        for p in self.presence:
            if p["nick"].lower() == nick.lower():
                return p["uid"]
        for uid, d in self.dms.items():
            if d["nick"].lower() == nick.lower():
                return uid
        return None

    def label(self, room: str) -> str:
        if room.startswith("dm:"):
            return "@" + self.dms.get(room[3:], {}).get("nick", room[3:9])
        return "#" + room

    def local_command(self, text: str) -> tuple[bool, str | None]:
        """Handle client-side commands. Returns (handled, message)."""
        parts = text.split(maxsplit=2)
        cmd = parts[0].lower()
        if cmd in ("/quit", "/exit"):
            raise KeyboardInterrupt
        if cmd == "/clear":
            self.lines[self.room] = []
            return True, None
        if cmd == "/dm" and len(parts) >= 2:
            uid = self.find_uid(parts[1])
            if not uid:
                return True, f"no known user '{parts[1]}' (they must be online, or have DMed you)"
            self.dms.setdefault(uid, {"nick": parts[1], "seq": 0, "unread": False})
            self.room = "dm:" + uid
            return True, f"DM with {parts[1]} — moderators cannot read DMs."
        if cmd == "/rooms" and self.rooms:
            return True, "rooms: " + ", ".join("#" + r["id"] for r in self.rooms) + "  (Tab cycles)"
        if cmd == "/help":
            return False, "client: /dm NICK · /clear · /quit · Tab next room · PgUp/PgDn scroll — server: /help lists the rest"
        return False, None

    def send(self, text: str) -> list[str]:
        out = []
        handled, msg = self.local_command(text)
        if msg:
            out.append(msg)
        if handled:
            return out
        r = self.t.post("/chat/send", {"room": self.room, "text": text})
        if r.get("identity"):
            self.you.update(r["identity"])
        if r.get("notice"):
            out.append(clean(r["notice"]))
        if r.get("switch_room"):
            self.room = r["switch_room"]
        if r.get("open_dm"):
            self.dms.setdefault(r["open_dm"]["uid"], {"nick": clean(r["open_dm"]["nick"]), "seq": 0, "unread": False})
        return out


class Poller(threading.Thread):
    """Background HTTP poller. Never touches UI state; pushes results to a queue."""

    def __init__(self, st: ChatState, q: "queue.Queue[tuple]"):
        super().__init__(daemon=True)
        self.st, self.q = st, q
        self.wake = threading.Event()
        self.stop = threading.Event()
        self.fails = 0

    def run(self) -> None:
        while not self.stop.is_set():
            room = self.st.room
            try:
                d = self.st.t.get("/chat/poll", room=room, since=self.st.since.get(room, 0))
                self.q.put(("poll", room, d))
                self.fails = 0
                delay = self.st.poll_ms / 1000
            except ApiError as e:
                self.fails += 1
                self.q.put(("error", room, str(e)))
                delay = min(60, (self.st.poll_ms / 1000) * (2 ** self.fails))
            self.wake.wait(delay)
            self.wake.clear()


def chat_plain(st: ChatState, color: bool) -> None:
    q: "queue.Queue[tuple]" = queue.Queue()
    poller = Poller(st, q)
    poller.start()
    print(f"{GREEN}TYLER chat{RESET} — {st.label(st.room)} as {st.you.get('nick')} · /help · Ctrl-C to quit" if color else f"TYLER chat {st.room} as {st.you.get('nick')}")

    def drain() -> None:
        while True:
            try:
                kind, room, d = q.get_nowait()
            except queue.Empty:
                return
            if kind == "poll":
                for m in st.apply_poll(room, d):
                    if room == st.room:
                        print(format_message(m, color))
            else:
                print(f"! {d}")

    stop = threading.Event()

    def printer() -> None:
        while not stop.is_set():
            drain()
            time.sleep(0.25)

    threading.Thread(target=printer, daemon=True).start()
    try:
        while True:
            line = input()
            if not line.strip():
                continue
            try:
                for n in st.send(line.strip()):
                    print(f"-- {n}")
                poller.wake.set()
            except ApiError as e:
                print(f"! {e}")
    except (KeyboardInterrupt, EOFError):
        stop.set()
        poller.stop.set()
        print()


def chat_curses(st: ChatState) -> None:
    import curses

    q: "queue.Queue[tuple]" = queue.Queue()
    poller = Poller(st, q)

    def ui(scr) -> None:
        curses.curs_set(1)
        curses.use_default_colors()
        try:
            curses.init_pair(1, 84, -1)   # green
            curses.init_pair(2, 39, -1)   # blue
            curses.init_pair(3, 141, -1)  # violet
            curses.init_pair(4, 203, -1)  # red
            curses.init_pair(5, 244, -1)  # dim
            curses.init_pair(6, 16, 84)   # bar
        except curses.error:
            for i, c in enumerate([curses.COLOR_GREEN, curses.COLOR_BLUE, curses.COLOR_MAGENTA, curses.COLOR_RED, curses.COLOR_WHITE], start=1):
                curses.init_pair(i, c, -1)
            curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_GREEN)
        scr.timeout(120)
        scr.keypad(True)
        buf, hist, hidx, scroll = "", [], 0, 0
        status = "connecting…"
        poller.start()
        while True:
            try:
                while True:
                    kind, room, d = q.get_nowait()
                    if kind == "poll":
                        st.apply_poll(room, d)
                        status = "online"
                    else:
                        status = "error: " + d
            except queue.Empty:
                pass
            h, w = scr.getmaxyx()
            scr.erase()
            tabs = [r["id"] for r in st.rooms] + ["dm:" + u for u in st.dms]
            bar = " TYLER │ " + " ".join((("[" + st.label(r) + "]") if r == st.room else st.label(r) + ("•" if r.startswith("dm:") and st.dms.get(r[3:], {}).get("unread") else "")) for r in tabs)
            scr.addnstr(0, 0, bar.ljust(w), w - 1, curses.color_pair(6) | curses.A_BOLD)
            side = 18 if w >= 70 else 0
            logw = w - side - 1
            lines = []
            for m in st.lines.get(st.room, []):
                prefix = f"{clean(m.get('ts') or '')[11:16]} "
                if m.get("kind") == "system":
                    lines.append([(prefix + "-- " + clean(m.get("text")), 5)])
                    continue
                nick = clean(m.get("nick")) + ("★" if m.get("mod") else "") + ("✓" if m.get("member") else "")
                lead = ("* " + nick + " ") if m.get("kind") == "me" else (nick + ": ")
                text = clean(m.get("text"))
                first = True
                rest = prefix + lead + text
                while rest:
                    chunk, rest = rest[:logw], rest[logw:]
                    if first:
                        lines.append([(chunk[:len(prefix)], 5), (chunk[len(prefix):len(prefix) + len(lead)], 3 if m.get("kind") == "me" else (1 if m.get("member") else 2)), (chunk[len(prefix) + len(lead):], 0)])
                        first = False
                    else:
                        lines.append([(chunk, 0)])
            for n in st.notices[-3:]:
                lines.append([("-- " + n, 5)])
            view_h = h - 3
            start = max(0, len(lines) - view_h - scroll)
            for i, segs in enumerate(lines[start:start + view_h]):
                x = 0
                for text, pair in segs:
                    if text and x < logw:
                        try:
                            scr.addnstr(1 + i, x, text, logw - x, curses.color_pair(pair) if pair else 0)
                        except curses.error:
                            pass
                        x += len(text)
            if side:
                for i, p in enumerate(st.presence[: h - 3]):
                    try:
                        scr.addnstr(1 + i, logw + 1, ("★" if p.get("mod") else " ") + clean(p["nick"]), side - 1, curses.color_pair(1 if p.get("member") else 2))
                    except curses.error:
                        pass
            you = st.you.get("nick", "?")
            flags = " muted" if st.you.get("muted_until") else (" banned" if st.you.get("banned") else "")
            try:
                scr.addnstr(h - 2, 0, f" {you}{flags} · {status} · Tab room · PgUp/PgDn · /help · /quit ".ljust(w), w - 1, curses.color_pair(5) | curses.A_REVERSE)
                prompt = st.label(st.room) + "> "
                scr.addnstr(h - 1, 0, (prompt + buf)[-(w - 1):], w - 1, curses.color_pair(1))
                scr.move(h - 1, min(w - 2, len(prompt) + len(buf)))
            except curses.error:
                pass
            scr.refresh()
            try:
                ch = scr.get_wch()
            except curses.error:
                continue
            if ch in ("\n", "\r", curses.KEY_ENTER, 10, 13):
                text, buf = buf.strip(), ""
                if text:
                    hist.append(text)
                    hidx = len(hist)
                    try:
                        st.notices.extend(st.send(text))
                    except ApiError as e:
                        st.notices.append(f"! {e}")
                    except KeyboardInterrupt:
                        poller.stop.set()
                        return
                    poller.wake.set()
            elif ch in (curses.KEY_BACKSPACE, "\x7f", "\b", 127, 8):
                buf = buf[:-1]
            elif ch == "\x15":
                buf = ""
            elif ch == "\t" and tabs:
                i = tabs.index(st.room) if st.room in tabs else -1
                st.room = tabs[(i + 1) % len(tabs)]
                if st.room.startswith("dm:"):
                    st.dms[st.room[3:]]["unread"] = False
                scroll = 0
                poller.wake.set()
            elif ch == curses.KEY_PPAGE:
                scroll = min(scroll + view_h // 2, max(0, len(lines) - view_h))
            elif ch == curses.KEY_NPAGE:
                scroll = max(0, scroll - view_h // 2)
            elif ch == curses.KEY_UP and hist:
                hidx = max(0, hidx - 1)
                buf = hist[hidx]
            elif ch == curses.KEY_DOWN and hist:
                hidx = min(len(hist), hidx + 1)
                buf = hist[hidx] if hidx < len(hist) else ""
            elif ch == curses.KEY_RESIZE:
                pass
            elif isinstance(ch, str) and ch.isprintable() and len(buf) < 500:
                buf += ch

    try:
        curses.wrapper(ui)
    except KeyboardInterrupt:
        pass
    finally:
        poller.stop.set()


# ------------------------------------------------------------------------ commands

def cmd_setup(args) -> None:
    base, mode = detect_base(args.url)
    cfg = load_config()
    cfg.setdefault("servers", {})
    srv = cfg["servers"].get(args.name, {})
    srv.update({"base": base, "mode": mode})
    if args.wp_user and args.wp_app_password:
        srv["auth"] = {"type": "wp", "user": args.wp_user, "password": args.wp_app_password}
    elif args.api_key:
        srv["auth"] = {"type": "bearer", "key": args.api_key}
    cfg["servers"][args.name] = srv
    if args.default or not cfg.get("default"):
        cfg["default"] = args.name
    save_config(cfg)
    t = Tyler(base, token=srv.get("token"), auth=srv.get("auth"))
    ident = t.ensure_identity(args.nick)
    srv["token"] = t.token
    save_config(cfg)
    print(f"Saved '{args.name}' → {base} ({mode}). You are {clean(ident['nick'])} (uid {ident['uid']}). Config: {CONFIG_PATH}")


def cmd_chat(args) -> None:
    t, cfg, name = client_from_config(args)
    st = ChatState(t, args.room)
    rooms = t.get("/chat/rooms")
    st.rooms = rooms.get("rooms", [])
    st.poll_ms = max(1500, int(rooms.get("poll_ms", 3000)))
    st.you = t.ensure_identity()
    persist_token(cfg, name, t)
    if args.plain or not sys.stdout.isatty() or os.environ.get("TERM") in (None, "", "dumb"):
        chat_plain(st, color=not args.no_color and sys.stdout.isatty())
    else:
        try:
            import curses  # noqa: F401
        except ImportError:
            chat_plain(st, color=not args.no_color)
            return
        chat_curses(st)


def cmd_send(args) -> None:
    t, cfg, name = client_from_config(args)
    t.ensure_identity()
    persist_token(cfg, name, t)
    r = t.post("/chat/send", {"room": args.room, "text": " ".join(args.text)})
    print(clean(r.get("notice") or "sent"))


def cmd_intel(args) -> None:
    t, _, _ = client_from_config(args)
    color = sys.stdout.isatty() and not args.no_color
    seen: set[str] = set()
    while True:
        d = t.get("/intel", per_page=args.n, q=args.q, source=args.source, category=args.category, sort="newest")
        fresh = [r for r in d.get("items", []) if r["rid"] not in seen]
        for r in reversed(fresh):
            print(format_intel(r, color))
            seen.add(r["rid"])
        if not args.follow:
            print(f"{DIM}{d.get('total', 0)} record(s){RESET}" if color else f"{d.get('total', 0)} record(s)")
            return
        time.sleep(max(30, args.interval))


def cmd_status(args) -> None:
    t, _, _ = client_from_config(args)
    d = t.get("/status")
    for a in d["agents"]:
        print(f"{a['state']:>10}  {clean(a['label'])}  (heartbeat {clean(a.get('last_heartbeat') or 'never')}{', STALE' if a.get('stale') else ''})")
    print()
    for s in d["sources"]:
        print(f"{s['health']:>9}  {clean(s['name']):<32} {s['agent']:<14} last ok {clean(s.get('last_ok') or '—')}  items {s['items_total']}")


def cmd_bus(args) -> None:
    t, _, _ = client_from_config(args)
    if args.say:
        t.post("/admin/bus", {"msg": args.say})
        print("posted")
        return
    color = sys.stdout.isatty() and not args.no_color
    after = 0
    while True:
        d = t.get("/admin/bus", after=after or None, limit=args.limit)
        for m in d.get("messages", []):
            print(format_bus(m, color))
            after = max(after, int(m["seq"]))
        if not args.follow:
            return
        time.sleep(4)


def cmd_run(args) -> None:
    t, _, _ = client_from_config(args)
    r = t.post("/admin/run", {"agent": args.agent or "", "source": args.source or ""})
    for run in r.get("runs", []):
        print(f"{run['source']:<20} {run['agent']:<14} {'OK ' if run['ok'] else 'ERR'} new={run.get('new', 0)} upd={run.get('updated', 0)} {clean(run.get('error') or '')}")
    print(json.dumps(r.get("summary", {})))


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(prog="tyler_term.py", description="TYLER terminal client (chat · intel · agent bus)")
    p.add_argument("--server", help="configured server name (default: the saved default)")
    p.add_argument("--no-color", action="store_true")
    p.add_argument("--version", action="version", version=VERSION)
    sub = p.add_subparsers(dest="cmd", required=True)
    s = sub.add_parser("setup", help="add a server (auto-detects WordPress or standalone)")
    s.add_argument("name")
    s.add_argument("url", help="site URL, or explicit API base (…/wp-json/tyler/v1 or …/api.php?route=)")
    s.add_argument("--nick")
    s.add_argument("--wp-user")
    s.add_argument("--wp-app-password")
    s.add_argument("--api-key")
    s.add_argument("--default", action="store_true")
    s.set_defaults(fn=cmd_setup)
    c = sub.add_parser("chat", help="interactive chat")
    c.add_argument("--room", default="main")
    c.add_argument("--plain", action="store_true", help="line mode instead of curses")
    c.set_defaults(fn=cmd_chat)
    sd = sub.add_parser("send", help="send one message")
    sd.add_argument("room")
    sd.add_argument("text", nargs="+")
    sd.set_defaults(fn=cmd_send)
    i = sub.add_parser("intel", help="intelligence feed")
    i.add_argument("-n", type=int, default=15)
    i.add_argument("-q")
    i.add_argument("--source")
    i.add_argument("--category")
    i.add_argument("--follow", action="store_true")
    i.add_argument("--interval", type=int, default=60)
    i.set_defaults(fn=cmd_intel)
    sub.add_parser("status", help="agents and sources").set_defaults(fn=cmd_status)
    b = sub.add_parser("bus", help="internal agent group chat (admin)")
    b.add_argument("--follow", action="store_true")
    b.add_argument("--limit", type=int, default=50)
    b.add_argument("--say", help="post an operator note")
    b.set_defaults(fn=cmd_bus)
    r = sub.add_parser("run", help="trigger collection now (admin)")
    r.add_argument("--agent")
    r.add_argument("--source")
    r.set_defaults(fn=cmd_run)
    args = p.parse_args(argv)
    try:
        args.fn(args)
    except ApiError as e:
        hint = " (admin auth required: setup with --wp-user/--wp-app-password or --api-key)" if e.status in (401, 403) and args.cmd in ("bus", "run") else ""
        print(f"error: {e}{hint}", file=sys.stderr)
        return 1
    except ValueError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        return 130
    return 0


if __name__ == "__main__":
    sys.exit(main())
