"""Safe aggregate host resource metrics for the WebUI system panel (#693).

The browser only needs coarse CPU/RAM/disk usage. Linux uses procfs first;
platforms without procfs (for example macOS) fall back to psutil for aggregate
CPU/RAM metrics. Keep the payload intentionally small: no process lists,
command strings, user identities, environment variables, or filesystem topology
leave the server.
"""

from __future__ import annotations

import shutil
import time
from importlib import import_module
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


_PROC_STAT = Path("/proc/stat")
_PROC_MEMINFO = Path("/proc/meminfo")
_CPU_SAMPLE_SECONDS = 0.05


def _load_optional_psutil():
    try:
        return import_module("psutil")
    except ImportError:
        raise RuntimeError("psutil_unavailable") from None


def _checked_at() -> str:
    return datetime.now(timezone.utc).isoformat()


def _clamp_percent(value: Any) -> float:
    try:
        numeric = float(value)
    except (TypeError, ValueError):
        return 0.0
    if numeric < 0:
        numeric = 0.0
    if numeric > 100:
        numeric = 100.0
    return round(numeric, 1)


def _read_proc_stat_cpu() -> tuple[int, int]:
    """Return (idle_ticks, total_ticks) from Linux /proc/stat."""
    with _PROC_STAT.open("r", encoding="utf-8") as handle:
        first = handle.readline().strip().split()
    if not first or first[0] != "cpu":
        raise RuntimeError("proc_stat_unavailable")
    values = [int(part) for part in first[1:]]
    if len(values) < 4:
        raise RuntimeError("proc_stat_unavailable")
    idle = values[3] + (values[4] if len(values) > 4 else 0)
    total = sum(values)
    if total <= 0:
        raise RuntimeError("proc_stat_unavailable")
    return idle, total


def _cpu_delta_percent(start: tuple[int, int], end: tuple[int, int]) -> float:
    idle_delta = end[0] - start[0]
    total_delta = end[1] - start[1]
    if total_delta <= 0:
        return 0.0
    busy_delta = max(0, total_delta - max(0, idle_delta))
    return _clamp_percent((busy_delta / total_delta) * 100.0)


def _cpu_percent() -> float:
    """Sample aggregate CPU usage.

    A short local sample avoids storing cross-request state and returns a stable
    percentage on the first poll. Linux uses procfs without extra dependencies;
    platforms without procfs fall back to psutil when it is already available.
    Unsupported platforms raise a safe error code.
    """
    try:
        start = _read_proc_stat_cpu()
    except OSError:
        psutil = _load_optional_psutil()
        return _clamp_percent(psutil.cpu_percent(interval=_CPU_SAMPLE_SECONDS))
    time.sleep(_CPU_SAMPLE_SECONDS)
    try:
        end = _read_proc_stat_cpu()
    except OSError:
        psutil = _load_optional_psutil()
        return _clamp_percent(psutil.cpu_percent(interval=0.0))
    return _cpu_delta_percent(start, end)


def _read_meminfo_kib() -> dict[str, int]:
    data: dict[str, int] = {}
    with _PROC_MEMINFO.open("r", encoding="utf-8") as handle:
        for line in handle:
            key, _, rest = line.partition(":")
            if not key or not rest:
                continue
            parts = rest.strip().split()
            if not parts:
                continue
            try:
                data[key] = int(parts[0])
            except ValueError:
                continue
    return data


def _memory_usage() -> dict[str, int | float]:
    try:
        meminfo = _read_meminfo_kib()
    except OSError:
        vm = _load_optional_psutil().virtual_memory()
        total = int(getattr(vm, "total", 0) or 0)
        if total <= 0:
            raise RuntimeError("memory_unavailable") from None
        available = max(0, int(getattr(vm, "available", 0) or 0))
    else:
        total = int(meminfo.get("MemTotal") or 0) * 1024
        if total <= 0:
            raise RuntimeError("meminfo_unavailable")
        available_kib = meminfo.get("MemAvailable")
        if available_kib is None:
            available_kib = (
                meminfo.get("MemFree", 0)
                + meminfo.get("Buffers", 0)
                + meminfo.get("Cached", 0)
                + meminfo.get("SReclaimable", 0)
                - meminfo.get("Shmem", 0)
            )
        available = max(0, int(available_kib) * 1024)
    used = max(0, min(total, total - available))
    return {
        "used_bytes": used,
        "total_bytes": total,
        "percent": _clamp_percent((used / total) * 100.0),
    }


def _disk_usage() -> dict[str, int | float]:
    usage = shutil.disk_usage("/")
    total = int(usage.total)
    if total <= 0:
        raise RuntimeError("disk_unavailable")
    used = int(usage.used)
    return {
        "used_bytes": used,
        "total_bytes": total,
        "percent": _clamp_percent((used / total) * 100.0),
    }


def _safe_error(metric: str, exc: Exception) -> dict[str, str]:
    # Keep this intentionally coarse. Exception messages can contain local paths
    # on unusual platforms; the browser only needs a safe unavailable reason.
    return {"metric": metric, "code": type(exc).__name__}


def _zero_webui_runtime_payload() -> dict[str, Any]:
    return {
        "sessions": {"available": False, "resident": 0, "cap": 0},
        "streams": {
            "available": False, "active": 0, "agent_instances": 0,
            "subscribers": 0, "offline_buffered_events": 0,
            "offline_dropped_events": 0, "subscriber_dropped_events": 0,
            "unavailable_channels": 0,
        },
        "session_list_cache": {
            "available": False, "entries": 0, "inflight_rebuilds": 0, "cap": 0,
        },
        "models_cache": {
            "available": False, "groups": 0, "models": 0, "age_seconds": None,
        },
    }


def _safe_runtime_int(value: Any) -> int:
    try:
        return max(0, int(value))
    except (TypeError, ValueError):
        return 0


def _webui_runtime_payload(errors: list[dict[str, str]]) -> dict[str, Any]:
    runtime = _zero_webui_runtime_payload()
    # The three owner modules stay lazily imported so this small diagnostics
    # module cannot create an eager import cycle. The import-lock wait that
    # implies is only reachable while a first import is in flight, and
    # api/routes.py imports all three at load, before any request can dispatch.
    try:
        from api import config
    except Exception as exc:
        config = None
        config_import_error = exc
    else:
        config_import_error = None
    try:
        from api import route_session_list_cache
    except Exception as exc:
        route_session_list_cache = None
        cache_import_error = exc
    else:
        cache_import_error = None
    try:
        from api import streaming
    except Exception as exc:
        streaming = None
        streaming_import_error = exc
    else:
        streaming_import_error = None

    # Collect the config owner exactly once per payload: two calls would double
    # the blocking edge and let `sessions` and `models_cache` come from two
    # different snapshots of the same process.
    config_snapshot: Any = None
    config_error = config_import_error
    if config is not None and config_error is None:
        try:
            config_snapshot = config.get_runtime_diagnostics_snapshot()
        except Exception as exc:
            config_error = exc

    def _config_section(section: str):
        def project():
            if not isinstance(config_snapshot, dict):
                raise TypeError("runtime_snapshot_invalid")
            return config_snapshot[section]

        return project

    sources = {
        "sessions": (_config_section("sessions"), config_error),
        "models_cache": (_config_section("models_cache"), config_error),
        "streams": (
            (lambda: streaming.get_stream_runtime_snapshot())
            if streaming is not None else None,
            streaming_import_error,
        ),
        "session_list_cache": (
            (lambda: route_session_list_cache.get_session_list_cache_snapshot())
            if route_session_list_cache is not None else None,
            cache_import_error,
        ),
    }
    for name, (collect, import_error) in sources.items():
        try:
            if import_error is not None:
                raise import_error
            source = collect()
            if not isinstance(source, dict):
                raise TypeError("runtime_snapshot_invalid")
            target = runtime[name]
            for key in target:
                if key == "available":
                    target[key] = bool(source.get(key, False))
                elif key == "age_seconds":
                    age = source.get(key)
                    target[key] = None if age is None else max(0.0, float(age))
                else:
                    target[key] = _safe_runtime_int(source.get(key, 0))
        except Exception as exc:
            errors.append(_safe_error(f"webui_runtime.{name}", exc))
    return runtime


def build_system_health_payload() -> dict[str, Any]:
    metrics: dict[str, Any] = {"cpu": None, "memory": None, "disk": None}
    errors: list[dict[str, str]] = []

    collectors = {
        "cpu": _cpu_percent,
        "memory": _memory_usage,
        "disk": _disk_usage,
    }
    for name, collect in collectors.items():
        try:
            value = collect()
            if name == "cpu":
                metrics[name] = {"percent": _clamp_percent(value)}
            else:
                metrics[name] = {
                    "used_bytes": max(0, int(value["used_bytes"])),
                    "total_bytes": max(0, int(value["total_bytes"])),
                    "percent": _clamp_percent(value["percent"]),
                }
        except Exception as exc:
            errors.append(_safe_error(name, exc))

    try:
        runtime = _webui_runtime_payload(errors)
    except Exception as exc:
        # Terminal fail-open: an unexpected failure in the runtime compositor
        # itself must not fail the whole authenticated response, which returned
        # host metrics before this subtree existed.
        runtime = _zero_webui_runtime_payload()
        errors.append(_safe_error("webui_runtime", exc))

    available = any(metrics[name] is not None for name in metrics)
    status = "ok" if available and not errors else "partial" if available else "unavailable"
    return {
        "status": status,
        "available": available,
        "checked_at": _checked_at(),
        "cpu": metrics["cpu"],
        "memory": metrics["memory"],
        "disk": metrics["disk"],
        "webui_runtime": runtime,
        "errors": errors,
    }
