"""rig — the rig's state, and the one button that brings it up.

The GUI half of `tools/gig-up.sh --converge`, which is the CLI half. converge
owns SERVICES and deliberately refuses to open windows ("a script that opens
windows under someone is a script they stop trusting"), leaving Pulsar and
Ardour reported-but-not-launched. That refusal is why this module exists: the
Bridge is a thing PLN clicked, so it is allowed to open his apps.

Two rules carried over from gig-up, because they are what make the panel worth
looking at:

  * A GREEN UNIT IS NOT SOUND. On 2026-08-01 parvagues-sc.service was `active`
    while scsynth was dead, and `systemctl start` is a no-op on an active unit —
    so the unit state alone would have shown a healthy rig over silence. Every
    service that has a process to point at is judged on BOTH.

  * REPORT ABSENT AS ABSENT. A piece that is not installed is not "down"; a
    down thing you can start and an absent thing you cannot are different
    problems, and collapsing them wastes the reader's time under pressure.
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import threading
import time
from pathlib import Path

import gearbox as GB
import launchers as L

TIDAL = L.TIDAL
GIG_UP = TIDAL / "tools" / "gig-up.sh"
THERMAL_CONF = Path("/etc/thermal-mode.conf")

# THE INVENTORY IS AUTHORED ONCE, in tools/rig_units.py.
#
# This used to be a hand-typed table, and it silently omitted lcxl-leds-watch:
# the panel showed a fully green rig while the LCXL sat dark for a whole session
# (the note that used to live here). On 2026-08-23 the same class of bug bit
# again one layer over — tidal-ardour-autoroute was `disabled`, so it never
# started at login, and orbits 5-9 reached no Ardour track. Three copies of the
# list existed: this one, gig-up.sh's loop, and each unit's [Install].
#
# One authored copy now, three consumers: this panel, `gig-up.sh --ensure`, and
# the GENERATED parvagues-rig.target. Same shape as lcxl_grid.py (#97). Adding a
# unit to the rig is one row in rig_units.py and nothing here.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from rig_units import SERVICES as _INVENTORY  # noqa: E402

# The panel does not care about boot policy (that is `--ensure`'s business), so
# it keeps its own 4-tuple shape rather than spreading a 5th field everywhere.
SERVICES = [(u, label, proc, blurb) for u, label, proc, _boot, blurb in _INVENTORY]

# Apps converge refuses to launch, which is exactly what the button adds.
APPS = ["pulsar", "ardour"]


def _unit_states(units):
    """{unit: {active, load}} for every unit in ONE fork.

    Per-unit `is-active` would be four forks per poll, and the Bridge polls every
    two seconds with an audio graph running — the same reasoning that made
    launchers._cmdlines() scan /proc once instead of pgrep-ing per item.

    Parsed by KEY, never by position: `systemctl show --value` emits properties
    in systemd's own order, not the order you asked for, so pairing them
    positionally silently swapped LoadState and ActiveState here. `Id` makes each
    block self-identifying, which is the only version of this that cannot drift.
    """
    try:
        r = subprocess.run(
            ["systemctl", "--user", "show", "-p", "Id", "-p", "ActiveState",
             "-p", "LoadState", *[f"{u}.service" for u in units]],
            capture_output=True, text=True, timeout=5)
    except (OSError, subprocess.SubprocessError):
        return {}
    out, cur = {}, {}
    for line in r.stdout.splitlines():
        if "=" not in line:
            continue
        k, _, v = line.partition("=")
        if k == "Id" and cur:
            out[cur.pop("_id", "")] = cur
            cur = {}
        if k == "Id":
            cur["_id"] = v[:-8] if v.endswith(".service") else v
        elif k == "ActiveState":
            cur["active"] = v
        elif k == "LoadState":
            cur["load"] = v
    if cur:
        out[cur.pop("_id", "")] = cur
    return out


def gear():
    """The gear and trim the gearbox persisted. Unprivileged read, no sudo.

    Absent conf = the gearbox was never installed here, which is a legitimate
    state and not an error: the panel says so instead of implying a gear.

    Delegates to gearbox.py so the panel and the Qt tray cannot drift apart on
    what a gear is called; THERMAL_CONF stays the parameter so the module remains
    monkeypatchable in tests.
    """
    s = GB.state(THERMAL_CONF)
    return {"gear": s["gear"], "trim": s["trim"]}


def _lcxl():
    """Launch Control XL presence, read from procfs — no aconnect fork."""
    try:
        cards = Path("/proc/asound/cards").read_text()
    except OSError:
        return False
    return "Launch Control XL" in cards or "LCXL" in cards


def _leds_state(state, detail):
    """The LED watcher has a THIRD state that `active` cannot express.

    It retries forever by design (the board is hot-pluggable and usually absent
    at boot), so `active` means "the daemon is alive", NOT "the surface is lit".
    On 2026-08-21 it logged `no LCXL sequencer port; retrying in 30s` eight times
    and a unit-only check would have called that green.

    So cross-check against the board itself, and keep the module's law: report
    ABSENT as absent. No board is not a fault — it is a missing board.
    """
    board = _lcxl()
    if not board:
        if state == "up":
            return "absent", "no board — watcher up, waiting for a replug"
        return "absent", "no board plugged in"
    if state == "up":
        return "up", "painting the surface"
    # Board present, daemon down: the one combination that is silently wrong.
    return "down", f"{detail} — board is plugged in but DARK, start this"


def status():
    """Every piece of the rig, with the state the reader actually needs."""
    lines = L._cmdlines()                    # one /proc scan for the whole snapshot
    names = {a for a, _ in lines}
    states = _unit_states([u for u, _, _, _ in SERVICES])
    items = []

    for unit, label, proc, blurb in SERVICES:
        st = states.get(unit, {})
        load, active = st.get("load", "unknown"), st.get("active", "unknown")
        if load in ("not-found", "masked"):
            state = "absent"
            detail = f"unit {load}"
        elif proc is not None:
            alive = proc in names
            if active == "active" and alive:
                state, detail = "up", f"{proc} running"
            elif active == "active" and not alive:
                # THE 2026-08-01 case. Naming it in the UI matters: `start` will
                # not fix it, only `restart` will, and the panel should not let
                # someone click a button that is a no-op on their real problem.
                state, detail = "broken", f"unit active but {proc} is DEAD — needs restart, not start"
            else:
                state, detail = "down", active
        else:
            state = "up" if active == "active" else "down"
            detail = active
        if unit == "lcxl-leds-watch":
            state, detail = _leds_state(state, detail)
        items.append({"key": unit, "name": label, "blurb": blurb,
                      "state": state, "detail": detail, "kind": "service"})

    for key in APPS:
        spec = L.BY_KEY.get(key)
        if not spec:
            continue
        if not L.available(spec):
            state, detail = "absent", "not installed"
        elif L.is_running(spec, lines):
            state, detail = "up", "running"
        else:
            state, detail = "down", "not running"
        items.append({"key": key, "name": spec["name"], "blurb": spec["blurb"],
                      "state": state, "detail": detail, "kind": "app"})

    items.append({"key": "lcxl", "name": "LCXL", "kind": "surface",
                  "blurb": "the control surface",
                  "state": "up" if _lcxl() else "down",
                  "detail": "connected" if _lcxl() else "not detected"})

    return {"items": items, "gear": gear(), "converge": _job.state()}


# ── converge, run off the request thread ─────────────────────────────────────
# A cold SuperDirt boot recompiles the class library and warms 51 banks, which
# gig-up waits up to 100 s for. Holding an HTTP request open that long would
# stall the UI poll and read as a hung dashboard, so converge runs in a worker
# and the panel watches it through the ordinary status poll.
class _Converge:
    def __init__(self):
        self._lock = threading.Lock()
        self._running = False
        self._started = 0.0
        self._finished = 0.0
        self._rc = None
        self._out = ""

    def state(self):
        with self._lock:
            return {"running": self._running, "rc": self._rc,
                    "started": self._started, "finished": self._finished,
                    "out": self._out[-4000:]}

    def start(self, launch_apps=True):
        with self._lock:
            if self._running:
                return {"ok": True, "status": "already-running",
                        "msg": "converge is already in flight"}
            self._running = True
            self._started = time.time()
            self._rc = None
            self._out = ""
        threading.Thread(target=self._run, args=(launch_apps,), daemon=True).start()
        return {"ok": True, "status": "started", "msg": "bringing the rig up…"}

    def _run(self, launch_apps):
        out, rc = "", 1
        try:
            # --converge implies --live. NOT --audio: that makes sound, and RIG UP
            # must stay safe to press mid-set, backstage, or at 3am — the same
            # promise gig-up's default already makes.
            r = subprocess.run(["bash", str(GIG_UP), "--converge", "--quiet"],
                               capture_output=True, text=True, timeout=300,
                               cwd=str(TIDAL))
            out, rc = (r.stdout or "") + (r.stderr or ""), r.returncode
        except subprocess.TimeoutExpired:
            out, rc = "converge timed out after 300s", 124
        except (OSError, subprocess.SubprocessError) as e:
            out, rc = f"could not run {GIG_UP}: {e}", 127

        if launch_apps:
            # The half converge refuses to do. Best-effort and reported: a DAW
            # that will not open must not make the whole button look failed.
            for key in APPS:
                try:
                    res = L.launch(key)
                    out += f"\n  {key}: {res.get('status')} — {res.get('msg')}"
                except Exception as e:                    # never let a launcher kill the job
                    out += f"\n  {key}: error — {e}"

        with self._lock:
            self._running = False
            self._finished = time.time()
            self._rc = rc
            self._out = out


_job = _Converge()


def rig_up(launch_apps=True):
    return _job.start(launch_apps)
