"""perf — rootless thermal/perf-mode reader + sudo-backed mode switch.

Ported from perf-tray.py (the PyQt tray) with the Qt dropped, so the same proven
logic backs the web Bridge. Reading thermals/mode is rootless (hwmon + cpufreq
sysfs); only *changing* mode needs root, done via `sudo -n` against a ROOT-OWNED,
sudoers-whitelisted copy of perf.sh at /usr/local/sbin/perf-audio (see
perf-audio.sudoers) — never the user-editable repo copy.

DRY note: perf-tray.py still has its own copy of Thermals/detect_mode; a later
pass can have it import this module. Kept separate now to avoid touching the
working tray.
"""
from __future__ import annotations

import glob
import os
import subprocess
import time

# Root-owned, sudoers-whitelisted deployment of perf.sh (same as perf-tray).
SCRIPT = os.environ.get("PERF_TRAY_SCRIPT", "/usr/local/sbin/perf-audio")

# mode key -> (label, perf.sh flag). Order = coolest → hottest → normal; the
# tray menu and Bridge toolbar both iterate this, so a new mode lights up in
# both faces automatically.
MODES = {
    "silent":   ("Silent",   "--silent"),
    "cool":     ("Cool",     "--cool"),
    "standard": ("Standard", "--optimize"),
    "extreme":  ("Extreme",  "--extreme"),
    "normal":   ("Normal",   "--stop"),
}

# temp thresholds (°C) → state name; the UI maps state→color (matches tray).
TEMP_STATES = ((55, "cool"), (70, "warm"), (85, "hot"), (10_000, "critical"))


def _read(path, default=None):
    # path can legitimately be None: the hwmon probes return None when a chip is
    # absent (no coretemp on AMD, no dell_smm on non-Dell), and callers pass that
    # straight back in. open(None) raises TypeError, not OSError, so without this
    # guard a missing sensor crashes the reader instead of reading as "unknown".
    if not path:
        return default
    try:
        with open(path) as f:
            return f.read().strip()
    except OSError:
        return default


def _read_int(path, default=None):
    v = _read(path)
    try:
        return int(v)
    except (TypeError, ValueError):
        return default


def _hwmon_by_name(name):
    for d in glob.glob("/sys/class/hwmon/hwmon*"):
        if _read(os.path.join(d, "name")) == name:
            return d
    return None


def _cpu_jiffies():
    """Per-logical-cpu (busy_total, idle_total) jiffies from /proc/stat."""
    out = {}
    try:
        with open("/proc/stat") as f:
            for line in f:
                if not (line.startswith("cpu") and len(line) > 3 and line[3].isdigit()):
                    continue
                parts = line.split()
                vals = list(map(int, parts[1:]))
                idle = vals[3] + (vals[4] if len(vals) > 4 else 0)  # idle + iowait
                out[int(parts[0][3:])] = (sum(vals), idle)
    except (OSError, ValueError, IndexError):
        pass
    return out


def dgpu_state():
    """Discrete (non-Intel) GPU power state, or {'present': False}."""
    for d in glob.glob("/sys/bus/pci/devices/*"):
        cls = _read(os.path.join(d, "class")) or ""
        if not (cls.startswith("0x0300") or cls.startswith("0x0302")):
            continue
        if (_read(os.path.join(d, "vendor")) or "") == "0x8086":
            continue  # skip the Intel iGPU
        return {
            "present": True,
            "status": _read(os.path.join(d, "power/runtime_status")),  # active|suspended
            "control": _read(os.path.join(d, "power/control")),        # auto|on
        }
    return {"present": False}


def thermald_active():
    """True if the thermald process is running (cheap /proc scan, no fork)."""
    for c in glob.glob("/proc/[0-9]*/comm"):
        if (_read(c) or "") == "thermald":
            return True
    return False


# EnvyControl mode is a *persistent* GPU config (distinct from dgpu_state()'s live
# runtime power). Switching to 'integrated' powers the dGPU fully off, but only
# after a re-login — so within a login session the value never changes and we
# cache it (a re-login restarts this --user service, refreshing the cache anyway).
_GPU_MODE_CACHE = {"val": None, "ts": 0.0, "done": False}
_GPU_MODE_TTL = 300  # s


def gpu_manager_mode():
    """EnvyControl graphics mode: 'integrated' (dGPU off) | 'hybrid' | 'nvidia',
    or None if envycontrol is absent/errors (the cockpit just omits the chip).
    Cached for _GPU_MODE_TTL to avoid spawning envycontrol on every refresh."""
    now = time.monotonic()
    if _GPU_MODE_CACHE["done"] and now - _GPU_MODE_CACHE["ts"] < _GPU_MODE_TTL:
        return _GPU_MODE_CACHE["val"]
    val = None
    try:
        r = subprocess.run(["envycontrol", "--query"],
                           capture_output=True, text=True, timeout=5)
        if r.returncode == 0:
            out = (r.stdout or "").strip().lower()
            val = next((m for m in ("integrated", "hybrid", "nvidia") if m in out), None)
    except (OSError, subprocess.SubprocessError):
        val = None
    _GPU_MODE_CACHE.update(val=val, ts=now, done=True)
    return val


_RAPL = "/sys/class/powercap/intel-rapl:0"


def power_caps_w():
    """Current RAPL package caps in watts (PL1 sustained / PL2 burst)."""

    def w(c):
        v = _read_int(f"{_RAPL}/constraint_{c}_power_limit_uw")
        return round(v / 1_000_000) if v else None

    return {"pl1": w(0), "pl2": w(1)}


def temp_state(c):
    if c is None:
        return "unknown"
    for ceiling, state in TEMP_STATES:
        if c < ceiling:
            return state
    return "critical"


class Thermals:
    """Rootless reader for package/core temps, fans, freq and throttle counts."""

    def __init__(self):
        self.coretemp = _hwmon_by_name("coretemp")
        self.dell = _hwmon_by_name("dell_smm")
        self._pkg_input = self._find_label_input(self.coretemp, "Package id 0")
        self._throttle_base = self._throttle_sum()
        self._prev_jiffies = _cpu_jiffies()   # seed for per-core util deltas
        self._e_prev = _read_int(f"{_RAPL}/energy_uj")   # None if root-only (usual)
        self._e_t = time.monotonic()
        self._e_max = _read_int(f"{_RAPL}/max_energy_range_uj")

    def _find_label_input(self, chip, label):
        if not chip:
            return None
        for lbl in glob.glob(os.path.join(chip, "temp*_label")):
            if _read(lbl) == label:
                return lbl.replace("_label", "_input")
        t1 = os.path.join(chip, "temp1_input")
        return t1 if os.path.exists(t1) else None

    def package_c(self):
        v = _read_int(self._pkg_input)
        return round(v / 1000) if v is not None else None

    def cores_c(self):
        out = []
        if self.coretemp:
            for lbl in sorted(glob.glob(os.path.join(self.coretemp, "temp*_label"))):
                if (_read(lbl) or "").startswith("Core"):
                    v = _read_int(lbl.replace("_label", "_input"))
                    if v is not None:
                        out.append(round(v / 1000))
        return out

    def fans_rpm(self):
        out = []
        if self.dell:
            for fi in sorted(glob.glob(os.path.join(self.dell, "fan*_input"))):
                v = _read_int(fi)
                if v is not None:
                    out.append(v)
        return out

    def freq_mhz(self):
        freqs = [_read_int(p) for p in
                 glob.glob("/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq")]
        freqs = [f for f in freqs if f]
        if not freqs:
            return (None, None)
        return (round(max(freqs) / 1000), round(sum(freqs) / len(freqs) / 1000))

    def _throttle_sum(self):
        total = 0
        for p in glob.glob(
                "/sys/devices/system/cpu/cpu*/thermal_throttle/core_throttle_count"):
            total += _read_int(p, 0) or 0
        return total

    def throttle_delta(self):
        return self._throttle_sum() - self._throttle_base

    def power_draw_w(self):
        """Actual package power draw (W) from the RAPL energy counter, or None
        if energy_uj isn't readable (root-only on most kernels — a udev rule can
        open it, and this then lights up automatically)."""
        cur = _read_int(f"{_RAPL}/energy_uj")
        now = time.monotonic()
        prev, pt = self._e_prev, self._e_t
        self._e_prev, self._e_t = cur, now
        if cur is None or prev is None:
            return None
        dt, de = now - pt, cur - prev
        if de < 0 and self._e_max:      # counter wrapped
            de += self._e_max
        if dt <= 0 or de < 0:
            return None
        return round(de / dt / 1_000_000, 1)   # µJ/s → W

    def cpu_pct(self):
        """Per-logical-cpu utilization 0-100 since the previous call (the
        'dancing cores'). First call after init returns [] until there's a
        delta to diff against."""
        cur = _cpu_jiffies()
        prev, self._prev_jiffies = self._prev_jiffies, cur
        out = []
        for n in sorted(cur):
            if n not in prev:
                continue
            dt = cur[n][0] - prev[n][0]
            di = cur[n][1] - prev[n][1]
            out.append(max(0, min(100, round(100 * (dt - di) / dt))) if dt > 0 else 0)
        return out


def detect_mode():
    gov = _read("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
    epp = _read("/sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference")
    maxp = _read_int("/sys/devices/system/cpu/intel_pstate/max_perf_pct", 100)
    minp = _read_int("/sys/devices/system/cpu/intel_pstate/min_perf_pct", 0)
    if gov == "performance" and minp >= 100:
        return "extreme"
    if gov == "performance":
        return "standard"
    # Silent and cool are both powersave + capped clock; the EPP tells them apart
    # (silent biases all the way to "power", cool keeps "balance_performance").
    if gov == "powersave" and epp in ("power", "balance_power") and (maxp or 100) < 100:
        return "silent"
    if epp == "balance_performance" and (maxp or 100) < 100:
        return "cool"
    return "normal"


def script_available():
    """True if the sudoers-whitelisted root perf.sh is deployed."""
    return os.path.exists(SCRIPT)


def set_mode(mode):
    """Switch perf mode via `sudo -n`. Returns (ok, message)."""
    if mode not in MODES:
        return False, f"unknown mode {mode!r}; have {sorted(MODES)}"
    if not script_available():
        return False, (f"{SCRIPT} not deployed — install the root-owned perf.sh + "
                       "sudoers rule (see README 'Deploy mode-switching')")
    flag = MODES[mode][1]
    try:
        r = subprocess.run(["sudo", "-n", SCRIPT, flag],
                           capture_output=True, text=True, timeout=30)
    except (OSError, subprocess.SubprocessError) as e:
        return False, str(e)
    if r.returncode != 0:
        return False, (r.stderr or r.stdout or f"exit {r.returncode}").strip()
    write_desired(mode)  # record intent so the watcher can reassert it (below)
    return True, (r.stdout or "ok").strip()


# --- power-aware desired-state maintenance -----------------------------------
# One chosen regime, reasserted whenever the platform silently resets the pstate
# caps underneath us. The trigger that started this: plugging in on AC lifts the
# battery turbo limit, so a "cool" set is quietly undone and temps jump ~25°C.
# Also fires on resume-from-suspend. The Bridge daemon runs run_watcher().
#
# "desired" is the mode the user actually chose (written by set_mode on both the
# tray and web faces). desired absent or "normal" == maintenance off, so we never
# fight a deliberate switch back to platform defaults.

DESIRED_PATH = os.environ.get(
    "PERF_DESIRED_FILE", os.path.expanduser("~/.cache/parvagues/perf-desired"))


def read_desired():
    """The mode to keep asserted, or None if maintenance is off."""
    m = _read(DESIRED_PATH)
    return m if m in MODES else None


def write_desired(mode):
    """Persist the chosen regime (called on every successful mode switch)."""
    if mode not in MODES:
        return
    try:
        os.makedirs(os.path.dirname(DESIRED_PATH) or ".", exist_ok=True)
        with open(DESIRED_PATH, "w") as f:
            f.write(mode)
    except OSError:
        pass


def on_ac():
    """True on AC, False on battery, None if no mains adapter is exposed."""
    for p in sorted(glob.glob("/sys/class/power_supply/A*/online")):
        v = _read_int(p)
        if v is not None:
            return bool(v)
    return None


def reconcile(force=False):
    """Reassert the desired mode if live state has drifted from it. Returns
    (acted, message). desired absent/"normal" == maintenance off."""
    desired = read_desired()
    if not desired or desired == "normal":
        return False, "maintenance off"
    if not force and detect_mode() == desired:
        return False, f"{desired} holding"
    ok, msg = set_mode(desired)
    return ok, (f"reasserted {desired} ({msg})" if ok else f"reassert FAILED ({msg})")


def run_watcher(interval=5.0, drift_every=6, log=None):
    """Daemon loop: keep the desired perf regime across AC/battery flips and
    resume-from-suspend. Reasserts on a power-source edge and on resume (both
    force), plus a periodic drift check as the safety net. Blocks; run in a
    thread. `log` is a callable(str); defaults to stdout."""
    emit = log or (lambda s: print(f"[perf-watch] {s}", flush=True))
    if read_desired() is None:              # seed from whatever is live right now
        cur = detect_mode()
        if cur in MODES and cur != "normal":
            write_desired(cur)
    # BOOTTIME (not MONOTONIC) counts time spent suspended, so a resume shows up
    # as a big gap here — MONOTONIC freezes across suspend and would miss it,
    # leaving only the slow periodic drift check to catch a post-resume reset.
    _bt = getattr(time, "CLOCK_BOOTTIME", None)
    uptime = (lambda: time.clock_gettime(_bt)) if _bt is not None else time.monotonic
    emit(f"watching (every {interval}s) on_ac={on_ac()} desired={read_desired()}")
    last_ac = on_ac()
    last_clock = uptime()
    i = 0
    while True:
        time.sleep(interval)
        i += 1
        now = uptime()
        resumed = (now - last_clock) > interval * 3   # gap ≫ sleep ⇒ we were suspended
        last_clock = now
        ac = on_ac()
        edge = ac is not None and last_ac is not None and ac != last_ac
        if edge or resumed:
            reason = ("power→" + ("AC" if ac else "battery")) if edge else "resume"
            time.sleep(2)                             # let the platform settle
            _, msg = reconcile(force=True)
            emit(f"{reason}: {msg}")
        elif i % drift_every == 0:
            acted, msg = reconcile(force=False)
            if acted:
                emit(f"drift: {msg}")
        last_ac = ac if ac is not None else last_ac


def snapshot(therm: "Thermals | None" = None) -> dict:
    """One JSON-ready reading of mode + thermals (the /api/perf payload)."""
    t = therm or Thermals()
    pkg = t.package_c()
    fmax, favg = t.freq_mhz()
    return {
        "mode": detect_mode(),
        "package_c": pkg,
        "temp_state": temp_state(pkg),
        "cores_c": t.cores_c(),
        "cores_pct": t.cpu_pct(),
        "fans_rpm": t.fans_rpm(),
        "freq_max_mhz": fmax,
        "freq_avg_mhz": favg,
        "throttle_delta": t.throttle_delta(),
        "power_w": t.power_draw_w(),
        "watts": power_caps_w(),
        "dgpu": dgpu_state(),
        "gpu_manager": gpu_manager_mode(),
        "thermald": thermald_active(),
        "script_available": script_available(),
        "modes": {k: v[0] for k, v in MODES.items()},
    }
