"""Bot Desktop runtime: one headless Xfce desktop per Hermes profile, served over RFB on a private
Unix socket, viewed and driven from Hermes Desktop.

Layout under ``<HERMES_HOME>/bot-desktop/``: ``display`` (allocated X display number), ``rfb.sock``
(Xvnc RFB Unix socket, 0600), ``Xauthority``, ``env`` (DISPLAY/XAUTHORITY/DBUS_SESSION_BUS_ADDRESS
published by the launcher once Xfce's bus exists), ``launcher.pid``, ``launcher.log``, ``xdg/``
(per-profile XDG_CONFIG_HOME so two profiles never share xfconf). Everything is profile-scoped via
``get_hermes_home()`` so N profiles in one gateway get N desktops: one screen per bot on the shared
machine.

The launcher is ``launcher.sh`` next to this module; :func:`desktop_env` is what cua-driver and headed
Chromium spawns merge in so the agent acts on this profile's screen and nowhere else.
"""

from __future__ import annotations

import contextlib
import logging
import os
import shutil
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional

from hermes_constants import get_hermes_home
from tools.bot_desktop import placement

logger = logging.getLogger(__name__)

_LAUNCHER = Path(__file__).with_name("launcher.sh")

# Display numbers below 10 collide with real seats and default Xvfb recipes (:99 is popular too); scan a
# private band and record the choice so restarts reuse it.
_DISPLAY_MIN, _DISPLAY_MAX = 20, 89

# Binaries the launcher execs; the package hint is per distro family.
REQUIRED_BINARIES = ("Xvnc", "xfwm4", "xfce4-panel", "xfdesktop", "xfsettingsd", "dbus-run-session",
                     "xauth", "xdpyinfo", "setxkbmap", "xprop")

# Which package in each distro list ships each required binary. Fedora retired the xorg-x11-utils /
# xorg-x11-server-utils umbrellas (per-binary packages since F35) and dnf5 refuses the whole transaction on
# one unknown name, so every binary must map to a package that still resolves; the test suite checks that
# each mapped package is in PACKAGES for its manager.
BINARY_PACKAGES = {
    "apt": {"Xvnc": "tigervnc-standalone-server", "xfwm4": "xfwm4", "xfce4-panel": "xfce4-panel",
            "xfdesktop": "xfdesktop4", "xfsettingsd": "xfce4-settings", "dbus-run-session": "dbus-x11",
            "xauth": "xauth", "xdpyinfo": "x11-utils", "setxkbmap": "x11-xkb-utils", "xprop": "x11-utils"},
    # tigervnc-x11-server is the real package (tigervnc-server-minimal is only a Provides on it); dbus-run-session
    # is in dbus-daemon (dbus-x11 ships dbus-launch only).
    "dnf": {"Xvnc": "tigervnc-x11-server", "xfwm4": "xfwm4", "xfce4-panel": "xfce4-panel",
            "xfdesktop": "xfdesktop", "xfsettingsd": "xfce4-settings", "dbus-run-session": "dbus-daemon",
            "xauth": "xorg-x11-xauth", "xdpyinfo": "xdpyinfo", "setxkbmap": "setxkbmap", "xprop": "xprop"},
    "pacman": {"Xvnc": "tigervnc", "xfwm4": "xfwm4", "xfce4-panel": "xfce4-panel", "xfdesktop": "xfdesktop",
               "xfsettingsd": "xfce4-settings", "dbus-run-session": "dbus",
               "xauth": "xorg-xauth", "xdpyinfo": "xorg-xdpyinfo", "setxkbmap": "xorg-setxkbmap", "xprop": "xorg-xprop"},
}

PACKAGES = {
    "apt": ["tigervnc-standalone-server", "xfce4-panel", "xfwm4", "xfdesktop4", "xfce4-settings",
            "xfce4-terminal", "dbus-x11", "x11-xserver-utils", "x11-utils", "x11-xkb-utils", "xauth",
            "fonts-dejavu-core"],
    "dnf": ["tigervnc-x11-server", "xfce4-panel", "xfwm4", "xfdesktop", "xfce4-settings",
            "xfce4-terminal", "dbus-daemon", "xsetroot", "xset", "xdpyinfo", "xprop", "xorg-x11-xauth", "setxkbmap",
            "dejavu-sans-fonts"],
    "pacman": ["tigervnc", "xfce4-panel", "xfwm4", "xfdesktop", "xfce4-settings", "xfce4-terminal", "dbus",
               "xorg-xsetroot", "xorg-xset", "xorg-xdpyinfo", "xorg-xprop", "xorg-xauth", "xorg-setxkbmap",
               "ttf-dejavu"],
}


def state_dir() -> Path:
    return get_hermes_home() / "bot-desktop"


def is_supported_host() -> bool:
    return sys.platform.startswith("linux")


def missing_binaries() -> list[str]:
    return [b for b in REQUIRED_BINARIES if shutil.which(b) is None]


def package_manager() -> Optional[str]:
    for pm in ("apt-get", "dnf", "pacman"):
        if shutil.which(pm):
            return "apt" if pm == "apt-get" else pm
    return None


def install_command() -> Optional[str]:
    """The distro command that installs the Bot Desktop packages, as the human would type it on THIS host:
    prefixed with ``sudo`` unless Hermes already runs as root, so it is both what the pane shows and what
    :mod:`tools.bot_desktop.install` runs. ``None`` when no package manager is present.

    Not a promise that it can run here: see :func:`installable`. The published Docker image supervises
    every service under ``s6-setuidgid hermes`` (UID 10000 by default) and ships no ``sudo`` binary, so an
    install on a hosted instance is impossible no matter what this returns."""
    pm = package_manager()
    if pm is None:
        return None
    pkgs = " ".join(PACKAGES[pm])
    body = {
        "apt": f"apt-get install -y --no-install-recommends {pkgs}",
        "dnf": f"dnf install -y {pkgs}",
        "pacman": f"pacman -S --needed --noconfirm {pkgs}",
    }[pm]
    return body if is_root() else f"sudo {body}"


def is_root() -> bool:
    return hasattr(os, "geteuid") and os.geteuid() == 0


def installable() -> bool:
    """Whether :func:`install_command` could actually succeed on this host.

    False on an unprivileged process with no ``sudo`` to reach for, which is exactly the published Docker
    image: services drop to the ``hermes`` user and no ``sudo`` binary is installed. The packages can only
    arrive in the image there, so :func:`start` says that instead of printing a sudo line the user has no
    way to run. ``status()`` still reports ``install_command`` for the pane; surfacing this there needs a
    wire-contract change and is deliberately out of scope.
    """
    if package_manager() is None:
        return False
    return is_root() or shutil.which("sudo") is not None


@dataclass
class DesktopStatus:
    profile: str
    supported: bool
    installed: bool
    missing: list[str]
    running: bool
    pid: Optional[int]
    display: Optional[str]
    socket: Optional[str]
    geometry: str
    install_command: Optional[str]
    browser: Optional[str]  # headed Chromium the dock's Browser icon and agent-browser share; None = no headed browser
    blocker: Optional[str] = None  # why start() would refuse right now (memory); None = may start
    memory_available_mb: Optional[int] = None
    memory_limit_mb: Optional[int] = None
    placement: str = "gateway"  # "gateway" | "terminal:<backend>" — where Xvnc runs
    image_switch: Optional[Dict[str, object]] = None  # pending default-image switch the pane can approve

    def as_dict(self) -> Dict[str, object]:
        return dict(self.__dict__)


def _read(path: Path) -> Optional[str]:
    try:
        return path.read_text(encoding="utf-8-sig").strip() or None
    except OSError:
        return None


def _pid_alive(pid: int) -> bool:
    """A zombie is dead for our purposes: a SIGKILLed launcher stays a zombie in the gateway until the next
    Popen reaps it, and reporting it as running would hide its orphaned X server behind a live status."""
    import psutil
    try:
        return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
    except psutil.Error:
        return False


def _create_time(pid: int) -> Optional[float]:
    import psutil
    try:
        return psutil.Process(pid).create_time()
    except (psutil.Error, OverflowError, ValueError):
        return None


def _launcher_pid() -> Optional[int]:
    """The live launcher's pid, or None. ``launcher.pid`` holds ``"<pid> <create_time>"``: a recycled pid
    with a different start time is somebody else's process and must never be reported as ours nor
    killed by :func:`stop`. The pre-identity single-number format is treated as not running."""
    raw = _read(state_dir() / "launcher.pid")
    pid_s, _, born_s = (raw or "").partition(" ")
    if not pid_s.isdigit() or not born_s:
        return None
    try:
        pid, born = int(pid_s), float(born_s)
    except ValueError:
        return None
    actual = _create_time(pid)
    return pid if actual is not None and abs(actual - born) < 0.01 and _pid_alive(pid) else None


def _recorded_launcher_pid() -> Optional[int]:
    """The pid ``launcher.pid`` names, alive or not (the orphan sweep matches process groups against it)."""
    pid_s, _, born_s = (_read(state_dir() / "launcher.pid") or "").partition(" ")
    return int(pid_s) if pid_s.isdigit() and born_s else None


_X_LOCK_DIR = Path("/tmp")  # no-tmp: ok — X servers write .X<n>-lock here by protocol (tests point it at a scratch dir)
_X_UNIX_TABLE = Path("/proc/net/unix")  # the kernel's list of bound Unix sockets (tests point it at a fixture)


def _x_lock_pid(num: int) -> Optional[int]:
    try:
        return int((_X_LOCK_DIR / f".X{num}-lock").read_text(encoding="utf-8-sig").strip())
    except (OSError, ValueError):
        return None


def _x_socket_bound(num: int) -> bool:
    """A running X server keeps ``@/tmp/.X11-unix/X<n>`` (abstract namespace) bound for its whole life; the
    kernel drops it only when the process exits. A /tmp reaper can remove the lock file under a live Xvnc,
    and only this binding then still says the number is taken (a new server on it dies 'already running')."""
    try:
        lines = _X_UNIX_TABLE.read_text(encoding="utf-8-sig").splitlines()
    except OSError:
        return False
    # no-tmp: ok — detects the X server's display socket at the path the X11 protocol fixes
    return any(line.split()[-1].lstrip("@") == f"/tmp/.X11-unix/X{num}" for line in lines if line.strip())


def _display_in_use(num: int) -> bool:
    """A live X server owns ``:num``: its lock file names a running pid, or its X11 socket is bound. A lock
    left by a crashed server (dead pid, no socket) does not count, so the number can be reclaimed."""
    pid = _x_lock_pid(num)
    return (pid is not None and _pid_alive(pid)) or _x_socket_bound(num)


def _reap_orphaned_server(sd: Path) -> bool:
    """Caller holds ``start.lock`` and has established that no live launcher exists. The launcher runs Xvnc in
    its own session, so a SIGKILLed launcher leaves the X server alive, holding the display and ``rfb.sock``;
    ``status()`` keys on the launcher and says stopped, and a naive restart allocates a second server next
    to it and overwrites the socket path both now claim. The X lock of the recorded display names that
    server: it is ours when it sits in the dead launcher's process group or its command line binds OUR
    socket. When a /tmp reaper took the lock too (or the failed launch dropped ``display``), the socket path
    on the Xvnc command line is the remaining handle — without it one X server leaks per occurrence. Kill it
    (group first), drop the state it left, and report whether anything was signalled."""
    import psutil

    def _binds_our_socket(cmdline: list) -> bool:
        return "Xvnc" in Path(cmdline[0] if cmdline else "").name and str(sd / "rfb.sock") in cmdline

    recorded = _read(sd / "display")
    pid = _x_lock_pid(int(recorded)) if recorded and recorded.isdigit() else None
    if pid is None or not _pid_alive(pid):
        pid = next((p.pid for p in psutil.process_iter(["cmdline"]) if _binds_our_socket(p.info["cmdline"] or [])), None)
        if pid is None:
            return False
    launcher = _recorded_launcher_pid()
    try:
        pgid = os.getpgid(pid)  # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
        cmdline = psutil.Process(pid).cmdline()
    except (ProcessLookupError, psutil.Error):
        return False
    if pgid != launcher and not _binds_our_socket(cmdline):
        return False  # somebody else's server took the number after we died; never touch it
    logger.warning("Bot Desktop launcher %s is gone but its X server (pid %s) survived on :%s; reaping",
                   launcher, pid, recorded or "?")
    _kill_group_then_wait(pgid if pgid == launcher else None, pid)
    if recorded:
        (_X_LOCK_DIR / f".X{recorded}-lock").unlink(missing_ok=True)
    for name in ("launcher.pid", "env", "rfb.sock"):
        (sd / name).unlink(missing_ok=True)
    return True


def _kill_group_then_wait(pgid: Optional[int], pid: int, grace: float = 2.0) -> None:
    """SIGTERM the group (or the lone pid), SIGKILL whatever is still there after ``grace``."""
    def _signal(sig: int) -> None:
        with contextlib.suppress(ProcessLookupError, PermissionError):
            if pgid is not None:
                os.killpg(pgid, sig)  # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
            else:
                os.kill(pid, sig)
    def _anything_left() -> bool:
        # The leader dying first is the common case (bash exits on TERM, Xvnc traps it); the group is
        # done only when killpg(0) finds nobody, else a TERM-ignoring descendant keeps the display.
        if pgid is None:
            return _pid_alive(pid)
        try:
            os.killpg(pgid, 0)  # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
        except ProcessLookupError:
            return False
        except PermissionError:
            return True
        return True

    def _reap_if_ours() -> None:
        # The launcher was Popen'd by whichever gateway started it; a later gateway that stops it holds
        # no Popen, so the dead leader would sit as a zombie in our table (and count as "left").
        with contextlib.suppress(ChildProcessError, OSError):
            os.waitpid(pid, os.WNOHANG)  # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)

    _signal(signal.SIGTERM)
    deadline = time.monotonic() + grace
    while time.monotonic() < deadline:
        _reap_if_ours()
        if not _anything_left():
            return
        time.sleep(0.05)
    _signal(signal.SIGKILL)  # windows-footgun: ok — Linux-only runtime (is_supported_host gates start/stop)
    time.sleep(0.1)
    _reap_if_ours()


# Host-wide (every profile allocates from one band), so it lives outside any profile home. A predictable
# name must not be squattable: XDG_RUNTIME_DIR is the boundary — 0700 from logind, or from
# docker/stage2-hook.sh in containers, which have none.
_ALLOC_LOCK = Path(os.environ.get("XDG_RUNTIME_DIR") or Path.home() / ".cache") / "hermes-bot-desktop-alloc.lock"


@contextlib.contextmanager
def _flocked(path: Path):
    import fcntl  # windows-footgun: ok — Linux-only runtime (is_supported_host gates start)
    with open(path, "a+", encoding="utf-8") as fh:  # windows-footgun: ok — Linux-only runtime
        fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
        try:
            yield fh
        finally:
            fcntl.flock(fh.fileno(), fcntl.LOCK_UN)


def _pick_display() -> int:
    """Caller holds ``_ALLOC_LOCK``. The recorded number is only reused when no OTHER server holds it now:
    after profile A stops, B may have taken A's number, and A's launcher must never unlink B's socket."""
    recorded = _read(state_dir() / "display")
    if recorded and recorded.isdigit() and not _display_in_use(int(recorded)):
        return int(recorded)
    for num in range(_DISPLAY_MIN, _DISPLAY_MAX + 1):
        if not _display_in_use(num):
            return num
    raise RuntimeError("no free X display number in the Bot Desktop band")


def _allocate_display() -> int:
    _ALLOC_LOCK.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    with _flocked(_ALLOC_LOCK):
        return _pick_display()


def desktop_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
    """``base_env`` (default ``os.environ``) with this profile's DISPLAY/XAUTHORITY/DBUS_SESSION_BUS_ADDRESS
    merged in when its desktop is running. Unchanged otherwise, so hosts with a real seat keep it.
    Pure: never starts anything (it is called from env builders, status probes and tests)."""
    env = dict(os.environ if base_env is None else base_env)
    published = published_env()
    if published:
        touch_activity()  # a browser / cua-driver spawn is the agent using its screen
        env.update(published)
        env.pop("WAYLAND_DISPLAY", None)  # X11 desktop; a leaked Wayland socket flips GTK/Chromium backends
        from tools.bot_desktop.browser import env_for_agent
        env_for_agent(env)  # same binary + user-data-dir as the dock's Browser icon
    return env


def ensure_started_for_tool() -> None:
    """Tool-boundary hook (``computer_use`` dispatch and the headed Chromium spawn sites of the browser tool).

    Under ``terminal`` placement the sandbox screen is brought up on first use unconditionally: the sandbox is
    the boundary the user chose, a screen inside it touches nothing outside it, and the tools below refuse
    rather than run on the host when it is not up. On the gateway host, ``bot_desktop.auto_start`` (opt-in,
    default off) starts a Linux host's screen when it has NO display and the packages installed, so a headless
    gateway works the first time. Host failure is not an error here (the tool's own "no display" diagnosis is
    the right message then); a sandbox start failure is logged and left for :func:`tool_placement` to raise."""
    if published_env().get("DISPLAY"):
        touch_activity()
        return
    where = placement.resolve()
    if where.where == placement.REFUSED:
        return  # tool_placement() raises the reason at the spawn site
    if where.where == placement.TERMINAL:
        try:
            start()
        except Exception as exc:
            logger.info("Bot Desktop sandbox start deferred to the tool: %s", exc)
        return
    if not _should_auto_start(os.environ):
        return
    try:
        start()
    except Exception as exc:
        logger.info("Bot Desktop auto-start skipped: %s", exc)


def tool_placement() -> str:
    """Where the browser / cua-driver a tool is about to spawn MUST run: ``placement.GATEWAY`` or
    ``placement.TERMINAL``. Policy first, liveness second: a ``terminal`` placement whose screen is down gets
    it started here (raising with the blocker when it cannot come up) and a ``refused`` placement raises its
    reason. Neither ever yields the host — a screen that happens to be down is not permission to run the
    agent's browser outside the sandbox the user chose."""
    where = placement.resolve()
    if where.where == placement.REFUSED:
        raise RuntimeError(where.reason)
    if where.where == placement.GATEWAY:
        return placement.GATEWAY
    if not sandbox_screen_running() or not published_env().get("DISPLAY"):
        start()
    return placement.TERMINAL


def _should_auto_start(env: Dict[str, str]) -> bool:
    if not is_supported_host() or env.get("DISPLAY") or env.get("WAYLAND_DISPLAY"):
        return False
    if missing_binaries():
        return False
    from hermes_cli.config import load_config_readonly
    cfg = load_config_readonly().get("bot_desktop") or {}
    return bool(cfg.get("auto_start", False))


# ---- idle auto-stop -------------------------------------------------------------------------------
# A desktop nobody is using still holds ~220 MiB (plus whatever browser was left open). Every use —
# a computer_use action, a browser spawn onto the screen, a viewer attached, a human takeover — stamps
# ``activity``; the gateway's display watcher stops a screen idle past ``bot_desktop.idle_stop_minutes``
# unless a human holds it. The next use starts it again (auto_start or the pane's Start).
DEFAULT_IDLE_STOP_MINUTES = 30


def touch_activity() -> None:
    path = state_dir() / "activity"
    try:
        path.touch()
        os.utime(path, None)
    except OSError:
        pass


def idle_seconds() -> Optional[float]:
    """Seconds since the last stamped use; None when the screen never recorded one (falls back to the
    env file's publish time so a screen started and then forgotten still ages)."""
    for name in ("activity", "env"):
        try:
            return max(0.0, time.time() - (state_dir() / name).stat().st_mtime)
        except OSError:
            continue
    return None


def idle_stop_seconds() -> float:
    from hermes_cli.config import load_config_readonly
    cfg = load_config_readonly().get("bot_desktop") or {}
    try:
        minutes = float(cfg.get("idle_stop_minutes", DEFAULT_IDLE_STOP_MINUTES))
    except (TypeError, ValueError):
        minutes = DEFAULT_IDLE_STOP_MINUTES
    return max(0.0, minutes) * 60


def stop_if_idle() -> bool:
    """Stop this profile's screen when it has been idle past the limit and no human holds it. True when
    it was stopped."""
    limit = idle_stop_seconds()
    if limit <= 0 or not is_running():
        return False
    idle = idle_seconds()
    if idle is None or idle < limit:
        return False
    from tools.bot_desktop import lease as _bd_lease
    if _bd_lease.get().holder == _bd_lease.HUMAN:
        return False
    logger.info("Bot Desktop for profile %s idle for %.0f min; stopping", _profile_name(), idle / 60)
    return stop()


def published_env() -> Dict[str, str]:
    """Variables the launcher wrote once Xfce's private bus existed; empty when the desktop is down.

    Pure file reads on the common path: this is called from every browser / cua-driver env builder, so it
    must not load config (that initializes HERMES_HOME). A sandbox-hosted screen leaves a host-side marker at
    start; only its presence routes to the sandbox probe."""
    from tools.bot_desktop import sandbox_host
    if sandbox_host._read_marker():
        return _sandbox_published_env()
    if _launcher_pid() is None:
        return {}
    raw = _read(state_dir() / "env")
    if not raw:
        return {}
    out: Dict[str, str] = {}
    for line in raw.splitlines():
        key, sep, value = line.partition("=")
        if sep:
            out[key.strip()] = value.strip()
    return out


def rfb_socket_path() -> Optional[Path]:
    """Host path of the RFB socket; None when down OR when the screen lives in a sandbox (use
    :func:`open_rfb_stream` there: the socket is not on this filesystem)."""
    from tools.bot_desktop import sandbox_host
    if sandbox_host._read_marker():
        return None
    sock = state_dir() / "rfb.sock"
    return sock if _launcher_pid() is not None and sock.exists() else None


def in_sandbox() -> bool:
    """True when this profile's screen is placed inside the terminal backend (policy; reads config)."""
    return placement.resolve().where == placement.TERMINAL


def sandbox_screen_running() -> bool:
    """True when a sandbox-hosted screen is UP for this profile: the start marker exists AND the sandbox it
    names is alive — registered in this process, or (after a gateway restart emptied the registry) the
    marker's recorded container still running. Only a sandbox that is provably gone (its container removed
    out from under us) drops the marker, so the next start rebuilds instead of the browser exec-ing into a
    dead container; an unregistered-but-alive one is re-attached by ``_sandbox_env(create=True)`` at the
    next spawn (the terminal planner reuses the persisted container by label)."""
    from tools.bot_desktop import sandbox_host
    marker = sandbox_host._read_marker()
    if not marker:
        return False
    if _sandbox_env(create=False) is not None:
        return True
    if sandbox_host.marker_sandbox_alive(marker):
        return True
    sandbox_host._marker().unlink(missing_ok=True)
    return False


def _owned_sandbox_env():
    """The environment hosting the screen the marker records, re-attaching after a restart when the recorded
    sandbox is still alive; None when there is no marker or its sandbox is gone. Never builds a sandbox for
    a screen that is not there."""
    from tools.bot_desktop import sandbox_host
    marker = sandbox_host._read_marker()
    if not marker:
        return None
    env = _sandbox_env(create=False)
    if env is not None:
        return env
    if sandbox_host.marker_sandbox_alive(marker):
        return _sandbox_env(create=True)
    return None


def is_running() -> bool:
    return bool(published_env().get("DISPLAY"))


def open_rfb_stream() -> "subprocess.Popen":
    """Popen whose stdin/stdout carry RFB bytes for a sandbox-hosted screen (``in_sandbox()`` only)."""
    from tools.bot_desktop import sandbox_host
    env = _sandbox_env(create=False)
    if env is None:
        raise RuntimeError("the sandbox hosting this screen is not running")
    return sandbox_host.open_rfb_stream(env, _profile_name())


def geometry() -> str:
    from hermes_cli.config import load_config_readonly
    cfg = load_config_readonly().get("bot_desktop") or {}
    return str(cfg.get("geometry") or "1440x900")


def status(profile: Optional[str] = None) -> DesktopStatus:
    from tools.bot_desktop import browser as _bd_browser
    from tools.bot_desktop import resources
    from tools.bot_desktop import sandbox_host
    where = placement.resolve()
    if where.where == placement.TERMINAL or sandbox_host._read_marker():
        # A screen already running inside a sandbox is reported (and stoppable) even after the placement
        # setting moved: the recorded owner wins over the current policy until it is stopped.
        return _sandbox_status(profile, where)
    missing: list[str] = missing_binaries() if is_supported_host() else list(REQUIRED_BINARIES)
    pid = _launcher_pid()
    env = published_env()
    running = pid is not None and bool(env.get("DISPLAY"))
    mem = resources.memory_info() if is_supported_host() else resources.MemoryInfo(None, None)
    return DesktopStatus(
        profile=profile or _profile_name(),
        supported=is_supported_host(),
        installed=not missing,
        missing=missing,
        running=running,
        pid=pid,
        display=env.get("DISPLAY"),
        socket=str(rfb_socket_path()) if rfb_socket_path() else None,
        geometry=geometry(),
        install_command=install_command() if missing else None,
        browser=_bd_browser.executable() if is_supported_host() else None,
        # A running screen is never "blocked": the check guards the allocation, not the session.
        blocker=None if running or missing or not is_supported_host() else resources.memory_blocker(mem),
        memory_available_mb=mem.available_mb,
        memory_limit_mb=mem.limit_mb,
    )


def _sandbox_status(profile: Optional[str], where) -> DesktopStatus:
    """Status of a sandbox-placed screen. Package presence is only known once the sandbox exists; before
    that the pane shows "installed" with the image hint carried in ``install_command`` so Start can explain."""
    from tools.bot_desktop import sandbox_host
    env = _owned_sandbox_env() or _sandbox_env(create=False)
    missing = sandbox_host.missing_binaries(env) if env is not None else []
    published = sandbox_host.published_env(env, profile or _profile_name()) if env is not None else {}
    running = bool(published.get("DISPLAY"))
    # No install_command: the pane's Install button runs apt on the HOST, which is the wrong machine here.
    # A sandbox missing the stack is a blocker (shown in place of Start) naming the image that has it.
    blocker = None
    image_switch = None
    if missing:
        blocker = (f"The terminal backend's sandbox image lacks {', '.join(missing)}. Use "
                   f"{sandbox_host.SANDBOX_IMAGE_HINT} as terminal.{where.backend}_image (the default sandbox base "
                   f"plus the desktop stack), or set bot_desktop.placement: gateway.")
        if where.backend == "docker":
            # The usual reason on an upgraded install: the persisted container predates the default
            # flip and was kept on purpose. The pane offers the switch instead of a config hint.
            from hermes_cli.sandbox_image_switch import pending
            sw = pending()
            if sw is not None:
                image_switch = {"current_image": sw.current_image, "target_image": sw.target_image,
                                "containers": len(sw.containers)}
                blocker = (f"Your sandbox container still runs {sw.current_image}, which has no desktop. "
                           f"Switch it to {sw.target_image}: files in /root and /workspace stay, packages "
                           f"installed inside the container are reinstalled on demand.")
    return DesktopStatus(
        profile=profile or _profile_name(),
        supported=True,
        installed=True,
        missing=missing,
        running=running,
        pid=None,
        display=published.get("DISPLAY"),
        socket=None,
        geometry=geometry(),
        install_command=None,
        browser=None,
        blocker=blocker,
        memory_available_mb=None,
        memory_limit_mb=None,
        placement=f"{placement.TERMINAL}:{where.backend}",
        image_switch=image_switch,
    )


def _profile_name() -> str:
    try:
        from hermes_cli.profiles import get_active_profile_name
        return get_active_profile_name() or "default"
    except Exception:
        return "default"


# The gate lives in ``resources`` so start() and status() cannot disagree about it. Measured in the
# official image: gateway idle 304 MiB, +216 for Xvnc/Xfce, 1073 MiB with one Chromium page. The OOM
# killer picks by score, so on a small instance the casualty is the dashboard or the gateway, not the
# desktop that caused the pressure.


def start(*, wait_seconds: float = 15.0) -> DesktopStatus:
    """Start this profile's desktop (idempotent). Blocks until the launcher publishes its env file or
    ``wait_seconds`` pass; raises ``RuntimeError`` naming the blocker.

    ``bot_desktop.placement`` decides WHERE: inside the configured terminal backend (docker/ssh/singularity;
    ``sandbox_host``), or on the gateway host (the rest of this function). A sandbox backend that cannot host
    a screen refuses rather than silently falling back to the host beside it.

    Two locks: the per-profile ``start.lock``, held from the running-check to the launcher's publish so two
    start() calls for one profile spawn one launcher (the loser sees it running), and the host-wide
    display-allocation lock, held only until this Xvnc has written ``/tmp/.X<n>-lock`` (a second profile
    picking the same number before that would fail and its stale-lock cleanup could remove our socket).
    Holding it for the whole Xfce bring-up serialized every profile's start behind one desktop launch."""
    where = placement.resolve()
    if where.where == placement.REFUSED:
        raise RuntimeError(where.reason)
    if where.where == placement.TERMINAL:
        return _start_in_sandbox(wait_seconds)
    if not is_supported_host():
        raise RuntimeError("Bot Desktop runs on Linux gateway hosts only")
    missing = missing_binaries()
    if missing:
        # Three dead ends: an operator told "unprivileged, no sudo" while running as root hunts the wrong bug.
        need = f"Bot Desktop needs {', '.join(missing)} on the gateway host"
        if package_manager() is None:
            raise RuntimeError(
                f"{need}, and no supported package manager (apt/dnf/pacman) is available to install them. "
                "Install TigerVNC (Xvnc) and the Xfce core components with this distro's own tooling.")
        if not installable():
            raise RuntimeError(
                f"{need}, and this host cannot install them: the process is unprivileged and there is no "
                "sudo. On the published Docker image the packages have to be baked in, so this needs a "
                "newer image rather than an install.")
        raise RuntimeError(f"{need}. Install: {install_command()}")
    sd = state_dir()
    sd.mkdir(parents=True, exist_ok=True)
    os.chmod(sd, 0o700)
    with _flocked(sd / "start.lock"):
        if _launcher_pid() is not None and published_env().get("DISPLAY"):
            return status()
        from tools.bot_desktop import resources
        floor = resources.min_free_mb()
        mem = resources.memory_info()
        if (blocker := resources.memory_blocker(mem, need=floor)) is not None:
            raise RuntimeError(blocker)
        if mem.available_mb is not None and mem.available_mb < resources.tight_headroom_mb(floor):
            logger.warning(
                "Bot Desktop starting with %d MB available; a browser with a few pages open can use most "
                "of that.", mem.available_mb)
        if _launcher_pid() is None:
            _reap_orphaned_server(sd)
        _ALLOC_LOCK.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
        try:
            return _spawn_and_wait(sd, wait_seconds)
        except RuntimeError:
            # _pick_display reuses the recorded number first: left in place after a failed launch (e.g. Xvnc
            # 'server already running' on it), every retry would pick the same number and the profile wedges.
            (sd / "display").unlink(missing_ok=True)
            raise


def _spawn_and_wait(sd: Path, wait_seconds: float) -> DesktopStatus:
    """Caller holds ``start.lock``. Takes ``_ALLOC_LOCK`` itself, from picking the number to Xvnc's claim."""
    with contextlib.ExitStack() as alloc:
        alloc.enter_context(_flocked(_ALLOC_LOCK))
        num = _pick_display()
        (sd / "display").write_text(str(num), encoding="utf-8")
        env_file = sd / "env"
        env_file.unlink(missing_ok=True)

        # The agent drives this desktop and its dock opens a terminal, so it starts from the
        # scrubbed child env like any other agent child, keeping the user's HOME.
        from tools.environments.local import served_profile_child_env
        child_env = served_profile_child_env(inherit_credentials=False)
        child_env["HOME"] = child_env["HERMES_REAL_HOME"]
        for key in ("DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", "SESSION_MANAGER"):
            child_env.pop(key, None)
        child_env.update({
            "HERMES_BD_PROFILE": _profile_name(),
            "HERMES_BD_DISPLAY_NUM": str(num),
            "HERMES_BD_SOCKET": str(sd / "rfb.sock"),
            "HERMES_BD_XAUTH": str(sd / "Xauthority"),
            "HERMES_BD_ENV_FILE": str(env_file),
            "HERMES_BD_CONFIG_HOME": str(sd / "xdg"),
            "HERMES_BD_GEOMETRY": geometry(),
        })
        from tools.bot_desktop.browser import dock_exec_line, dock_launch
        if (browser := dock_launch()) is not None:
            # The bare executable (the launcher checks it exists) and the ready-made, spec-quoted Exec= line.
            child_env["HERMES_BD_BROWSER_EXEC"] = browser[0]
            child_env["HERMES_BD_BROWSER_EXEC_LINE"] = dock_exec_line(*browser)
        # Truncated per start: the log is a diagnostic for THIS launch, and nothing rotates it otherwise.
        log = open(sd / "launcher.log", "wb")  # noqa: SIM115 — handed to the child, closed by it
        proc = subprocess.Popen(  # windows-footgun: ok — Linux-only runtime (is_supported_host)
            ["bash", str(_LAUNCHER)], env=child_env, stdin=subprocess.DEVNULL, stdout=log, stderr=log,
            start_new_session=True, close_fds=True)
        log.close()
        born = _create_time(proc.pid)
        (sd / "launcher.pid").write_text(f"{proc.pid} {born if born is not None else 0}", encoding="utf-8")

        deadline = time.monotonic() + wait_seconds
        while time.monotonic() < deadline:
            if _x_lock_pid(num) is not None:
                alloc.close()  # the number is Xvnc's now; other profiles may allocate (idempotent)
            if proc.poll() is not None:
                tail = (sd / "launcher.log").read_bytes()[-2000:].decode("utf-8", "replace")
                raise RuntimeError(f"Bot Desktop launcher exited with {proc.returncode}:\n{tail}")
            if env_file.exists() and (sd / "rfb.sock").exists():
                logger.info("Bot Desktop for profile %s up on :%s", _profile_name(), num)
                touch_activity()
                return status()
            time.sleep(0.1)
        # Giving up must take the launch down: left alone, the launcher publishes DISPLAY and rfb.sock a moment
        # later and a screen whose start() reported failure stays up as "running". The launcher is its own
        # session leader, so its group is exactly this launch (Xvnc, dbus, Xfce) and nothing else.
        _kill_group_then_wait(proc.pid, proc.pid)
        proc.wait()
        for name in ("launcher.pid", "env", "rfb.sock"):
            (sd / name).unlink(missing_ok=True)
        raise RuntimeError(f"Bot Desktop did not publish its display within {wait_seconds:.0f}s (see {sd / 'launcher.log'})")


def _sandbox_env(*, create: bool):
    """The terminal environment hosting this profile's screen, or None. ``create=False`` for status probes
    (a status call must never build a container)."""
    return placement.terminal_environment(create=create)


def _start_in_sandbox(wait_seconds: float) -> DesktopStatus:
    from tools.bot_desktop import sandbox_host
    env = _sandbox_env(create=True)
    if env is None:
        raise RuntimeError("the terminal backend's sandbox could not be started, so there is nowhere to put the screen")
    sd = state_dir()
    sd.mkdir(parents=True, exist_ok=True)
    os.chmod(sd, 0o700)
    with _flocked(sd / "start.lock"):
        # The dock's Browser icon runs the sandbox's own Playwright Chromium on the profile agent-browser
        # uses there: the human's browser is the bot's browser, as on the host.
        from tools.bot_desktop.browser import dock_exec_line
        exe = sandbox_host.chromium_executable(env)
        browser = (exe, dock_exec_line(exe, sandbox_host.browser_profile_dir(env), sandbox_bypass=True)) if exe else (None, None)
        published = sandbox_host.start(env, _profile_name(), geometry=geometry(), wait_seconds=max(wait_seconds, 20.0),
                                       browser_exec=browser[0], browser_exec_line=browser[1])
    (sd / "env").write_text("".join(f"{k}={v}\n" for k, v in published.items()), encoding="utf-8")
    touch_activity()
    return status()


def _sandbox_published_env() -> Dict[str, str]:
    """Published env of a sandbox-hosted screen (the caller saw the host-side marker written at start)."""
    from tools.bot_desktop import sandbox_host
    env = _sandbox_env(create=False)
    if env is None:
        return {}
    return sandbox_host.published_env(env, _profile_name())


def stop() -> bool:
    """Stop this profile's desktop; True when a running launcher (or the X server a dead one left behind)
    was signalled."""
    from tools.bot_desktop import sandbox_host
    if sandbox_host._read_marker() or placement.resolve().where == placement.TERMINAL:
        # The marker names the sandbox that owns the screen; stop THAT one (re-attaching after a restart),
        # whatever the placement setting says now. A marker whose sandbox is gone is simply dropped.
        env = _owned_sandbox_env()
        stopped = sandbox_host.stop(env, _profile_name()) if env is not None else False
        sandbox_host._marker().unlink(missing_ok=True)
        for name in ("env", "activity"):
            (state_dir() / name).unlink(missing_ok=True)
        return stopped
    if not is_supported_host():
        return False
    sd = state_dir()
    sd.mkdir(parents=True, exist_ok=True)
    with _flocked(sd / "start.lock"):
        return _stop_locked(sd)


def _stop_locked(sd: Path) -> bool:
    pid = _launcher_pid()
    if pid is None:
        reaped = _reap_orphaned_server(sd)
        (sd / "env").unlink(missing_ok=True)
        return reaped
    # The launcher runs in its own session; killing the group takes Xvnc, dbus and Xfce with it.
    _kill_group_then_wait(pid, pid, grace=5.0)
    for name in ("launcher.pid", "env", "activity"):
        (sd / name).unlink(missing_ok=True)
    return True
