"""Host resources the screen needs before it starts: memory.

A headed desktop is a big allocation relative to a small gateway host: Xvnc + Xfce idle at ~220 MiB
and the browser a human opens during a takeover adds 500 MiB to 1 GiB (measured in the official image:
gateway 304 MiB → +Xfce 520 MiB → +Chromium on one page 1,073 MiB). On a 4 GB container the loser
of that squeeze is Chromium, killed mid-login, or the gateway itself. So ``runtime.start()`` refuses
while the host cannot spare ``bot_desktop.min_free_memory_mb`` and ``display.status`` says why, which
the pane shows in place of the Start button.

"Available" is the tighter of two numbers: what the cgroup still allows (a container limit; v2
``memory.max``/``memory.current``, v1 ``memory.limit_in_bytes``/``memory.usage_in_bytes``) and what
the kernel reports free for the whole machine (``MemAvailable``) — a limit of 8 GB on a 4 GB host is
not 8 GB.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Optional

_CGROUP_V2 = Path("/sys/fs/cgroup")
_CGROUP_V1 = Path("/sys/fs/cgroup/memory")
_MEMINFO = Path("/proc/meminfo")
_MIB = 1024 * 1024
DEFAULT_MIN_FREE_MB = 1536


@dataclass
class MemoryInfo:
    available_mb: Optional[int]  # None when nothing readable (non-Linux, odd sandboxes)
    limit_mb: Optional[int]  # the cgroup limit when there is one, else the machine's MemTotal


def _read_int(path: Path) -> Optional[int]:
    try:
        text = path.read_text(encoding="utf-8-sig").strip()
    except OSError:
        return None
    return int(text) if text.isdigit() else None  # "max" → None


def _meminfo() -> dict[str, int]:
    try:
        lines = _MEMINFO.read_text(encoding="utf-8-sig").splitlines()
    except OSError:
        return {}
    out: dict[str, int] = {}
    for line in lines:
        key, _, rest = line.partition(":")
        parts = rest.split()
        if parts and parts[0].isdigit():
            out[key] = int(parts[0]) * 1024  # kB
    return out


def _stat_value(path: Path, key: str) -> Optional[int]:
    """One ``<key> <bytes>`` line out of a cgroup ``memory.stat``."""
    try:
        for line in path.read_text(encoding="utf-8-sig").splitlines():
            name, _, rest = line.partition(" ")
            if name == key:
                return int(rest.strip())
    except (OSError, ValueError):
        return None
    return None


def _cgroup_limit_and_usage() -> tuple[Optional[int], Optional[int]]:
    """The cgroup's limit and its *working set* — usage minus reclaimable page cache.

    ``memory.current`` counts page cache, so a container reads several hundred MB above idle right after a
    desktop stops with every process gone. Charging that would make the gate tighten over uptime, so we
    subtract ``inactive_file``, the working-set convention kubelet uses.
    """
    limit = _read_int(_CGROUP_V2 / "memory.max")
    usage = _read_int(_CGROUP_V2 / "memory.current")
    cache = _stat_value(_CGROUP_V2 / "memory.stat", "inactive_file")
    if limit is None and usage is None:
        limit = _read_int(_CGROUP_V1 / "memory.limit_in_bytes")
        usage = _read_int(_CGROUP_V1 / "memory.usage_in_bytes")
        cache = _stat_value(_CGROUP_V1 / "memory.stat", "total_inactive_file")
        if limit is not None and limit >= 1 << 60:  # v1 "unlimited" is a huge sentinel
            limit = None
    if usage is not None and cache:
        usage = max(usage - cache, 0)
    return limit, usage


def memory_info() -> MemoryInfo:
    info = _meminfo()
    host_available = info.get("MemAvailable")
    host_total = info.get("MemTotal")
    limit, usage = _cgroup_limit_and_usage()
    candidates = [x for x in (host_available, (limit - usage) if limit is not None and usage is not None else None)
                  if x is not None]
    available = min(candidates) if candidates else None
    limit_bytes = limit if limit is not None else host_total
    return MemoryInfo(available_mb=None if available is None else max(0, available // _MIB),
                      limit_mb=None if limit_bytes is None else limit_bytes // _MIB)


def min_free_mb() -> int:
    """``bot_desktop.min_free_memory_mb``; 0 disables the gate. A hosted deployment sets it in the
    instance's config.yaml (or the managed overlay), not an env var."""
    from hermes_cli.config import load_config_readonly
    cfg = load_config_readonly().get("bot_desktop") or {}
    try:
        return max(0, int(cfg.get("min_free_memory_mb", DEFAULT_MIN_FREE_MB)))
    except (TypeError, ValueError):
        return DEFAULT_MIN_FREE_MB


def tight_headroom_mb(floor: Optional[int] = None) -> int:
    """Above the floor but below this, a start is allowed and logged: the desktop fits, a few browser tabs
    would not. Derived from the floor so raising the floor cannot silently retire the warning."""
    floor = min_free_mb() if floor is None else floor
    return floor + floor // 3


def memory_blocker(info: Optional[MemoryInfo] = None, need: Optional[int] = None) -> Optional[str]:
    """Why the screen must not start now, or None. Unknown memory is not a blocker: a host we cannot
    read is not a host we know to be small. ``need`` lets a caller that also wants
    :func:`tight_headroom_mb` read the floor once instead of loading the config twice."""
    need = min_free_mb() if need is None else need
    if need == 0:
        return None
    info = info or memory_info()
    if info.available_mb is None or info.available_mb >= need:
        return None
    limit = f" of {info.limit_mb} MB" if info.limit_mb else ""
    return (f"Not enough free memory for a desktop: {info.available_mb} MB available{limit}, "
            f"{need} MB needed (bot_desktop.min_free_memory_mb)")
