#!/usr/bin/env python3
"""Aggregate runtime snapshot for EtherGold field /status dashboard.

Host metrics from /sys and /proc (fast). Machine pane from durable status files
written by the bridge + DyzineFrame (see machine_status_ipc). Field identity
from field_config.status_snapshot.
"""

from __future__ import annotations

import os
import re
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

SCHEMA = "ethergold-runtime/1"

_UTC_Z = re.compile(
    r"^(?P<y>\d{4})-(?P<m>\d{2})-(?P<d>\d{2})T"
    r"(?P<H>\d{2}):(?P<M>\d{2}):(?P<S>\d{2})(?:\.\d+)?Z$"
)


def _utc_now_str() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _read_text(path: str) -> Optional[str]:
    try:
        with open(path, encoding="utf-8", errors="replace") as handle:
            return handle.read().strip()
    except OSError:
        return None


def _parse_utc_age_s(stamp: Optional[str]) -> Optional[float]:
    if not stamp or not isinstance(stamp, str):
        return None
    m = _UTC_Z.match(stamp.strip())
    if not m:
        return None
    try:
        dt = datetime(
            int(m.group("y")),
            int(m.group("m")),
            int(m.group("d")),
            int(m.group("H")),
            int(m.group("M")),
            int(m.group("S")),
            tzinfo=timezone.utc,
        )
    except ValueError:
        return None
    return max(0.0, time.time() - dt.timestamp())


def _file_age_s(path: str) -> Optional[float]:
    try:
        return max(0.0, time.time() - os.path.getmtime(path))
    except OSError:
        return None


def collect_cpu_temp_c() -> Optional[float]:
    """Prefer thermal_zone* containing 'cpu' / 'soc'; else first zone."""
    base = Path("/sys/class/thermal")
    if not base.is_dir():
        return None
    candidates: list[tuple[int, float]] = []
    for zone in sorted(base.glob("thermal_zone*")):
        raw = _read_text(str(zone / "temp"))
        if raw is None:
            continue
        try:
            milli = int(raw)
        except ValueError:
            continue
        c = milli / 1000.0
        typ = (_read_text(str(zone / "type")) or "").lower()
        rank = 0
        if "cpu" in typ or "soc" in typ or "package" in typ:
            rank = 2
        elif "gpu" in typ:
            rank = 1
        candidates.append((rank, c))
    if not candidates:
        return None
    candidates.sort(key=lambda t: (-t[0], t[1]))
    return candidates[0][1]


def collect_loadavg() -> dict[str, Any]:
    raw = _read_text("/proc/loadavg")
    if not raw:
        return {"load_1": None, "load_5": None, "load_15": None}
    parts = raw.split()
    try:
        return {
            "load_1": float(parts[0]),
            "load_5": float(parts[1]) if len(parts) > 1 else None,
            "load_15": float(parts[2]) if len(parts) > 2 else None,
        }
    except (ValueError, IndexError):
        return {"load_1": None, "load_5": None, "load_15": None}


def collect_mem() -> dict[str, Any]:
    raw = _read_text("/proc/meminfo")
    if not raw:
        return {"total_b": None, "available_b": None, "used_pct": None}
    fields: dict[str, int] = {}
    for line in raw.splitlines():
        if ":" not in line:
            continue
        key, rest = line.split(":", 1)
        parts = rest.strip().split()
        if not parts:
            continue
        try:
            # values are kB
            fields[key] = int(parts[0]) * 1024
        except ValueError:
            continue
    total = fields.get("MemTotal")
    available = fields.get("MemAvailable")
    if total is None:
        return {"total_b": None, "available_b": None, "used_pct": None}
    if available is None:
        free = fields.get("MemFree") or 0
        buff = fields.get("Buffers") or 0
        cached = fields.get("Cached") or 0
        available = free + buff + cached
    used = max(0, total - available)
    used_pct = round(100.0 * used / total, 1) if total > 0 else None
    return {"total_b": total, "available_b": available, "used_pct": used_pct}


def collect_disk(path: str) -> dict[str, Any]:
    statvfs = getattr(os, "statvfs", None)
    if statvfs is None:
        return {"path": path, "total_b": None, "free_b": None, "used_pct": None}
    try:
        st = statvfs(path)
    except OSError:
        return {"path": path, "total_b": None, "free_b": None, "used_pct": None}
    total = st.f_frsize * st.f_blocks
    free = st.f_frsize * st.f_bavail
    used = max(0, total - free)
    used_pct = round(100.0 * used / total, 1) if total > 0 else None
    return {"path": path, "total_b": total, "free_b": free, "used_pct": used_pct}


def collect_uptime_s() -> Optional[float]:
    raw = _read_text("/proc/uptime")
    if not raw:
        return None
    try:
        return float(raw.split()[0])
    except (ValueError, IndexError):
        return None


def collect_throttled() -> Optional[str]:
    """vcgencmd get_throttled → hex string, or None if unavailable."""
    try:
        cp = subprocess.run(
            ["vcgencmd", "get_throttled"],
            capture_output=True,
            text=True,
            timeout=0.4,
            check=False,
        )
    except (OSError, subprocess.TimeoutExpired):
        return None
    if cp.returncode != 0 or not cp.stdout:
        return None
    # e.g. throttled=0x0
    line = cp.stdout.strip()
    if "=" in line:
        return line.split("=", 1)[1].strip()
    return line or None


def collect_host(ethergold_root: Optional[str] = None) -> dict[str, Any]:
    root = ethergold_root or os.environ.get("ETHERGOLD_ROOT", "/home/topbox/EtherGold")
    load = collect_loadavg()
    out: dict[str, Any] = {
        "cpu_temp_c": collect_cpu_temp_c(),
        "load_1": load.get("load_1"),
        "load_5": load.get("load_5"),
        "load_15": load.get("load_15"),
        "mem": collect_mem(),
        "disk_root": collect_disk("/"),
        "disk_ethergold": collect_disk(root if os.path.isdir(root) else "/"),
        "uptime_s": collect_uptime_s(),
        "throttled": collect_throttled(),
    }
    return out


def _read_json_file(path: str) -> Optional[dict[str, Any]]:
    if not os.path.isfile(path):
        return None
    try:
        import json

        with open(path, encoding="utf-8") as handle:
            data = json.load(handle)
        return data if isinstance(data, dict) else None
    except (OSError, ValueError):
        return None


def build_runtime_payload(*, etc: Any = None) -> dict[str, Any]:
    """Full /EtherGold/api/runtime body."""
    field: dict[str, Any] = {}
    machine: Optional[dict[str, Any]] = None
    current_game: Optional[dict[str, Any]] = None
    machine_path = ""
    tele_path = ""
    machine_age: Optional[float] = None
    pogcc_age: Optional[float] = None

    try:
        from field_config import load_field_config, status_snapshot

        cfg = load_field_config(etc)
        field = status_snapshot(cfg, etc)
    except Exception as exc:  # pragma: no cover - defensive
        field = {"error": str(exc)}

    try:
        from machine_status_ipc import (
            resolve_ethergold_root,
            resolve_machine_status_path,
            resolve_pogcc_telemetry_path,
        )

        root = resolve_ethergold_root()
        machine_path = resolve_machine_status_path()
        tele_path = resolve_pogcc_telemetry_path()
        machine = _read_json_file(machine_path)
        tele = _read_json_file(tele_path)
        # Status CREDITS must follow live Heltec telemetry, not a listen-throttled
        # machine-status snapshot (listen down → pogcc pennies freeze).
        if isinstance(machine, dict) and isinstance(tele, dict) and tele:
            machine = dict(machine)
            machine["pogcc"] = tele
        machine_age = _file_age_s(machine_path)
        if machine and isinstance(machine.get("updated_utc"), str):
            stamped = _parse_utc_age_s(machine.get("updated_utc"))
            if stamped is not None:
                machine_age = stamped
        pogcc = (machine or {}).get("pogcc") if machine else None
        if isinstance(pogcc, dict):
            pogcc_age = _parse_utc_age_s(pogcc.get("updated_utc")) or _file_age_s(tele_path)
        else:
            pogcc_age = _file_age_s(tele_path)
        host = collect_host(root)
    except Exception as exc:  # pragma: no cover
        root = os.environ.get("ETHERGOLD_ROOT", "/home/topbox/EtherGold")
        host = collect_host(root)
        host["error"] = str(exc)

    try:
        from current_game_ipc import resolve_current_game_path

        cg_path = resolve_current_game_path()
        current_game = _read_json_file(cg_path)
    except Exception:
        current_game = None

    # Human-readable name even when current-game.json was written without it.
    if isinstance(current_game, dict) and not current_game.get("game_name"):
        try:
            from machine_status_ipc import game_name_for_index

            idx = current_game.get("game_index")
            if idx is not None:
                gname = game_name_for_index(int(idx))
                if gname:
                    current_game = dict(current_game)
                    current_game["game_name"] = gname
        except Exception:
            pass

    try:
        from floor_games_cache import HTTP_BUILD

        http_build = HTTP_BUILD
    except Exception:
        http_build = "unknown"

    ota_channel = "field"
    try:
        from eg_deploy_control import resolve_ota_channel

        ota_channel = resolve_ota_channel()
    except Exception:
        ota_channel = (os.environ.get("OTA_CHANNEL") or "field").strip().lower() or "field"

    return {
        "schema": SCHEMA,
        "server_now_utc": _utc_now_str(),
        "http_build": http_build,
        "ota_channel": ota_channel,
        "field": field,
        "machine": machine,
        "current_game": current_game,
        "host": host,
        "paths": {
            "machine_status": machine_path or None,
            "pogcc_telemetry": tele_path or None,
        },
        "freshness": {
            "machine_age_s": machine_age,
            "pogcc_age_s": pogcc_age,
        },
    }
