#!/usr/bin/env python3
"""
perf-tray — system-tray companion for perf.sh (TidalCycles performance modes).

Shows live thermals in the tray (a ParVagues-wave badge whose colour tracks heat)
and lets you switch perf.sh modes from a menu without leaving your livecoding
session.

Design notes
------------
* The perf/thermal LOGIC now lives in The Bridge (`tools/bridge/perf.py`) — the
  tray is just its Qt face (DRY; the web dashboard is the other face). Reading
  thermals is rootless; only *changing mode* needs root, via `sudo -n <SCRIPT>`
  against a ROOT-OWNED, sudoers-whitelisted copy of perf.sh (see
  perf-audio.sudoers). We never sudo the user-editable repo copy.
* hwmon device numbers are not stable across boots → chips resolved by name
  (in perf.py).
* PyQt5 because it ships in the Arch repos (python-pyqt5) — no extra deps.
"""

import math
import os
import subprocess
import sys
import time
from pathlib import Path

from PyQt5.QtWidgets import (
    QApplication, QSystemTrayIcon, QMenu, QAction, QActionGroup,
)
from PyQt5.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QPen, QPainterPath
from PyQt5.QtCore import QTimer, Qt, QProcess, QRect

# Shared perf logic (DRY with the web Bridge). The tray lives at the repo root;
# perf.py lives under tools/bridge.
sys.path.insert(0, str(Path(__file__).resolve().parent / "tools" / "bridge"))
from perf import (Thermals, detect_mode, MODES, SCRIPT, write_desired,  # noqa: E402
                  dgpu_state, thermald_active, power_caps_w, gpu_manager_mode)
import launchers as LA  # noqa: E402

SERVICE = "perf-tray"   # systemd --user unit controlling autostart
REFRESH_MS = 2000
BRIDGE_URL = "http://127.0.0.1:8773/"

# The unit that IS the sound. Restarting it is the one destructive thing this
# menu can do, so it gets its own confirm step (see PerfTray.on_sc_clicked).
SC_UNIT = "parvagues-sc"
SC_CONFIRM_MS = 10_000          # how long an armed restart stays armed
SC_READY_S = 30                 # measured 2026-08-14: active→responder armed in 27 s


def sc_state():
    """(active, seconds_up) for the SuperDirt unit. Never raises — the tray is
    non-essential and a status label must not be able to take the menu down."""
    try:
        r = subprocess.run(
            ["systemctl", "--user", "show", SC_UNIT,
             "-p", "ActiveState", "-p", "ActiveEnterTimestampMonotonic"],
            capture_output=True, text=True, timeout=3,
        )
        kv = dict(ln.split("=", 1) for ln in r.stdout.splitlines() if "=" in ln)
        active = kv.get("ActiveState") == "active"
        # Monotonic, so it survives a wall-clock jump; same epoch as
        # time.monotonic() (both CLOCK_MONOTONIC) on Linux.
        us = int(kv.get("ActiveEnterTimestampMonotonic") or 0)
        up = time.monotonic() - us / 1e6 if (active and us) else None
        return active, up
    except (OSError, subprocess.SubprocessError, ValueError):
        return None, None


def fmt_dur(s):
    if s is None:
        return "?"
    s = int(s)
    if s < 90:
        return f"{s}s"
    if s < 5400:
        return f"{s // 60}m"
    return f"{s // 3600}h{(s % 3600) // 60:02d}m"


def autostart_enabled():
    """True if the perf-tray systemd --user service is enabled (starts at login)."""
    try:
        r = subprocess.run(
            ["systemctl", "--user", "is-enabled", SERVICE],
            capture_output=True, text=True, timeout=3,
        )
        return r.stdout.strip() == "enabled"
    except (OSError, subprocess.SubprocessError):
        return False


def set_autostart(on):
    """Enable/disable login autostart. Does not stop a running instance."""
    try:
        subprocess.run(
            ["systemctl", "--user", "enable" if on else "disable", SERVICE],
            capture_output=True, text=True, timeout=5,
        )
    except (OSError, subprocess.SubprocessError):
        pass


def temp_color(c):
    if c is None:
        return QColor("#607d8b")
    if c < 55:
        return QColor("#2e7d32")   # green
    if c < 70:
        return QColor("#f9a825")   # amber
    if c < 85:
        return QColor("#ef6c00")   # orange
    return QColor("#c62828")       # red


def make_icon(temp_c, hist=None):
    """Heat-coloured badge + the temp number over a REAL sparkline of the recent
    package-temp history (the wave is live data now, not a decorative sine)."""
    px = QPixmap(64, 64)
    px.fill(Qt.transparent)
    p = QPainter(px)
    p.setRenderHint(QPainter.Antialiasing)

    # heat-coloured rounded badge
    p.setBrush(temp_color(temp_c))
    p.setPen(Qt.NoPen)
    p.drawRoundedRect(2, 2, 60, 60, 14, 14)

    # live temp-history sparkline across the lower band (auto-scaled to its
    # own min/max + a little pad, so small wiggles are still legible)
    pts = [h for h in (hist or []) if h is not None]
    if len(pts) >= 2:
        lo, hi = min(pts), max(pts)
        if hi - lo < 6:
            mid = (hi + lo) / 2
            lo, hi = mid - 3, mid + 3
        y_bot, y_top = 59, 40
        path = QPainterPath()
        n = len(pts)
        for i, v in enumerate(pts):
            x = 5 + (i / (n - 1)) * 54
            y = y_bot - (v - lo) / (hi - lo) * (y_bot - y_top)
            (path.moveTo if i == 0 else path.lineTo)(x, y)
        pen = QPen(QColor(255, 255, 255, 180))
        pen.setWidth(2)
        pen.setCapStyle(Qt.RoundCap)
        pen.setJoinStyle(Qt.RoundJoin)
        p.setPen(pen)
        p.drawPath(path)

    # temp number in the upper area, clear of the sparkline
    p.setPen(QColor("white"))
    f = QFont()
    f.setBold(True)
    f.setPixelSize(30 if (temp_c is not None and temp_c < 100) else 24)
    p.setFont(f)
    p.drawText(QRect(0, 0, 64, 38), Qt.AlignCenter, "--" if temp_c is None else str(temp_c))
    p.end()
    return QIcon(px)


class PerfTray:
    def __init__(self, app):
        self.app = app
        self.therm = Thermals()
        self.hist = []                       # rolling package-temp history for the icon sparkline
        self.tray = QSystemTrayIcon(make_icon(self.therm.package_c(), self.hist))
        self.tray.setToolTip("perf-tray")

        self.menu = QMenu()
        self._build_menu()
        self.tray.setContextMenu(self.menu)
        self.tray.show()

        self.timer = QTimer()
        self.timer.timeout.connect(self.refresh)
        self.timer.start(REFRESH_MS)
        self.refresh()

    def _build_menu(self):
        header = QAction("⚡ perf.sh modes", self.menu)
        header.setEnabled(False)
        self.menu.addAction(header)

        self.mode_group = QActionGroup(self.menu)
        self.mode_group.setExclusive(True)
        self.mode_actions = {}
        for key, (label, _flag) in MODES.items():
            act = QAction(label, self.menu, checkable=True)
            act.triggered.connect(lambda _c, k=key: self.set_mode(k))
            self.mode_group.addAction(act)
            self.menu.addAction(act)
            self.mode_actions[key] = act

        self.menu.addSeparator()
        bridge_act = QAction("⚓ Open Bridge", self.menu)
        bridge_act.triggered.connect(lambda: self._open_url(BRIDGE_URL))
        self.menu.addAction(bridge_act)

        # PLN, 2026-08-03: "below open bridge, i want a 'Launch Gig' button that
        # opens and focuses auto the parvagues".
        #
        # Pulsar was already reachable, but four clicks down (Gear ▸ Pulsar) and
        # — worse — clicking it while Pulsar was ALREADY UP did nothing but
        # print "already-running", because launchers.py assumed Wayland could
        # not focus a window. It can, through KWin's scripting bus; see
        # launchers.focus_window. So this is a top-level entry AND a real focus.
        #
        # Deliberately editor-only: it does not start SuperCollider, boot Tidal
        # or arm Ardour. #116 is the standing rule that the hot path must not be
        # able to start the sound by accident — one wrong click before a set is
        # a stuck scsynth, not a convenience.
        self.gig_act = QAction("🌊 Launch Gig", self.menu)
        self.gig_act.triggered.connect(lambda: self._launch("pulsar"))
        self.menu.addAction(self.gig_act)

        # PLN, 2026-08-14: "add a restart SC command in parvagues tray gui menu".
        #
        # This is the only entry in the menu that can STOP THE SOUND, and it sits
        # two pixels under "Launch Gig" — so it is deliberately not a one-click
        # action while the rig is up. First click ARMS it and says what it costs;
        # a second click, which means opening the menu again, actually restarts.
        # The arming lapses after ten seconds. That is the mirror of #116 ("the
        # hot path cannot start the sound by accident") applied to the opposite
        # accident, and it is why there is no confirmation DIALOG: a modal box
        # stealing focus mid-set is its own hazard (#136).
        #
        # When the unit is DOWN there is nothing to interrupt, so starting it is
        # a single click. Only a restart-while-playing needs the guard.
        #
        # Why it is wanted at all: the SuperCollider boot is the only way to arm
        # the sample-watcher responder, and to pick up sample packs dropped since
        # boot — a rig running headless under systemd has no post window and no
        # eval-over-OSC endpoint, so a restart is the ONLY way in.
        self.sc_act = QAction("↻ Restart SuperDirt", self.menu)
        self.sc_act.triggered.connect(self.on_sc_clicked)
        self.menu.addAction(self.sc_act)
        self._sc_armed = False
        self._sc_restart_at = None      # monotonic time of the last restart we fired
        self.menu.aboutToShow.connect(self.refresh_sc)

        # #69 — the menu offered to LAUNCH things without ever saying what was
        # already up. The state was there all along (launchers.is_running), but
        # _build_menu ran once at startup and read only `available`, so a running
        # Ardour looked identical to a stopped one — and the safe action ("do
        # nothing, it's up") looked identical to the dangerous one.
        self.gear_menu = self.menu.addMenu("Gear ▸")
        self.gear_actions = {}
        snap = LA.snapshot()
        for item in (*snap["web"], *snap["apps"]):
            act = QAction(item["name"], self.gear_menu)
            act.triggered.connect(lambda _c, k=item["key"]: self._launch(k))
            self.gear_menu.addAction(act)
            self.gear_actions[item["key"]] = act
        self._apply_gear(snap)
        # Gear state is refreshed on menu-open ONLY, never on the 2s timer: reading
        # it walks /proc, and doing that 30x a minute for a label nobody is looking
        # at is the per-tick cost this rig keeps getting bitten by.
        self.menu.aboutToShow.connect(self.refresh_gear)

        self.menu.addSeparator()
        self.autostart_action = QAction("Start at login", self.menu, checkable=True)
        self.autostart_action.toggled.connect(self.on_autostart_toggled)
        self.menu.addAction(self.autostart_action)

        self.menu.addSeparator()
        # Live stats block — refreshed whenever the menu is about to show.
        self.stat_actions = []
        for _ in range(4):
            a = QAction("", self.menu)
            a.setEnabled(False)
            self.menu.addAction(a)
            self.stat_actions.append(a)
        self.menu.aboutToShow.connect(self.refresh)

        self.menu.addSeparator()
        quit_act = QAction("Quit perf-tray", self.menu)
        quit_act.triggered.connect(self.app.quit)
        self.menu.addAction(quit_act)

    def set_mode(self, key):
        flag = MODES[key][1]
        if not os.path.exists(SCRIPT):
            self.tray.showMessage(
                "perf-tray: not deployed",
                f"{SCRIPT} missing. Deploy it:\n"
                f"sudo install -m755 -o root -g root "
                f"~/Work/Sound/Tidal/perf.sh {SCRIPT}",
                QSystemTrayIcon.Warning, 8000,
            )
            return
        proc = QProcess(self.app)
        proc.setProgram("sudo")
        proc.setArguments(["-n", SCRIPT, flag])

        def done(_code, _status):
            err = bytes(proc.readAllStandardError()).decode(errors="replace")
            if proc.exitCode() != 0:
                self.tray.showMessage(
                    "perf-tray: mode change failed",
                    err.strip() or "sudo -n refused (check perf-audio.sudoers)",
                    QSystemTrayIcon.Critical, 8000,
                )
            else:
                # Record intent so The Bridge's watcher reasserts THIS mode across
                # AC/battery flips & resume (and never fights a deliberate switch).
                write_desired(key)
            QTimer.singleShot(400, self.refresh)

        proc.finished.connect(done)
        proc.start()

    # Glyphs, not colour: a tray menu inherits the desktop palette, so colour is not
    # reliably legible, and PLN reads this in a dark room seconds before playing.
    GEAR_GLYPH = {"running": "●", "stopped": "○", "missing": "✗"}

    def _apply_gear(self, snap):
        """Label every gear entry with what it IS, not just what it could become."""
        up = 0
        total = 0
        for item in (*snap["web"], *snap["apps"]):
            act = self.gear_actions.get(item["key"])
            if act is None:
                continue
            total += 1
            if not item["available"]:
                state = "missing"
            elif item["running"]:
                state = "running"
                up += 1
            else:
                state = "stopped"
            suffix = {"running": " — running", "stopped": "", "missing": " — not installed"}
            act.setText(f"{self.GEAR_GLYPH[state]} {item['name']}{suffix[state]}")
            # A running NATIVE app used to be greyed out, because launch() could
            # only refuse: there was no window focus on Wayland, so the only
            # honest thing a click could do was print "already-running". Now that
            # launchers.focus_window drives KWin's scripting bus, clicking a
            # running app RAISES it — which is the thing you actually want mid-set
            # — so the row stays live. Only a missing binary is unclickable.
            act.setEnabled(item["available"])
        self.gear_menu.setTitle(f"Gear ▸  {up}/{total} up")
        if getattr(self, "gig_act", None) is not None:
            pulsar = next((i for i in snap["apps"] if i["key"] == "pulsar"), None)
            # Say which of the two things the click will do. "Focus" vs "Open" is
            # the difference between a no-op-looking click and a confident one.
            self.gig_act.setText("🌊 Launch Gig — focus ParVagues"
                                 if pulsar and pulsar["running"]
                                 else "🌊 Launch Gig — open ParVagues")
            self.gig_act.setEnabled(bool(pulsar and pulsar["available"]))

    # ------------------------------------------------------------ SuperDirt
    #
    # Read on menu-open only, never on the 2 s timer: this shells out to
    # systemctl, and doing that 30x a minute for a label nobody is looking at is
    # the per-tick cost this rig keeps getting bitten by.

    def refresh_sc(self):
        try:
            active, up = sc_state()
            if self._sc_armed:
                self.sc_act.setText("⚠ Restart SuperDirt — click again to CUT THE SOUND")
                return
            if active is None:
                self.sc_act.setText("↻ Restart SuperDirt — state unknown")
            elif not active:
                self.sc_act.setText("○ Start SuperDirt — down")
            elif up is not None and up < SC_READY_S:
                # `active` fires at exec; the banks then register alphabetically
                # and the rig is not playable until the end of that. Say which of
                # the two "up"s this is, so a silent rig is not a mystery.
                self.sc_act.setText(
                    f"◐ SuperDirt booting — {fmt_dur(up)} of ~{SC_READY_S}s")
            else:
                self.sc_act.setText(f"● Restart SuperDirt — up {fmt_dur(up)}")
        except Exception:
            pass

    def on_sc_clicked(self):
        active, _ = sc_state()
        if active and not self._sc_armed:
            self._sc_armed = True
            QTimer.singleShot(SC_CONFIRM_MS, self._disarm_sc)
            self.tray.showMessage(
                "perf-tray: restart armed",
                "SuperDirt is UP. Restarting stops the sound for ~30 s.\n"
                "Open the menu and click again within 10 s to confirm.",
                QSystemTrayIcon.Warning, SC_CONFIRM_MS,
            )
            self.refresh_sc()
            return
        self._disarm_sc()
        self._sc_restart(was_up=bool(active))

    def _disarm_sc(self):
        self._sc_armed = False
        self.refresh_sc()

    def _sc_restart(self, was_up):
        # QProcess, not subprocess.run: a restart takes a couple of seconds to
        # return and the tray must not freeze while it does.
        proc = QProcess(self.app)
        proc.setProgram("systemctl")
        proc.setArguments(["--user", "restart", SC_UNIT])

        def done(_code, _status):
            err = bytes(proc.readAllStandardError()).decode(errors="replace")
            if proc.exitCode() != 0:
                self.tray.showMessage(
                    "perf-tray: SuperDirt restart failed",
                    err.strip() or f"systemctl --user restart {SC_UNIT} failed",
                    QSystemTrayIcon.Critical, 8000,
                )
            else:
                self._sc_restart_at = time.monotonic()
                self.tray.showMessage(
                    "perf-tray: SuperDirt restarting",
                    f"{'Sound stopped. ' if was_up else ''}Banks register "
                    f"alphabetically — playable in ~{SC_READY_S}s.\n"
                    "Any sample packs added since the last boot come up with it.",
                    QSystemTrayIcon.Information, 6000,
                )
            self.refresh_sc()

        proc.finished.connect(done)
        proc.start()

    def refresh_gear(self):
        try:
            self._apply_gear(LA.snapshot())
        except Exception:
            # The tray is non-essential: a status label must never take the menu down.
            pass

    def _open_url(self, url):
        QProcess.startDetached("xdg-open", [url])

    def _launch(self, key):
        r = LA.launch(key)
        if r["ok"] and r.get("url"):
            QTimer.singleShot(1300 if r["status"] == "launched" else 0,
                              lambda: self._open_url(r["url"]))
        if not r["ok"]:
            self.tray.showMessage("perf-tray", r["msg"], QSystemTrayIcon.Warning, 5000)

    def on_autostart_toggled(self, on):
        set_autostart(on)
        QTimer.singleShot(200, self.refresh)

    def refresh(self):
        t = self.therm
        pkg = t.package_c()
        self.hist.append(pkg)
        if len(self.hist) > 24:              # ~48s of history at the 2s refresh
            self.hist.pop(0)
        self.tray.setIcon(make_icon(pkg, self.hist))

        mode = detect_mode()
        if mode in self.mode_actions:
            self.mode_actions[mode].setChecked(True)

        # Reflect autostart state without re-triggering the toggle handler.
        self.autostart_action.blockSignals(True)
        self.autostart_action.setChecked(autostart_enabled())
        self.autostart_action.blockSignals(False)

        fmax, favg = t.freq_mhz()
        fans = t.fans_rpm()
        cores = t.cores_c()
        hottest = max(cores) if cores else pkg
        w = power_caps_w()
        g = dgpu_state()
        gm = gpu_manager_mode()
        cap = f"{w['pl1']}/{w['pl2']}W" if w.get("pl1") is not None else "–"
        if not g.get("present"):
            gpu = "none"
        elif gm == "integrated":
            # config'd off; live status stays active until re-login (↻ = pending)
            gpu = "off" if g.get("status") == "suspended" else "off↻"
        else:
            gpu = "asleep" if g.get("status") == "suspended" else "awake"
            if gm:
                gpu = f"{gpu}·{gm}"
        td = "on" if thermald_active() else "off"
        label = MODES.get(mode, ("?",))[0]

        tip = (
            f"perf-tray — mode: {label}  ·  cap {cap}\n"
            f"package {pkg}°C  (hottest core {hottest}°C)\n"
            f"freq {favg}/{fmax} MHz avg/max · fans {' / '.join(str(f) for f in fans) or 'n/a'} RPM\n"
            f"gpu {gpu} · thermald {td} · throttle Δ {t.throttle_delta()}"
        )
        self.tray.setToolTip(tip)

        rows = [
            f"mode: {label}  ·  cap {cap}",
            f"package {pkg}°C · hottest core {hottest}°C",
            f"freq {favg}/{fmax} MHz · fans {'/'.join(str(f) for f in fans) or 'n/a'} RPM",
            f"gpu {gpu} · thermald {td} · throttle Δ {t.throttle_delta()}",
        ]
        for a, txt in zip(self.stat_actions, rows):
            a.setText(txt)


def main():
    app = QApplication([])
    app.setQuitOnLastWindowClosed(False)
    if not QSystemTrayIcon.isSystemTrayAvailable():
        print("No system tray available in this session.")
        return 1
    PerfTray(app)
    return app.exec_()


if __name__ == "__main__":
    raise SystemExit(main())
