"""
Agent Sandbox — Isolated execution environment for 3rd-party local agents.

Security model:
    - **Filesystem**: Each agent can only read/write inside its own directory
      (``~/.brynq/agents/{agent_id}/``). No access to the user's documents,
      SSH keys, API key vault, or any other agent's directory.
    - **Network**: Agents may only reach ``127.0.0.1`` (their own protocol
      endpoint) and a small whitelist of external URLs declared in the agent
      manifest. All other outbound connections are blocked.
    - **Resources**: CPU time and memory are capped via subprocess resource
      limits. A runaway agent is killed, not just throttled.
    - **Lifecycle**: Install → start → (health-check loop) → stop → uninstall.
      No implicit state survives uninstall.

Threat model assumptions:
    - Agent code is UNTRUSTED (could be malicious or buggy).
    - The sandbox runs with the user's OS-level permissions but restricts
      the child process via environment stripping, chroot-like path
      validation, and resource limits.
    - On Windows we rely on subprocess job objects / CREATE_SUSPENDED;
      on POSIX we use setrlimit.

This module is deliberately dependency-free (stdlib only) so it ships
in the thin runtime without extra installs.
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
import platform
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any

log = logging.getLogger("brynq.runtime.sandbox")

# ── Defaults ─────────────────────────────────────────────────────────────

_HOME = Path.home()
AGENTS_BASE_DIR: Path = _HOME / ".brynq" / "agents"

# Ports reserved for local agents. Each agent gets one.
_PORT_RANGE_START: int = 9100
_PORT_RANGE_END: int = 9399

# Resource limits (conservative defaults — user can override via config).
DEFAULT_MEMORY_LIMIT_MB: int = 512
DEFAULT_CPU_TIME_LIMIT_SECONDS: int = 300  # 5 minutes of CPU time per invocation

# Maximum allowed HTTP response body from an agent (5 MB).
RESPONSE_SIZE_LIMIT: int = 5_242_880

# Health check retries when starting an agent.
_HEALTH_RETRIES: int = 10
_HEALTH_RETRY_DELAY: float = 1.0  # seconds

# Network whitelist: always allowed destinations (besides localhost).
_DEFAULT_NETWORK_WHITELIST: list[str] = [
    "pypi.org",
    "files.pythonhosted.org",
]

# Paths that agents must NEVER access, regardless of sandbox root.
_DENY_PATHS: list[str] = [
    str(_HOME / ".ssh"),
    str(_HOME / ".gnupg"),
    str(_HOME / ".brynq" / "vault"),
    str(_HOME / ".brynq" / "vault.json"),
    str(_HOME / ".aws"),
    str(_HOME / ".azure"),
    str(_HOME / ".config" / "gcloud"),
]


# ── Data Models ──────────────────────────────────────────────────────────


@dataclass
class InstalledAgent:
    """Represents an agent package installed on disk.

    Attributes:
        agent_id: Marketplace identifier (globally unique slug).
        name: Human-readable display name.
        version: Semantic version string from the agent manifest.
        install_dir: Absolute path to the agent's isolated directory.
        entrypoint: Command to run the agent (relative to install_dir).
        network_whitelist: URLs the agent is allowed to reach.
        installed_at: Unix timestamp of installation.
        checksum: SHA-256 of the downloaded package (for integrity).
    """

    agent_id: str
    name: str
    version: str
    install_dir: str
    entrypoint: str
    network_whitelist: list[str] = field(default_factory=list)
    installed_at: float = 0.0
    checksum: str = ""

    def to_dict(self) -> dict:
        return asdict(self)

    @classmethod
    def from_dict(cls, d: dict) -> InstalledAgent:
        return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})


@dataclass
class RunningAgent:
    """Represents a currently-running agent process.

    Attributes:
        agent_id: Which agent is running.
        pid: OS process ID.
        port: Localhost port the agent is listening on.
        started_at: Unix timestamp of process start.
        endpoint: Full URL for protocol calls (http://127.0.0.1:{port}).
    """

    agent_id: str
    pid: int
    port: int
    started_at: float
    endpoint: str

    def to_dict(self) -> dict:
        return asdict(self)


class SandboxError(Exception):
    """Raised when a sandbox operation fails."""


# ── Path Validation ──────────────────────────────────────────────────────


def _is_path_safe(target: str | Path, sandbox_root: str | Path) -> bool:
    """Check that *target* is strictly inside *sandbox_root*.

    Resolves symlinks and normalises case on case-insensitive filesystems.
    Blocks traversal attacks like ``../../etc/passwd``.

    Returns:
        True if target is inside (or equal to) sandbox_root; False otherwise.
    """
    try:
        resolved_target = Path(target).resolve()
        resolved_root = Path(sandbox_root).resolve()
    except (OSError, ValueError):
        return False

    # Must be equal to or a child of the sandbox root.
    try:
        resolved_target.relative_to(resolved_root)
        return True
    except ValueError:
        return False


def _is_path_denied(target: str | Path) -> bool:
    """Check if *target* falls inside any deny-listed path."""
    try:
        resolved = str(Path(target).resolve())
    except (OSError, ValueError):
        return True  # If we can't resolve it, deny by default.

    lower = resolved.lower() if platform.system() == "Windows" else resolved
    for deny in _DENY_PATHS:
        deny_norm = deny.lower() if platform.system() == "Windows" else deny
        if lower == deny_norm or lower.startswith(deny_norm + os.sep):
            return True
    return False


# ── Port Allocation ──────────────────────────────────────────────────────


def _find_free_port(start: int = _PORT_RANGE_START, end: int = _PORT_RANGE_END) -> int:
    """Find an unused TCP port in the agent port range.

    Raises:
        SandboxError: If no free port is available.
    """
    for port in range(start, end + 1):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            try:
                s.bind(("127.0.0.1", port))
                return port
            except OSError:
                continue
    raise SandboxError(f"No free ports in range {start}-{end}")


# ── Environment Scrubbing ────────────────────────────────────────────────


# Allowlist: ONLY these env vars (or prefixes) are passed to sandboxed agents.
# Everything else is stripped. This prevents leaking secrets the blocklist missed.
_ALLOWED_ENV_PREFIXES: tuple[str, ...] = (
    "PATH",
    "HOME",
    "USER",
    "USERNAME",
    "LOGNAME",
    "LANG",
    "LC_",
    "TMP",
    "TEMP",
    "TMPDIR",
    "SYSTEMROOT",         # Windows needs this
    "COMSPEC",            # Windows cmd.exe
    "WINDIR",             # Windows
    "PROGRAMFILES",       # Windows
    "APPDATA",            # Windows
    "LOCALAPPDATA",       # Windows
    "NUMBER_OF_PROCESSORS",
    "PROCESSOR_ARCHITECTURE",
    "OS",
    "TERM",               # Terminal type (Unix)
    "SHELL",              # Unix shell
    "COLORTERM",
    "DISPLAY",            # X11
    "XDG_",               # XDG dirs (Unix)
    "FOR_DISABLE_CONSOLE_CTRL_HANDLER",
)


def _build_safe_env(
    agent_dir: str,
    port: int,
    network_whitelist: list[str],
) -> dict[str, str]:
    """Build a scrubbed environment dict for the agent subprocess.

    Uses ALLOWLIST approach: only known-safe env vars are passed through.
    Everything else is stripped, preventing accidental secret leaks from
    vars the blocklist didn't anticipate (HUGGINGFACE_TOKEN, custom keys, etc.).
    """
    safe: dict[str, str] = {}

    for key, val in os.environ.items():
        upper = key.upper()
        if any(upper.startswith(p) for p in _ALLOWED_ENV_PREFIXES):
            safe[key] = val

    # Override PYTHONPATH so the agent cannot import from the host app.
    safe["PYTHONPATH"] = agent_dir

    # Inject sandbox metadata.
    safe["BRYNQ_AGENT_SANDBOX"] = "1"
    safe["BRYNQ_AGENT_DIR"] = agent_dir
    safe["BRYNQ_AGENT_PORT"] = str(port)
    safe["BRYNQ_NETWORK_WHITELIST"] = ",".join(network_whitelist)

    # Post-scrub validation: verify no sensitive variable names survived.
    _validate_env_scrubbed(safe)

    return safe


# Substrings that must never appear in env var names passed to agents.
_SENSITIVE_NAME_PATTERNS: tuple[str, ...] = ("KEY", "SECRET", "TOKEN", "PASSWORD")


def _validate_env_scrubbed(env: dict[str, str]) -> None:
    """Verify that no env var whose name contains KEY, SECRET, TOKEN, or PASSWORD remains.

    This is a defence-in-depth check *after* the allowlist filter. If a
    future allowlist change accidentally lets a sensitive variable through,
    this catch-all will raise immediately rather than silently leaking.

    Raises:
        SandboxError: If any variable name matches a sensitive pattern.
    """
    leaked: list[str] = []
    for name in env:
        upper = name.upper()
        if any(pat in upper for pat in _SENSITIVE_NAME_PATTERNS):
            leaked.append(name)
    if leaked:
        raise SandboxError(
            f"Env scrub validation failed — sensitive vars remain: {', '.join(sorted(leaked))}"
        )


# ── Package Verification ────────────────────────────────────────────────


def _sha256_of_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def _download_package(url: str, dest: Path, timeout: float = 60.0) -> str:
    """Download a package from *url* to *dest* and return its SHA-256.

    Raises:
        SandboxError: On network error or timeout.
    """
    try:
        req = urllib.request.Request(url)
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = resp.read()
    except (urllib.error.URLError, TimeoutError) as exc:
        raise SandboxError(f"Failed to download agent package from {url}: {exc}") from exc

    dest.write_bytes(data)
    return _sha256_of_bytes(data)


# ── Response Size Enforcement ────────────────────────────────────────────


def _read_agent_response(resp: Any, limit: int = RESPONSE_SIZE_LIMIT) -> bytes:
    """Read an agent's HTTP response body, enforcing a size limit.

    If the response exceeds *limit* bytes the body is truncated and a
    human-readable marker is appended so callers know data was lost.

    Args:
        resp: An open :class:`http.client.HTTPResponse` (or compatible).
        limit: Maximum allowed body size in bytes (default 5 MB).

    Returns:
        The (possibly truncated) response body as bytes.
    """
    data = resp.read(limit + 1)
    if len(data) > limit:
        data = data[:limit] + b"[response truncated: exceeded 5MB limit]"
        log.warning("Agent response exceeded %d bytes — truncated", limit)
    return data


# ── Sandbox Manager ─────────────────────────────────────────────────────


class AgentSandbox:
    """Manages the full lifecycle of sandboxed local agents.

    One instance per runtime process. Stores installed-agent metadata in
    ``~/.brynq/agents/{agent_id}/manifest.json`` and tracks running
    processes in memory.

    Attributes:
        base_dir: Root directory for all agent installations.
        running: Map of agent_id -> RunningAgent for currently-alive agents.
        memory_limit_mb: Per-agent memory cap.
        cpu_time_limit_s: Per-agent CPU time cap.
    """

    def __init__(
        self,
        base_dir: str | Path | None = None,
        memory_limit_mb: int = DEFAULT_MEMORY_LIMIT_MB,
        cpu_time_limit_s: int = DEFAULT_CPU_TIME_LIMIT_SECONDS,
    ) -> None:
        self.base_dir = Path(base_dir) if base_dir else AGENTS_BASE_DIR
        self.base_dir.mkdir(parents=True, exist_ok=True)
        self.memory_limit_mb = memory_limit_mb
        self.cpu_time_limit_s = cpu_time_limit_s
        self.running: dict[str, RunningAgent] = {}

    # ── Install ──────────────────────────────────────────────────────

    def install_agent(
        self,
        agent_id: str,
        package_url: str,
        name: str = "",
        version: str = "0.0.0",
        entrypoint: str = "python -m agent",
        network_whitelist: list[str] | None = None,
    ) -> InstalledAgent:
        """Download and install an agent into its isolated directory.

        The agent is placed in ``{base_dir}/{agent_id}/`` with its own
        virtualenv-style isolation. A ``manifest.json`` records metadata.

        Args:
            agent_id: Unique marketplace slug.
            package_url: HTTPS URL to the agent package (.tar.gz / .zip / .whl).
            name: Human-readable display name.
            version: Semantic version.
            entrypoint: Shell command to start the agent (relative to install dir).
            network_whitelist: URLs the agent is allowed to contact beyond localhost.

        Returns:
            InstalledAgent with install details.

        Raises:
            SandboxError: On download failure, path-safety violation, or I/O error.
        """
        self._validate_agent_id(agent_id)

        agent_dir = self.base_dir / agent_id
        if agent_dir.exists():
            log.warning("Agent %s already installed — overwriting", agent_id)
            shutil.rmtree(agent_dir)

        agent_dir.mkdir(parents=True, exist_ok=True)
        data_dir = agent_dir / "data"
        data_dir.mkdir(exist_ok=True)

        # Download the package.
        pkg_path = agent_dir / "package.archive"
        checksum = _download_package(package_url, pkg_path)

        # Unpack if it's an archive (basic .tar.gz support).
        if package_url.endswith((".tar.gz", ".tgz")):
            import tarfile

            with tarfile.open(pkg_path, "r:gz") as tf:
                # Security: validate no path traversal in archive members.
                for member in tf.getmembers():
                    member_path = (agent_dir / member.name).resolve()
                    if not _is_path_safe(member_path, agent_dir):
                        raise SandboxError(
                            f"Path traversal detected in archive member: {member.name}"
                        )
                tf.extractall(agent_dir)
        elif package_url.endswith(".zip"):
            import zipfile

            with zipfile.ZipFile(pkg_path, "r") as zf:
                for name_in_zip in zf.namelist():
                    member_path = (agent_dir / name_in_zip).resolve()
                    if not _is_path_safe(member_path, agent_dir):
                        raise SandboxError(
                            f"Path traversal detected in archive member: {name_in_zip}"
                        )
                zf.extractall(agent_dir)
        # else: single file (e.g. .whl) — already saved as package.archive.

        whitelist = list(_DEFAULT_NETWORK_WHITELIST)
        if network_whitelist:
            whitelist.extend(network_whitelist)

        agent = InstalledAgent(
            agent_id=agent_id,
            name=name or agent_id,
            version=version,
            install_dir=str(agent_dir),
            entrypoint=entrypoint,
            network_whitelist=whitelist,
            installed_at=time.time(),
            checksum=checksum,
        )

        # Persist manifest.
        manifest_path = agent_dir / "manifest.json"
        manifest_path.write_text(json.dumps(agent.to_dict(), indent=2))

        log.info("Installed agent %s v%s at %s (sha256:%s)", agent_id, version, agent_dir, checksum[:12])
        return agent

    # ── Start ────────────────────────────────────────────────────────

    def start_agent(self, agent_id: str, port: int | None = None) -> RunningAgent:
        """Start an installed agent in an isolated subprocess.

        The agent process is launched with:
            - A scrubbed environment (no API keys, no host PYTHONPATH).
            - Its working directory set to its own install dir.
            - Resource limits applied (memory + CPU).
            - Only localhost networking (plus declared whitelist).

        Args:
            agent_id: Must be already installed.
            port: Port to bind. Auto-allocated from the agent port range if None.

        Returns:
            RunningAgent with pid, port, and endpoint.

        Raises:
            SandboxError: If the agent is not installed, already running,
                or fails to start / pass health check.
        """
        if agent_id in self.running:
            raise SandboxError(f"Agent {agent_id} is already running (pid={self.running[agent_id].pid})")

        agent = self._load_manifest(agent_id)
        if port is None:
            port = _find_free_port()

        env = _build_safe_env(agent.install_dir, port, agent.network_whitelist)

        # Build the launch command.
        cmd = agent.entrypoint.split()

        # Prepare subprocess kwargs.
        kwargs: dict[str, Any] = {
            "cwd": agent.install_dir,
            "env": env,
            "stdout": subprocess.PIPE,
            "stderr": subprocess.PIPE,
        }

        # Apply resource limits (POSIX only — Windows lacks setrlimit).
        if platform.system() != "Windows":
            import resource as posix_resource

            mem_bytes = self.memory_limit_mb * 1024 * 1024

            def _set_limits() -> None:
                # Virtual memory limit.
                posix_resource.setrlimit(
                    posix_resource.RLIMIT_AS, (mem_bytes, mem_bytes)
                )
                # CPU time limit.
                posix_resource.setrlimit(
                    posix_resource.RLIMIT_CPU,
                    (self.cpu_time_limit_s, self.cpu_time_limit_s),
                )

            kwargs["preexec_fn"] = _set_limits
        else:
            # On Windows, use CREATE_NEW_PROCESS_GROUP so we can terminate cleanly.
            kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP

        try:
            proc = subprocess.Popen(cmd, **kwargs)
        except OSError as exc:
            raise SandboxError(f"Failed to start agent {agent_id}: {exc}") from exc

        running = RunningAgent(
            agent_id=agent_id,
            pid=proc.pid,
            port=port,
            started_at=time.time(),
            endpoint=f"http://127.0.0.1:{port}",
        )
        self.running[agent_id] = running

        # Wait for the agent to become healthy.
        if not self._wait_for_health(running):
            # Kill the unhealthy process and clean up.
            self._kill_process(proc)
            del self.running[agent_id]
            raise SandboxError(
                f"Agent {agent_id} failed health check after {_HEALTH_RETRIES} retries"
            )

        log.info("Started agent %s (pid=%d, port=%d)", agent_id, proc.pid, port)
        return running

    # ── Stop ─────────────────────────────────────────────────────────

    def stop_agent(self, agent_id: str) -> None:
        """Cleanly shut down a running agent.

        Sends SIGTERM (POSIX) or CTRL_BREAK_EVENT (Windows), waits up
        to 5 seconds, then force-kills if the process is still alive.

        Args:
            agent_id: Must be currently running.

        Raises:
            SandboxError: If the agent is not running.
        """
        running = self.running.get(agent_id)
        if running is None:
            raise SandboxError(f"Agent {agent_id} is not running")

        try:
            if platform.system() == "Windows":
                # Send CTRL_BREAK_EVENT to the process group.
                os.kill(running.pid, signal.CTRL_BREAK_EVENT)
            else:
                os.kill(running.pid, signal.SIGTERM)
        except OSError:
            pass  # Process may already be dead.

        # Wait for graceful shutdown.
        deadline = time.time() + 5.0
        while time.time() < deadline:
            if not self._is_pid_alive(running.pid):
                break
            time.sleep(0.2)
        else:
            # Force kill.
            try:
                if platform.system() == "Windows":
                    os.kill(running.pid, signal.SIGTERM)
                else:
                    os.kill(running.pid, signal.SIGKILL)
            except OSError:
                pass

        del self.running[agent_id]
        log.info("Stopped agent %s (was pid=%d)", agent_id, running.pid)

    # ── Uninstall ────────────────────────────────────────────────────

    def uninstall_agent(self, agent_id: str) -> None:
        """Remove an agent and all its data from disk.

        Stops the agent first if it is running.

        Args:
            agent_id: Must be installed.

        Raises:
            SandboxError: If the agent is not installed.
        """
        agent_dir = self.base_dir / agent_id
        if not agent_dir.exists():
            raise SandboxError(f"Agent {agent_id} is not installed")

        # Stop if running.
        if agent_id in self.running:
            self.stop_agent(agent_id)

        shutil.rmtree(agent_dir)
        log.info("Uninstalled agent %s", agent_id)

    # ── Query ────────────────────────────────────────────────────────

    def list_agents(self) -> list[InstalledAgent]:
        """Return all installed agents.

        Reads manifest.json from each agent subdirectory.
        Silently skips directories with missing or corrupt manifests.
        """
        agents: list[InstalledAgent] = []
        if not self.base_dir.exists():
            return agents

        for child in sorted(self.base_dir.iterdir()):
            if not child.is_dir():
                continue
            manifest = child / "manifest.json"
            if not manifest.exists():
                continue
            try:
                data = json.loads(manifest.read_text())
                agents.append(InstalledAgent.from_dict(data))
            except (json.JSONDecodeError, TypeError, KeyError) as exc:
                log.warning("Corrupt manifest in %s: %s", child, exc)
        return agents

    def health_check(self, agent_id: str) -> bool:
        """Check whether a running agent is alive and responding.

        Returns False if the agent is not running or the health
        endpoint is unreachable.
        """
        running = self.running.get(agent_id)
        if running is None:
            return False

        # Check if the OS process is still alive.
        if not self._is_pid_alive(running.pid):
            # Process died — clean up our tracking.
            del self.running[agent_id]
            return False

        # Probe the HTTP health endpoint.
        try:
            url = f"{running.endpoint}/health"
            req = urllib.request.Request(url, method="GET")
            with urllib.request.urlopen(req, timeout=5.0) as resp:
                return resp.status == 200
        except Exception:
            return False

    # ── Agent Communication ────────────────────────────────────────

    def call_agent(
        self,
        agent_id: str,
        path: str,
        *,
        method: str = "POST",
        body: bytes | None = None,
        timeout: float = 30.0,
    ) -> bytes:
        """Send an HTTP request to a running agent and return the response body.

        Enforces a 5 MB response size limit. If the agent returns a larger
        payload the body is truncated and a marker is appended.

        Args:
            agent_id: Must be currently running.
            path: URL path (e.g. ``/execute``).
            method: HTTP method.
            body: Optional request body.
            timeout: Request timeout in seconds.

        Returns:
            Response body bytes (truncated if oversized).

        Raises:
            SandboxError: If the agent is not running or the request fails.
        """
        running = self.running.get(agent_id)
        if running is None:
            raise SandboxError(f"Agent {agent_id} is not running")

        url = f"{running.endpoint}{path}"
        req = urllib.request.Request(url, method=method, data=body)
        try:
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                return _read_agent_response(resp)
        except urllib.error.URLError as exc:
            raise SandboxError(f"Request to agent {agent_id} failed: {exc}") from exc

    # ── Path Safety (public for testing) ─────────────────────────────

    def validate_path(self, agent_id: str, target: str | Path) -> bool:
        """Check if *target* is accessible by the given agent.

        An agent may only access paths inside its own install directory.
        Additionally, global deny-listed paths are always blocked.

        Resolves symlinks upfront so a single canonical path is used for
        both the deny-list check and the sandbox containment check,
        preventing TOCTOU symlink-based sandbox escapes.

        Returns True if access is allowed.
        """
        try:
            resolved = Path(target).resolve()
        except (OSError, ValueError):
            return False
        agent_dir = self.base_dir / agent_id
        if _is_path_denied(resolved):
            return False
        return _is_path_safe(resolved, agent_dir)

    # ── Internals ────────────────────────────────────────────────────

    @staticmethod
    def _validate_agent_id(agent_id: str) -> None:
        """Ensure agent_id is safe for use as a directory name.

        Must be non-empty, alphanumeric + hyphens, no path separators.
        """
        if not agent_id:
            raise SandboxError("agent_id cannot be empty")
        if not all(c.isalnum() or c == "-" for c in agent_id):
            raise SandboxError(
                f"Invalid agent_id {agent_id!r}: only alphanumeric chars and hyphens allowed"
            )
        if agent_id.startswith("-") or agent_id.endswith("-"):
            raise SandboxError(f"agent_id must not start or end with a hyphen: {agent_id!r}")

    def _load_manifest(self, agent_id: str) -> InstalledAgent:
        """Load an installed agent's manifest from disk."""
        manifest_path = self.base_dir / agent_id / "manifest.json"
        if not manifest_path.exists():
            raise SandboxError(f"Agent {agent_id} is not installed (no manifest)")
        try:
            data = json.loads(manifest_path.read_text())
            return InstalledAgent.from_dict(data)
        except (json.JSONDecodeError, KeyError) as exc:
            raise SandboxError(f"Corrupt manifest for {agent_id}: {exc}") from exc

    def _wait_for_health(self, running: RunningAgent) -> bool:
        """Poll the agent's health endpoint until it responds or we give up."""
        for attempt in range(_HEALTH_RETRIES):
            try:
                url = f"{running.endpoint}/health"
                req = urllib.request.Request(url, method="GET")
                with urllib.request.urlopen(req, timeout=2.0) as resp:
                    if resp.status == 200:
                        return True
            except Exception:
                pass

            # Check if the process died.
            if not self._is_pid_alive(running.pid):
                return False

            time.sleep(_HEALTH_RETRY_DELAY)
        return False

    @staticmethod
    def _is_pid_alive(pid: int) -> bool:
        """Check if a process with the given PID is still alive."""
        try:
            if platform.system() == "Windows":
                # On Windows, os.kill(pid, 0) with signal 0 doesn't work
                # the same as POSIX. Use a subprocess check.
                result = subprocess.run(
                    ["tasklist", "/FI", f"PID eq {pid}"],
                    capture_output=True,
                    text=True,
                    timeout=5,
                )
                return str(pid) in result.stdout
            else:
                os.kill(pid, 0)
                return True
        except (OSError, subprocess.TimeoutExpired):
            return False

    @staticmethod
    def _kill_process(proc: subprocess.Popen) -> None:
        """Force-kill a subprocess."""
        try:
            proc.kill()
            proc.wait(timeout=3)
        except Exception:
            pass
