#!/usr/bin/env python3
"""
perf-tray — system-tray face of the thermal GEARBOX (SRE/thermal/GEARBOX.md).

Shows live thermals in the tray (a heat-coloured badge with a real temp sparkline)
and lets you move the gear lever without leaving your livecoding session.

This is THE gear UI. PLN, 2026-08-21: "i dont use the webui most of the time,
tray indicator is the only ui i use for parvagues gear bro" — hands on keyboard
and LCXL, eyes on Pulsar and the room, so a browser tab is a context switch that
does not happen mid-set. Everything the gearbox can do has to be reachable here.

Design notes
------------
* TWO AXES, never one list of modes: GEAR (off/standard/aggressive) is how hard
  the machine may work, TRIM (quiet/auto/cool) is how the heat gets paid for, and
  trim applies in every gear. Collapsing them back into five flat "modes" is the
  exact mistake GEARBOX.md exists to undo — the old menu here did that, offering
  Silent/Cool/Standard/Extreme/Normal as if quiet and cool were the same wish.
* The gearbox ENGINE is shell in the SRE repo; `tools/bridge/gearbox.py` is the
  shared client both this tray and the web Bridge speak, so the two faces cannot
  drift on what a gear is called. Reading state is rootless (a plain file); only
  switching needs root, via `sudo -n` against the root-owned, exact-args
  sudoers-whitelisted helper.
* It does NOT write governor/EPP/platform_profile itself. That is the point: four
  independent writers of the same knobs (perf.sh, perf.py, thermal-mode, gig-up,
  plus power-profiles-daemon making five) is what the gearbox replaced, and a
  tray that reached around the arbiter would rebuild the mess in one click.
* 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 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, QRectF

# 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, dgpu_state, thermald_active,  # noqa: E402
                  power_caps_w, gpu_manager_mode)
import gearbox as GB  # noqa: E402
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


# The gear, drawn as the badge's BORDER: width = gear, colour = trim.
#
# Both axes in one element, because a tray icon is ~22 px on this panel and any
# design with two separate marks turns to mush at that size. Width is the axis
# that has to survive a dark room and a peripheral glance ("am I in the gig gear?"
# is the question asked mid-set), so gear gets the SHAPE cue and trim, which is
# set-and-forget, gets the colour. Off draws no ring at all: a bare badge is the
# honest picture of nothing steering the machine.
# Widths are DELIBERATELY heavy. The first version used 2 px and 5 px on the 64 px
# pixmap, which looked right in a mockup and vanished on the panel: Qt renders the
# icon at ~22 px, so a 2 px stroke becomes 0.7 px of grey mush. Verified by
# rendering every combination at 20/24/32 px before believing it — a tray cue that
# only works at 64 px is a cue that does not work.
GEAR_RING_PX = {"off": 0, "standard": 6, "aggressive": 11}
TRIM_INK = {"quiet": "#9fa8da",   # indigo — the fans are being held back
            "auto": "#eceff1",    # bone   — platform's choice
            "cool": "#4dd0e1"}    # cyan   — the fans are allowed to work


def make_icon(temp_c, hist=None, gear=None, trim=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),
    ringed by the gear + trim so the lever is visible with the menu shut."""
    ring_px = GEAR_RING_PX.get(gear or "", 0)
    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 - ring_px // 2, 40 + ring_px // 3
        path = QPainterPath()
        n = len(pts)
        for i, v in enumerate(pts):
            x = 5 + ring_px // 2 + (i / (n - 1)) * (54 - ring_px)
            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 — and clear of the
    # gear ring, which is why the size depends on the gear: an 11 px ring drawn
    # over a 30 px numeral clips the digits at panel size, and aggressive is
    # exactly the gear in which the temperature matters most.
    p.setPen(QColor("white"))
    f = QFont()
    f.setBold(True)
    f.setPixelSize((30 if (temp_c is not None and temp_c < 100) else 24) - ring_px // 2)
    p.setFont(f)
    p.drawText(QRect(ring_px, ring_px, 64 - 2 * ring_px, 38 - ring_px),
               Qt.AlignCenter, "--" if temp_c is None else str(temp_c))

    # gear ring, drawn last so nothing else can paint over it
    rw = ring_px
    if rw:
        pen = QPen(QColor(TRIM_INK.get(trim or "auto", TRIM_INK["auto"])))
        pen.setWidth(rw)
        p.setPen(pen)
        p.setBrush(Qt.NoBrush)
        # Inset by half the pen width: Qt strokes centred on the path, so a raw
        # 2,2,60,60 rect loses the outer half of a thick ring off the pixmap edge.
        i = rw / 2.0
        p.drawRoundedRect(QRectF(2 + i, 2 + i, 60 - rw, 60 - rw), 14 - i, 14 - i)

    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
        gb0 = GB.state()
        self.tray = QSystemTrayIcon(
            make_icon(self.therm.package_c(), self.hist, gb0["gear"], gb0["trim"]))
        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):
        # ── the gearbox: two axes, laid out as two axes ──────────────────────
        #
        # Both are INLINE rather than tucked in a submenu, and they are separated
        # by their own dim header, because the separation IS the design: a reader
        # who sees one flat list will keep believing there is a single "how fast"
        # dial and that "quiet" and "cool" are two words for it. Six rows is a
        # cheap price for a menu that teaches the model every time it opens.
        self.gear_header = QAction("⚙ gear", self.menu)
        self.gear_header.setEnabled(False)
        self.menu.addAction(self.gear_header)

        self.gear_group = QActionGroup(self.menu)
        self.gear_group.setExclusive(True)
        self.gear_actions = {}
        for key, (label, blurb) in GB.GEARS.items():
            act = QAction(f"{label} — {blurb}", self.menu, checkable=True)
            act.triggered.connect(lambda _c, k=key: self.set_gear(k))
            self.gear_group.addAction(act)
            self.menu.addAction(act)
            self.gear_actions[key] = act

        self.trim_header = QAction("🌡 trim — applies in every gear", self.menu)
        self.trim_header.setEnabled(False)
        self.menu.addAction(self.trim_header)

        self.trim_group = QActionGroup(self.menu)
        self.trim_group.setExclusive(True)
        self.trim_actions = {}
        for key, (label, blurb) in GB.TRIMS.items():
            act = QAction(f"{label} — {blurb}", self.menu, checkable=True)
            act.triggered.connect(lambda _c, k=key: self.set_trim(k))
            self.trim_group.addAction(act)
            self.menu.addAction(act)
            self.trim_actions[key] = act

        if not GB.installed():
            # A sibling repo that was never installed here is a legitimate state,
            # not an error — but a live-looking radio button that silently cannot
            # move is worse than a greyed one, so say it and grey them.
            self.gear_header.setText("⚙ gear — gearbox NOT INSTALLED")
            for act in (*self.gear_actions.values(), *self.trim_actions.values()):
                act.setEnabled(False)

        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.
        # Named "Rig ▸", not "Gear ▸": "gear" is now the thermal lever above, and
        # the web panel already calls this collection of processes the rig (RIG UP).
        self.rig_menu = self.menu.addMenu("Rig ▸")
        self.rig_actions = {}
        snap = LA.snapshot()
        for item in (*snap["web"], *snap["apps"]):
            act = QAction(item["name"], self.rig_menu)
            act.triggered.connect(lambda _c, k=item["key"]: self._launch(k))
            self.rig_menu.addAction(act)
            self.rig_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)

    # ------------------------------------------------------------ the gearbox

    def set_gear(self, key):
        self._shift("gear", key, GB.gear_argv(key))

    def set_trim(self, key):
        self._shift("trim", key, GB.trim_argv(key))

    def _shift(self, kind, key, argv):
        """Move one axis, through the arbiter, without blocking the tray.

        QProcess and not subprocess.run: the helper walks both RAPL zones, pushes
        a firmware thermal profile and (in the loud gears) re-priorities the audio
        chain, which takes about a second — long enough that a synchronous call
        would visibly freeze the menu on every click.

        No desired-state file is written here. The gearbox persists the gear
        itself and re-asserts it from a udev hook on power-source changes and from
        a system-sleep hook on resume, so a second bookkeeper in the tray could
        only ever disagree with the first. (perf-tray used to keep one, for a
        watcher that no longer exists.)
        """
        if not GB.installed():
            self.tray.showMessage(
                "perf-tray: no gearbox here",
                f"{GB.HELPER} missing. Install it:\n"
                f"cd ~/Work/SRE/thermal && sudo ./install.sh --reassert",
                QSystemTrayIcon.Warning, 8000,
            )
            return
        proc = QProcess(self.app)
        proc.setProgram(argv[0])
        proc.setArguments(argv[1:])

        def done(_code, _status):
            out = bytes(proc.readAllStandardOutput()).decode(errors="replace").strip()
            err = bytes(proc.readAllStandardError()).decode(errors="replace").strip()
            if proc.exitCode() != 0:
                self.tray.showMessage(
                    f"perf-tray: {kind} change failed",
                    err or out or "sudo -n refused (check /etc/sudoers.d/thermal-mode)",
                    QSystemTrayIcon.Critical, 8000,
                )
            else:
                # Show what the machine ACTUALLY got, not what was asked for:
                # `aggressive` sizes its watts from the live power source, so the
                # same click means 45/95 W on the barrel brick and 25/45 W on
                # battery. That number is the whole reason to read the toast.
                self.tray.showMessage(
                    f"perf-tray: {GB.label(kind, key)}",
                    out or err or f"{kind} = {key}",
                    QSystemTrayIcon.Information, 5000,
                )
            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.rig_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.rig_menu.setTitle(f"Rig ▸  {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)

        gb = GB.state()
        self.tray.setIcon(make_icon(pkg, self.hist, gb["gear"], gb["trim"]))

        # Check the gear the FILE says, never one inferred from sysfs. The old
        # menu inferred it (perf.detect_mode reads governor/EPP/max_perf_pct), and
        # inference cannot tell a deliberate gear from power-profiles-daemon
        # having quietly moved the same knobs — which it does, by design, since
        # the gearbox cooperates with ppd instead of masking it.
        for key, act in self.gear_actions.items():
            act.blockSignals(True)
            act.setChecked(key == gb["gear"])
            act.blockSignals(False)
        for key, act in self.trim_actions.items():
            act.blockSignals(True)
            act.setChecked(key == (gb["trim"] or GB.DEFAULT_TRIM))
            act.blockSignals(False)
        if GB.installed():
            self.gear_header.setText(
                "⚙ gear — " + (GB.label("gear", gb["gear"]) if gb["gear"]
                               else "none engaged (ppd owns the knobs)"))
            self.trim_header.setText(
                "🌡 trim — " + GB.label("trim", gb["trim"] or GB.DEFAULT_TRIM)
                + ("" if gb["trim"] else " (default)"))

        # 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 = GB.summary()

        tip = (
            f"perf-tray — gear: {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"gear: {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())
