"""
wivexx_sysmon.py — live system telemetry for the Performance HUD  (v1.0)
═══════════════════════════════════════════════════════════════════════
Our OWN monitor (no third-party apps): a light background thread samples the
machine ~1.5×/s and exposes the latest reading via snapshot().  Everything is
best-effort — any source that isn't available just returns None and the HUD
shows "—" for that metric.

Sources (all first-party / OS-built-in):
  • CPU %, per-core %, base clock, RAM        → psutil
  • GPU name / util / VRAM / temp / power / clk → nvidia-smi (ships with the
        NVIDIA driver; one batched query). AMD/Intel → gpu = None.
  • CPU package temp                           → Windows WMI thermal zone
        (MSAcpi_ThermalZoneTemperature, via win32com). Many desktop boards
        don't expose it → temp_c = None.
  • Net up/down throughput                     → psutil counter deltas
  • FPS                                        → reserved (own ETW counter, later)

Public API (called from wivexx_gui / wivexx_api):
    start(interval=1.5) -> bool
    stop()
    snapshot() -> dict
    speedtest(seconds=5.0) -> dict      # on-demand real up/down probe (later)
"""

import subprocess
import threading
import time
from typing import Optional

_thread: Optional[threading.Thread] = None
_stop_evt = threading.Event()
_lock = threading.Lock()
_snap: dict = {}
_interval = 1.5

_NOWIN = getattr(subprocess, "CREATE_NO_WINDOW", 0)

# ── GPU (nvidia-smi, batched) ────────────────────────────────────────────────
# one query pulls every field we show; cache whether the tool exists so we don't
# spawn a failing process every tick on non-NVIDIA machines.
_gpu_ok: Optional[bool] = None
_GPU_QUERY = ("name,utilization.gpu,memory.used,memory.total,"
              "temperature.gpu,power.draw,clocks.gr")


def _read_gpu() -> Optional[dict]:
    # _gpu_ok latches False ONLY when nvidia-smi is genuinely ABSENT (no NVIDIA GPU
    # / driver) — so we stop spawning a doomed process every tick. A TRANSIENT
    # failure (timeout, a busy driver returning non-zero — common while a game is
    # saturating the GPU) must NOT latch: it would blank GPU+VRAM for the whole
    # session. Those just return None for this tick and retry next tick. (v1.54)
    global _gpu_ok
    if _gpu_ok is False:
        return None
    try:
        out = subprocess.run(
            ["nvidia-smi", f"--query-gpu={_GPU_QUERY}",
             "--format=csv,noheader,nounits"],
            capture_output=True, timeout=6, creationflags=_NOWIN,
        )
    except FileNotFoundError:
        _gpu_ok = False          # nvidia-smi not installed → give up permanently
        return None
    except Exception:
        return None              # timeout / transient → retry next tick, DON'T latch
    try:
        if out.returncode != 0 or not out.stdout.strip():
            return None          # driver busy/transient → retry next tick, DON'T latch
        _gpu_ok = True
        line = out.stdout.decode(errors="ignore").strip().splitlines()[0]
        parts = [p.strip() for p in line.split(",")]

        def _f(i):
            try:
                return float(parts[i])
            except (ValueError, IndexError):
                return None
        name = parts[0] if parts and parts[0] else "GPU"
        used, total = _f(2), _f(3)
        mem_pct = round(100.0 * used / total, 1) if used and total else None
        return {
            "name": name, "pct": _f(1),
            "mem_used_mb": used, "mem_total_mb": total, "mem_pct": mem_pct,
            "temp_c": _f(4), "power_w": _f(5), "clock_mhz": _f(6),
        }
    except Exception:
        return None              # parse hiccup → retry next tick, DON'T latch


# ── FPS (RTSS shared memory — non-admin, see wivexx_fps) ─────────────────────
_fps_mod = None
_fps_ok: Optional[bool] = None


def _read_fps() -> Optional[float]:
    """Foreground game's FPS via RTSS. Latches off only if the module can't be
    imported; a plain None (RTSS not running) is retried every tick."""
    global _fps_mod, _fps_ok
    if _fps_ok is False:
        return None
    try:
        if _fps_mod is None:
            import wivexx_fps
            _fps_mod = wivexx_fps
            _fps_ok = True
        return _fps_mod.read_fps()
    except Exception:
        _fps_ok = False
        return None


# ── CPU package temperature (Windows WMI thermal zone) ───────────────────────
_wmi_obj = None          # cached SWbemServices for root\WMI
_wmi_ok: Optional[bool] = None


def _wmi_service():
    global _wmi_obj, _wmi_ok
    if _wmi_obj is not None or _wmi_ok is False:
        return _wmi_obj
    try:
        import win32com.client
        loc = win32com.client.Dispatch("WbemScripting.SWbemLocator")
        _wmi_obj = loc.ConnectServer(".", "root\\WMI")
        _wmi_ok = True
    except Exception:
        _wmi_obj, _wmi_ok = None, False
    return _wmi_obj


def _read_cpu_temp() -> Optional[float]:
    """ACPI thermal zone temp in °C (tenths-of-Kelvin → Celsius). Best-effort;
    many desktop motherboards simply don't publish this to Windows."""
    svc = _wmi_service()
    if svc is None:
        return None
    try:
        rows = svc.ExecQuery("SELECT CurrentTemperature FROM "
                             "MSAcpi_ThermalZoneTemperature")
        temps = []
        for r in rows:
            try:
                temps.append(r.CurrentTemperature / 10.0 - 273.15)
            except Exception:
                pass
        if temps:
            return round(max(temps), 1)        # hottest zone ≈ package
    except Exception:
        pass
    return None


# ── Sampler loop ─────────────────────────────────────────────────────────────
def _loop() -> None:
    import psutil
    try:
        import comtypes
        comtypes.CoInitialize()
    except Exception:
        pass

    psutil.cpu_percent(interval=None)          # prime the % counters
    psutil.cpu_percent(interval=None, percpu=True)
    last_net = psutil.net_io_counters()
    last_t = time.monotonic()
    temp_c = None
    tick = 0
    last_gpu = None            # hold the last good GPU reading across a transient miss
    last_gpu_ts = 0.0

    while not _stop_evt.wait(_interval):
        try:
            now = time.monotonic()
            dt = max(0.1, now - last_t)
            last_t = now

            cpu = psutil.cpu_percent(interval=None)
            per_core = psutil.cpu_percent(interval=None, percpu=True)
            vm = psutil.virtual_memory()
            try:
                freq = psutil.cpu_freq()
                freq_mhz = round(freq.current) if freq else None
            except Exception:
                freq_mhz = None

            net = psutil.net_io_counters()
            up_kbps = max(0.0, (net.bytes_sent - last_net.bytes_sent) / dt / 1024.0)
            down_kbps = max(0.0, (net.bytes_recv - last_net.bytes_recv) / dt / 1024.0)
            last_net = net

            gpu = _read_gpu()
            if gpu is not None:
                last_gpu, last_gpu_ts = gpu, now
            elif _gpu_ok is not False and last_gpu is not None and (now - last_gpu_ts) < 8.0:
                gpu = last_gpu       # transient miss → hold last good reading (no "—" flicker)

            fps = _read_fps()        # real per-game FPS via RTSS (None if not running)

            # temps move slowly — only re-read every ~4 ticks (≈6 s) to stay cheap
            tick += 1
            if tick % 4 == 1:
                temp_c = _read_cpu_temp()

            try:
                disk_pct = psutil.disk_usage("C:\\").percent
            except Exception:
                disk_pct = None

            data = {
                "ts": time.time(),
                "cpu": {"pct": round(cpu, 1), "freq_mhz": freq_mhz,
                        "per_core": [round(c, 0) for c in per_core],
                        "temp_c": temp_c},
                "ram": {"pct": round(vm.percent, 1),
                        "used_gb": round(vm.used / 1024**3, 1),
                        "total_gb": round(vm.total / 1024**3, 1)},
                "gpu": gpu,
                "net": {"up_kbps": round(up_kbps, 1), "down_kbps": round(down_kbps, 1)},
                "disk": {"pct": disk_pct} if disk_pct is not None else None,
                "fps": fps,         # real per-game FPS from RTSS (None when unavailable)
                "speed": last_speed() or None,   # last internet speed test (on-demand)
            }
            with _lock:
                _snap.clear()
                _snap.update(data)
        except Exception:
            pass


# ── Public API ───────────────────────────────────────────────────────────────
def start(interval: float = 1.5) -> bool:
    """Begin sampling in a daemon thread. Idempotent. Returns False w/o psutil."""
    global _thread, _interval
    try:
        import psutil  # noqa: F401
    except Exception:
        return False
    _interval = max(0.4, float(interval))
    if _thread is not None and _thread.is_alive():
        return True
    _stop_evt.clear()
    _thread = threading.Thread(target=_loop, daemon=True, name="wivexx-sysmon")
    _thread.start()
    return True


def stop() -> None:
    _stop_evt.set()


def snapshot() -> dict:
    """Latest reading (a copy). Empty dict until the first sample lands."""
    with _lock:
        return dict(_snap)


def is_running() -> bool:
    return _thread is not None and _thread.is_alive()


_speed_lock = threading.Lock()
_last_speed: dict = {}          # {"down_mbps", "up_mbps", "ts"}
_speed_running = False


def last_speed() -> dict:
    with _speed_lock:
        return dict(_last_speed)


def speedtest(max_bytes: int = 25_000_000, timeout: float = 12.0) -> dict:
    """Our OWN internet speed test (no external app): time a download from
    Cloudflare's public speed endpoint, then a small upload, and report Mbps.
    Result is cached + folded into snapshot()['speed']. Returns {} on failure."""
    global _speed_running, _last_speed
    with _speed_lock:
        if _speed_running:
            return dict(_last_speed)
        _speed_running = True
    out: dict = {}
    try:
        import urllib.request
        # ── download ──
        req = urllib.request.Request(
            "https://speed.cloudflare.com/__down?bytes=%d" % max_bytes,
            headers={"User-Agent": "Wivexx"})
        got, t0 = 0, time.monotonic()
        with urllib.request.urlopen(req, timeout=timeout) as r:
            while True:
                chunk = r.read(131072)
                if not chunk:
                    break
                got += len(chunk)
                if time.monotonic() - t0 > timeout:
                    break
        dt = max(0.05, time.monotonic() - t0)
        down_mbps = round(got * 8 / dt / 1e6, 1)
        # ── upload (small — just enough for a rough figure) ──
        up_mbps = None
        try:
            payload = b"0" * 5_000_000
            ureq = urllib.request.Request(
                "https://speed.cloudflare.com/__up", data=payload,
                headers={"Content-Type": "application/octet-stream", "User-Agent": "Wivexx"})
            u0 = time.monotonic()
            urllib.request.urlopen(ureq, timeout=timeout).read()
            up_mbps = round(len(payload) * 8 / max(0.05, time.monotonic() - u0) / 1e6, 1)
        except Exception:
            pass
        out = {"down_mbps": down_mbps, "up_mbps": up_mbps, "ts": time.time()}
    except Exception:
        out = {}
    finally:
        with _speed_lock:
            _speed_running = False
            if out:
                _last_speed = out
    # fold into the live snapshot so the HUD can show it
    if out:
        with _lock:
            _snap["speed"] = out
    return out


def speedtest_async() -> None:
    """Kick off a speed test in the background (HUD button / voice / startup)."""
    threading.Thread(target=speedtest, daemon=True, name="wivexx-speedtest").start()


if __name__ == "__main__":
    start(1.0)
    for _ in range(4):
        time.sleep(1.1)
        import json
        print(json.dumps(snapshot(), indent=2, default=str))
    stop()
