#!/usr/bin/env python3
"""bullpen-up — idempotent bring-up: enable+start only what's MISSING on THIS host.

Never stops, disables, kills, or restarts anything already running — safe to run
repeatedly, by hand or by Deus, without disturbing an in-flight campaign. `systemctl
enable`/`start` are themselves no-ops on an already-enabled/active unit, which is
what makes this safe; this script's only job is picking the right per-host unit list
and never touching a unit outside it.

Per-host roster is EXPLICIT, not auto-discovered from unit files present on disk.
`deploy/install.sh --system` symlinks EVERY bin/bullpen-* + systemd/*.service onto
every --system host uniformly, so "enable everything with a unit file here" would be
WRONG — it would start boltzmann's stale grinder-service copies (grinding actually
happens via on-demand SSH-exec from noether, not a persistent service there) and
duplicate noether's doctor/triage/grinder-serve coordinators onto hertz. This bit for
real on 2026-07-27: bullpen-triage.service was found running BOTH on hertz (crash-
looping for 2.5 days on a broken ExecStart path, separately fixed) AND continuously
on noether (the actual working one) — two independent failure-supervisors watching
the same room simultaneously. hertz's copy is now disabled; noether's is canonical.

An operator can switch a unit OFF for good: drop a file named exactly like the unit into
/etc/bullpen/inhibit/ (or ~/.config/bullpen/inhibit/), with the REASON as its content.
bullpen-up then skips it and prints the reason instead of re-enabling it. Without this,
a hand-disabled unit came back on the next bring-up with nothing in the output to say so
— which is how bullpen-selfimprove.timer would have re-armed itself on 2026-08-02.

  bullpen-up            # bring up this host's roster
  bullpen-up --dry-run  # show what WOULD change, touch nothing

  echo "why" > ~/.config/bullpen/inhibit/bullpen-selfimprove.timer   # switch one off
  rm ~/.config/bullpen/inhibit/bullpen-selfimprove.timer             # switch it back on
"""
import os, socket, subprocess, sys
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_config as cfg

HOST = socket.gethostname().split(".")[0]
DRY_RUN = "--dry-run" in sys.argv

# One entry per ROLE, keyed by the CONFIGURED host that plays it — not by a literal
# hostname, which would make bullpen-up a silent no-op on any other fleet. Still an
# explicit table, NOT auto-discovery: see the docstring above for why "every unit file
# present" was too coarse.
#
# A LIST, not a dict: a small fleet may put two roles on ONE machine, and a dict literal
# with configurable keys silently drops the earlier entry when two keys collide — which
# is the same silent-no-op bug one level down (caught by @reviewer, 2026-08-02). Roles
# sharing a host are merged per scope below, so a combined room+coordinator box brings up
# both its system and its user units.
# "units" are plain systemd unit names; "lurkers" instantiate bullpen-lurker@<nick>.
ROLES = [
    (cfg.ROOM_HOST, {
        "scope": "system",
        "units": [
            "bullpen-architect.service", "bullpen-callboy.service", "bullpen-coder.service",
            "bullpen-dispatcher.service", "bullpen-librarian.service", "bullpen-researcher.service",
            "bullpen-skeptic.service", "bullpen-gc.timer",
        ],
        "lurkers": [],
    }),
    (cfg.COORD_HOST, {
        "scope": "user",
        "units": [
            "bullpen-doctor.service", "bullpen-triage.service", "bullpen-selfimprove.timer",
            "bullpen-grinder-godev.service", "bullpen-grinder-jsdev.service", "bullpen-grinder-py.service",
        ],
        # explicit, not a *_lurker/ directory glob: not every such dir is a live chat-lurker
        # (e.g. py_lurker holds test fixtures for the @py grinder, not a lurker@py instance).
        "lurkers": ["artdesigner", "foreman", "herder", "reviewer", "testdesigner", "textwriter"],
    }),
    (cfg.GRIND_HOST, {
        "scope": "system",
        "units": [],   # nothing persistent by design — grinds are on-demand SSH-exec from the coordinator
        "lurkers": [],
    }),
]

# host -> {scope: [unit names]} — roles that share a host merge, scopes stay separate
# because a system unit and a user unit need different systemctl invocations.
ROSTER = {}
for _host, _r in ROLES:
    ROSTER.setdefault(_host, {}).setdefault(_r["scope"], []).extend(
        list(_r["units"]) + [f"bullpen-lurker@{n}.service" for n in _r["lurkers"]])


def systemctl(scope, *args):
    cmd = ["systemctl"] + (["--user"] if scope == "user" else []) + list(args)
    try:
        return subprocess.run(cmd, capture_output=True, text=True, timeout=20)
    except Exception as e:
        class R: returncode = 1; stdout = ""; stderr = str(e)
        return R()


def ensure(scope, name):
    """Return (changed: bool, note: str). Only ever enable/start — never the reverse."""
    en = systemctl(scope, "is-enabled", name).stdout.strip()
    act = systemctl(scope, "is-active", name).stdout.strip()
    reason = cfg.inhibit_reason(name)
    if reason is not None:
        # An inhibited unit is skipped, LOUDLY. A silent skip would be its own trap: the
        # herd would come up missing a piece with nothing in the output to say why.
        # Note this never STOPS anything — bullpen-up does not tear down, so an inhibit
        # that arrives while the unit runs prevents re-arming, not the current run.
        if act == "active":
            return False, (f"INHIBIT {name}: {reason}\n"
                           f"        …but it is ACTIVE. An inhibit only blocks re-arming; "
                           f"stop it by hand if that is what you meant.")
        return False, f"INHIBIT {name}: {reason}"
    need_enable = en not in ("enabled", "static")
    # the roster only ever lists .timer names for oneshot-triggered work (gc, selfimprove),
    # never the underlying .service — so "active" is always the right bar here: a timer's
    # "active" state IS "waiting to fire", not "currently running".
    need_start = act != "active"
    if not need_enable and not need_start:
        return False, f"ok      {name} (enabled, active)"
    if DRY_RUN:
        todo = []
        if need_enable: todo.append("enable")
        if need_start: todo.append("start")
        return True, f"WOULD   {name}: {'+'.join(todo)} (currently enabled={en!r} active={act!r})"
    msgs = []
    if need_enable:
        r = systemctl(scope, "enable", name)
        msgs.append("enabled" if r.returncode == 0 else f"enable FAILED: {r.stderr.strip()[:120]}")
    if need_start:
        r = systemctl(scope, "start", name)
        msgs.append("started" if r.returncode == 0 else f"start FAILED: {r.stderr.strip()[:120]}")
    return True, f"FIXED   {name}: {', '.join(msgs)}"


def main():
    # NB: not `cfg` — that name is the config module at import scope, and shadowing it
    # here would make any later cfg.<KEY> lookup in this function silently wrong.
    by_scope = ROSTER.get(HOST)
    if by_scope is None:
        print(f"bullpen-up: no roster defined for host {HOST!r} — nothing to do "
              f"(known hosts: {', '.join(ROSTER)})", file=sys.stderr)
        sys.exit(0)
    if not any(by_scope.values()):
        print(f"bullpen-up on {HOST}: nothing runs persistently here by design.")
        return
    any_change = False
    for scope, targets in sorted(by_scope.items()):
        any_change = _bring_up(scope, targets) or any_change
    _report(any_change)


def _bring_up(scope, targets):
    any_change = False
    for name in targets:
        changed, note = ensure(scope, name)
        any_change = any_change or changed
        print(note)
    return any_change


def _report(any_change):
    targets = [u for units in ROSTER.get(HOST, {}).values() for u in units]
    if DRY_RUN:
        print("(dry-run — nothing was changed)")
    elif not any_change:
        print(f"bullpen-up on {HOST}: already fully up ({len(targets)} unit(s) checked).")


if __name__ == "__main__":
    main()
