"""HTTP-based client for the Sandbox Orchestrator.

This module provides :class:`HTTPClient`, a drop-in alternative to the
gRPC-based :class:`SandboxClient` that communicates with the orchestrator
over its REST/JSON HTTP API (default port 8080).

Every public method on :class:`HTTPClient` mirrors the corresponding method
on :class:`SandboxClient` so that callers can switch transports without
changing application logic.
"""

from __future__ import annotations

import base64 as _base64
import json as _json
import re as _re
import time as _time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING, Union
from urllib.parse import urljoin, urlparse

if TYPE_CHECKING:
    from .sandbox import Sandbox, AsyncSandbox

import aiohttp
import requests


# ---------------------------------------------------------------------------
# Command preparation
# ---------------------------------------------------------------------------
# Accept flexible command input: a plain string, a single-element list
# containing a shell expression, or a traditional argv list.  When shell
# interpretation is needed (pipes, globs, redirects …) the command is
# transparently wrapped in ``["sh", "-c", ...]``.

Command = Union[str, List[str], Sequence[str]]
"""Accepted command types for :pymethod:`exec_command`.

* ``str`` – interpreted by ``/bin/sh`` (pipes, globs, etc. work).
* Single-element list whose element contains whitespace or shell
  meta-characters – also routed through ``/bin/sh``.
* Multi-element list – executed directly (no shell).
"""

_SHELL_META = frozenset("|&;<>()$`\\\"'*?[#~!{}")


def _prepare_command(command: Command) -> List[str]:
    """Normalise *command* into an argv list, wrapping in a shell when needed.

    * ``"ls -al"``              → ``["sh", "-c", "ls -al"]``
    * ``["ls -al"]``            → ``["sh", "-c", "ls -al"]``
    * ``["ls", "-al"]``         → ``["ls", "-al"]``  (no shell)
    * ``["cat *.py | head"]``   → ``["sh", "-c", "cat *.py | head"]``
    """
    if isinstance(command, str):
        return ["sh", "-c", command]

    cmd = list(command)  # materialise arbitrary sequences
    if len(cmd) == 1:
        element = cmd[0]
        # A single element that contains whitespace or shell metacharacters
        # is almost certainly a shell expression, not a literal binary name.
        if " " in element or "\t" in element or any(c in _SHELL_META for c in element):
            return ["sh", "-c", element]
    return cmd


# ---------------------------------------------------------------------------
# Response data-classes
# ---------------------------------------------------------------------------
# These give attribute-style access identical to the protobuf message objects
# returned by the gRPC client, so callers can treat the two transports
# interchangeably.


# ---------------------------------------------------------------------------
# OCI error cleaning
# ---------------------------------------------------------------------------
# When a command executed inside a container fails at the OCI runtime level
# (e.g. the binary doesn't exist), Docker/runc wraps the real error in a
# verbose prefix.  We strip that prefix so callers see output that looks like
# it came directly from the system rather than the orchestration layer.

_OCI_ERROR_RE = _re.compile(
    r"OCI runtime exec failed: exec failed: "
    r"unable to start container process: exec: "
    r'"(?P<cmd>(?:[^"\\]|\\.)*)":\s*'   # quoted command
    r"(?:stat\s+.*?:\s*)?"              # optional "stat <cmd>: " detail
    r"(?P<err>.+)",
    _re.DOTALL,
)


def _parse_oci_error(text: str):
    """Parse an OCI runtime error message.

    Returns ``(True, error_message)`` when *text* contains an OCI exec
    error, or ``(False, text)`` when it does not.  The *error_message*
    contains only the underlying error (e.g. ``"no such file or
    directory"``), without the command echo or OCI boilerplate.
    """
    m = _OCI_ERROR_RE.match(text)
    if not m:
        return False, text
    err = m.group("err").strip().rstrip(": unknown").strip()
    return True, f"{err}\n"


@dataclass(frozen=True)
class ExecResult:
    """Result of executing a command inside a sandbox.

    Attributes mirror ``sandbox_pb2.ExecResponse``.
    """

    stdout: str = ""
    stderr: str = ""
    exit_code: int = 0
    elapsed_ms: float = 0.0

    def __post_init__(self) -> None:
        # frozen=True prevents normal assignment; use object.__setattr__.
        #
        # When the OCI runtime itself rejects the exec (e.g. binary not
        # found), Docker stuffs the whole error into *stdout* while
        # *stderr* stays empty.  We detect that pattern, move the real
        # error into stderr, and clear stdout so callers see the same
        # layout they would get from a direct shell invocation.
        for field in ("stdout", "stderr"):
            is_oci, msg = _parse_oci_error(getattr(self, field))
            if is_oci:
                # Real command produced no output → clear stdout.
                object.__setattr__(self, "stdout", "")
                # Append the cleaned error to stderr (avoid duplicates
                # when both fields carried an OCI message, which is rare).
                existing = self.stderr if field != "stderr" else ""
                object.__setattr__(self, "stderr", existing + msg if existing else msg)
                break  # only one field will carry the OCI message


@dataclass(frozen=True)
class MetricsResult:
    """Point-in-time resource-usage metrics for a sandbox.

    Attributes mirror ``sandbox_pb2.MetricsResponse``.
    """

    memory_usage_bytes: int = 0
    memory_limit_bytes: int = 0
    memory_percent: float = 0.0
    cpu_percent: float = 0.0
    pids_current: int = 0
    net_rx_bytes: int = 0
    net_tx_bytes: int = 0
    block_read_bytes: int = 0
    block_write_bytes: int = 0


@dataclass(frozen=True)
class SnapshotResult:
    """Paths produced by a snapshot operation.

    Attributes mirror ``sandbox_pb2.SnapshotResponse``.
    """

    snapshot_path: str = ""
    mem_file_path: str = ""


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------


class SandboxHTTPError(Exception):
    """Raised when the orchestrator returns a non-success HTTP status."""

    def __init__(self, status_code: int, detail: str) -> None:
        self.status_code = status_code
        self.detail = detail
        super().__init__(f"HTTP {status_code}: {detail}")


class SandboxNotFoundError(SandboxHTTPError):
    """Raised when the requested sandbox does not exist (HTTP 404)."""


# ---------------------------------------------------------------------------
# Type alias
# ---------------------------------------------------------------------------

#: A sandbox identifier: either a plain string ID or a :class:`Sandbox` /
#: :class:`AsyncSandbox` wrapper.  All client methods that take a sandbox ID
#: accept both forms.
SandboxID = Union[str, "Sandbox", "AsyncSandbox"]

# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------


def _resolve_id(sandbox_or_id: SandboxID) -> str:
    """Extract a plain string sandbox ID.

    Accepts either a raw string ID or a :class:`Sandbox` / :class:`AsyncSandbox`
    wrapper (anything with an ``id`` attribute).  This lets every client method
    accept both forms interchangeably.
    """
    if isinstance(sandbox_or_id, str):
        return sandbox_or_id
    # Duck-type: Sandbox / AsyncSandbox both expose `.id`
    sid = getattr(sandbox_or_id, "id", None)
    if sid is not None:
        return str(sid)
    raise TypeError(
        f"Expected a sandbox ID string or a Sandbox/AsyncSandbox object, "
        f"got {type(sandbox_or_id).__name__!r}"
    )


class HTTPClient:
    """Client for the Sandbox Orchestrator REST/JSON API.

    The public interface intentionally matches :class:`SandboxClient` so that
    users can swap transports with minimal code changes.

    Parameters
    ----------
    host:
        Hostname or IP of the orchestrator.  Ignored when *address* is given.
    port:
        HTTP port of the orchestrator (default ``8080``).
    address:
        Full base URL **or** ``host:port/path`` string.  When a path component
        is present it is used as a prefix for all requests (useful for reverse-
        proxy setups such as ``localhost:8092/v1/service/sandbox``).  Overrides
        *host* and *port*.
    timeout:
        Default request timeout in seconds.  ``None`` means no timeout.
    session:
        Optional :class:`requests.Session` to use.  When not provided a new
        session is created internally.  Passing your own session is handy for
        custom TLS settings, retries, or authentication.

    Usage::

        from flash_sandbox import HTTPClient

        client = HTTPClient(host="localhost", port=8080)

        sandbox = client.start_sandbox(type="docker", image="alpine:latest",
                                       command=["tail", "-f", "/dev/null"])
        status = client.get_status(sandbox)   # accepts Sandbox or string
        result = client.exec_command(sandbox, ["echo", "hello"])
        print(result.stdout)
        client.stop_sandbox(sandbox)
        client.close()
    """

    # --------------------------------------------------------------------- #
    # Construction / teardown
    # --------------------------------------------------------------------- #

    def __init__(
        self,
        host: Optional[str] = None,
        port: int = 8080,
        address: Optional[str] = None,
        timeout: Optional[float] = 30.0,
        session: Optional[requests.Session] = None,
    ) -> None:
        if address is not None:
            self._base_url = self._normalize_address(address)
        elif host is not None:
            self._base_url = f"http://{host}:{port}"
        else:
            raise ValueError("Must provide either 'address' or 'host'")

        # Guarantee a trailing slash so that urljoin works correctly.
        if not self._base_url.endswith("/"):
            self._base_url += "/"

        self.timeout = timeout
        self._session = session or requests.Session()
        self._owns_session = session is None  # only close if we created it

        self._session.headers.setdefault("X-Otela-Fallback", "2")

    # -- helpers for address normalisation -------------------------------- #

    @staticmethod
    def _normalize_address(address: str) -> str:
        """Turn a user-supplied *address* into a proper base URL."""
        addr = address.strip()
        # Add a scheme when missing so urlparse works correctly.
        if not addr.startswith("http://") and not addr.startswith("https://"):
            addr = f"http://{addr}"
        parsed = urlparse(addr)
        scheme = parsed.scheme or "http"
        host = parsed.hostname or "localhost"
        port = parsed.port or (443 if scheme == "https" else 80)
        path = parsed.path.rstrip("/")
        return f"{scheme}://{host}:{port}{path}"

    # -- context-manager -------------------------------------------------- #

    def close(self) -> None:
        """Close the underlying HTTP session (if we own it)."""
        if self._owns_session:
            self._session.close()

    def __enter__(self) -> "HTTPClient":
        return self

    def __exit__(
        self,
        exc_type: Any,
        exc_val: Any,
        exc_tb: Any,
    ) -> None:
        self.close()

    # --------------------------------------------------------------------- #
    # Internal request helpers
    # --------------------------------------------------------------------- #

    def _url(self, path: str) -> str:
        """Build an absolute URL for the given *path* (no leading slash)."""
        return urljoin(self._base_url, path)

    def _raise_for_status(self, resp: requests.Response) -> None:
        """Raise a :class:`SandboxHTTPError` when the response is not 2xx."""
        if resp.ok:
            return
        detail = resp.text.strip()
        if resp.status_code == 404:
            raise SandboxNotFoundError(resp.status_code, detail)
        raise SandboxHTTPError(resp.status_code, detail)

    def _get(self, path: str, **kwargs: Any) -> requests.Response:
        resp = self._session.get(self._url(path), timeout=self.timeout, **kwargs)
        self._raise_for_status(resp)
        return resp

    def _post(self, path: str, json: Any = None, **kwargs: Any) -> requests.Response:
        resp = self._session.post(self._url(path), json=json, timeout=self.timeout, **kwargs)
        self._raise_for_status(resp)
        return resp

    def _put(self, path: str, json: Any = None, **kwargs: Any) -> requests.Response:
        resp = self._session.put(self._url(path), json=json, timeout=self.timeout, **kwargs)
        self._raise_for_status(resp)
        return resp

    def _delete(self, path: str, **kwargs: Any) -> requests.Response:
        resp = self._session.delete(self._url(path), timeout=self.timeout, **kwargs)
        self._raise_for_status(resp)
        return resp

    # --------------------------------------------------------------------- #
    # Public API – mirrors SandboxClient (gRPC)
    # --------------------------------------------------------------------- #

    def start_sandbox(
        self,
        type: str,
        image: str,
        command: Optional[List[str]] = None,
        memory_mb: int = 512,
        cpu_cores: float = 1.0,
        kernel_image: Optional[str] = None,
        initrd_path: Optional[str] = None,
        snapshot_path: Optional[str] = None,
        mem_file_path: Optional[str] = None,
    ) -> "Sandbox":
        """Start a new sandbox and return its ID.

        Parameters
        ----------
        type:
            Sandbox backend – ``"docker"``, ``"gvisor"``, or
            ``"firecracker"``.
        image:
            Container / rootfs image name.
        command:
            Entrypoint command to run inside the sandbox.
        memory_mb:
            Memory limit in megabytes.
        cpu_cores:
            CPU cores limit.
        kernel_image:
            *(Firecracker only)* Path to the kernel image.
        initrd_path:
            *(Firecracker only)* Path to the initrd.
        snapshot_path:
            When restoring from a snapshot, path to the snapshot file.
        mem_file_path:
            When restoring from a snapshot, path to the memory file.

        Returns
        -------
        Sandbox
            A :class:`~flash_sandbox.sandbox.Sandbox` bound to the newly
            created sandbox.  Access the raw ID via ``.id``.
        """
        from .sandbox import Sandbox

        payload: Dict[str, Any] = {
            "type": type,
            "image": image,
            "command": command or [],
            "memory_mb": memory_mb,
            "cpu_cores": cpu_cores,
        }
        if kernel_image is not None:
            payload["kernel_image"] = kernel_image
        if initrd_path is not None:
            payload["initrd_path"] = initrd_path
        if snapshot_path is not None:
            payload["snapshot_path"] = snapshot_path
        if mem_file_path is not None:
            payload["mem_file_path"] = mem_file_path

        data = self._post("sandboxes", json=payload).json()
        return Sandbox(data["id"], self)

    def stop_sandbox(self, sandbox_id: SandboxID, cleanup: bool = True) -> None:
        """Stop a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the sandbox to stop, or a :class:`Sandbox` object.
        cleanup:
            When ``True`` (the default), backing resources (container, VM,
            temporary files) are removed.  When ``False`` the sandbox
            process is stopped but resources are left in place for
            inspection or later resumption.
        """
        sid = _resolve_id(sandbox_id)
        params: Dict[str, str] = {}
        if not cleanup:
            params["cleanup"] = "false"
        self._delete(f"sandboxes/{sid}", params=params)

    def exec_command(self, sandbox_id: SandboxID, command: Command) -> ExecResult:
        """Execute a command inside a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the target sandbox, or a :class:`Sandbox` object.
        command:
            The command to execute.  Accepts a plain string
            (``"ls -al"``), a single-element list containing a shell
            expression (``["ls -al | head"]``), or a traditional argv
            list (``["ls", "-al"]``).  Strings and single-element lists
            that contain whitespace or shell meta-characters are
            automatically wrapped in ``["sh", "-c", ...]``.

        Returns
        -------
        ExecResult
            An object with ``stdout``, ``stderr``, and ``exit_code``
            attributes – the same shape as ``sandbox_pb2.ExecResponse``.
        """
        sid = _resolve_id(sandbox_id)
        argv = _prepare_command(command)
        t0 = _time.monotonic()
        data = self._post(
            f"sandboxes/{sid}/exec",
            json={"command": argv},
        ).json()
        elapsed = (_time.monotonic() - t0) * 1000.0
        return ExecResult(
            stdout=data.get("stdout", ""),
            stderr=data.get("stderr", ""),
            exit_code=int(data.get("exit_code", 0)),
            elapsed_ms=elapsed,
        )

    def get_status(self, sandbox_id: SandboxID) -> str:
        """Return the current status string of a sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the sandbox, or a :class:`Sandbox` object.

        Returns
        -------
        str
            Status such as ``"running"`` or ``"stopped"``.
        """
        sid = _resolve_id(sandbox_id)
        data = self._get(f"sandboxes/{sid}").json()
        return data.get("status", "")

    def get_metrics(self, sandbox_id: SandboxID) -> MetricsResult:
        """Return point-in-time resource-usage metrics for a sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the sandbox, or a :class:`Sandbox` object.

        Returns
        -------
        MetricsResult
            Dataclass with memory, CPU, network, and block-I/O counters.
        """
        sid = _resolve_id(sandbox_id)
        data = self._get(f"sandboxes/{sid}/metrics").json()
        return MetricsResult(
            memory_usage_bytes=int(data.get("memory_usage_bytes", 0)),
            memory_limit_bytes=int(data.get("memory_limit_bytes", 0)),
            memory_percent=float(data.get("memory_percent", 0.0)),
            cpu_percent=float(data.get("cpu_percent", 0.0)),
            pids_current=int(data.get("pids_current", 0)),
            net_rx_bytes=int(data.get("net_rx_bytes", 0)),
            net_tx_bytes=int(data.get("net_tx_bytes", 0)),
            block_read_bytes=int(data.get("block_read_bytes", 0)),
            block_write_bytes=int(data.get("block_write_bytes", 0)),
        )

    def snapshot_sandbox(self, sandbox_id: SandboxID) -> Dict[str, str]:
        """Create a snapshot of a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the sandbox to snapshot, or a :class:`Sandbox` object.

        Returns
        -------
        dict
            ``{"snapshot_path": "...", "mem_file_path": "..."}`` – same
            shape returned by :meth:`SandboxClient.snapshot_sandbox`.
        """
        sid = _resolve_id(sandbox_id)
        data = self._post(f"sandboxes/{sid}/snapshot").json()
        return {
            "snapshot_path": data.get("snapshot_path", ""),
            "mem_file_path": data.get("mem_file_path", ""),
        }

    def resume_sandbox(self, sandbox_id: SandboxID) -> None:
        """Resume a paused / snapshotted sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the sandbox to resume, or a :class:`Sandbox` object.

        .. note::

            This calls ``POST /sandboxes/{id}/resume``.  The endpoint must
            be registered on the orchestrator (it is available in the gRPC
            API but may not yet be wired into the HTTP router).
        """
        sid = _resolve_id(sandbox_id)
        self._post(f"sandboxes/{sid}/resume")

    def run_python(self, sandbox_id: SandboxID, code: str) -> str:
        """Execute Python code inside a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the target sandbox, or a :class:`Sandbox` object.
        code:
            The Python code snippet to execute.

        Returns
        -------
        str
            The standard output resulting from executing the Python code.
        """
        sid = _resolve_id(sandbox_id)
        data = self._post(
            f"sandboxes/{sid}/run_python",
            json={"code": code},
        ).json()
        return data.get("result", "")

    def get_platform_info(self, sandbox_id: SandboxID) -> Dict[str, Any]:
        """Get platform information from a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the target sandbox, or a :class:`Sandbox` object.

        Returns
        -------
        dict
            A dictionary containing platform info (e.g. keys like
            ``"system"``, ``"node"``, ``"release"``, ``"version"``,
            ``"machine"``, ``"processor"``).
        """
        sid = _resolve_id(sandbox_id)
        data = self._get(f"sandboxes/{sid}/platform_info").json()
        info = data.get("info", "")
        if not info:
            return {}
        if isinstance(info, dict):
            return info
        return _json.loads(info)

    def read_file(self, sandbox_id: SandboxID, path: str) -> bytes:
        """Read a file from a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the target sandbox, or a :class:`Sandbox` object.
        path:
            The path of the file to read inside the sandbox.

        Returns
        -------
        bytes
            The contents of the file.
        """
        sid = _resolve_id(sandbox_id)
        data = self._get(f"sandboxes/{sid}/files", params={"path": path}).json()
        return _base64.b64decode(data.get("content", ""))

    def write_file(self, sandbox_id: SandboxID, path: str, content: bytes) -> None:
        """Write a file to a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the target sandbox, or a :class:`Sandbox` object.
        path:
            The path of the file to write inside the sandbox.
        content:
            The file content as bytes.
        """
        sid = _resolve_id(sandbox_id)
        self._put(
            f"sandboxes/{sid}/files",
            json={
                "path": path,
                "content": _base64.b64encode(content).decode("ascii"),
            },
        )


class AsyncHTTPClient:
    """Async client for the Sandbox Orchestrator REST/JSON API.

    The public interface intentionally matches :class:`HTTPClient` but uses
    ``async``/``await`` for all I/O operations.

    Parameters
    ----------
    host:
        Hostname or IP of the orchestrator.  Ignored when *address* is given.
    port:
        HTTP port of the orchestrator (default ``8080``).
    address:
        Full base URL **or** ``host:port/path`` string.  When a path component
        is present it is used as a prefix for all requests (useful for reverse-
        proxy setups such as ``localhost:8092/v1/service/sandbox``).  Overrides
        *host* and *port*.
    timeout:
        Default request timeout in seconds.  ``None`` means no timeout.
    session:
        Optional :class:`aiohttp.ClientSession` to use.  When not provided a new
        session is created internally on the first request.  Passing your own session
        is handy for custom TLS settings, retries, or authentication.
    """

    def __init__(
        self,
        host: Optional[str] = None,
        port: int = 8080,
        address: Optional[str] = None,
        timeout: Optional[float] = 30.0,
        session: Optional[aiohttp.ClientSession] = None,
    ) -> None:
        if address is not None:
            self._base_url = HTTPClient._normalize_address(address)
        elif host is not None:
            self._base_url = f"http://{host}:{port}"
        else:
            raise ValueError("Must provide either 'address' or 'host'")

        if not self._base_url.endswith("/"):
            self._base_url += "/"

        self.timeout = aiohttp.ClientTimeout(total=timeout) if timeout is not None else aiohttp.ClientTimeout()
        self._session = session
        self._owns_session = session is None

    async def _get_session(self) -> aiohttp.ClientSession:
        if self._session is None:
            self._session = aiohttp.ClientSession(
                timeout=self.timeout,
                headers={"X-Otela-Fallback": "2"},
            )
        return self._session

    async def close(self) -> None:
        """Close the underlying HTTP session (if we own it)."""
        if self._owns_session and self._session is not None:
            await self._session.close()

    async def __aenter__(self) -> "AsyncHTTPClient":
        return self

    async def __aexit__(
        self,
        exc_type: Any,
        exc_val: Any,
        exc_tb: Any,
    ) -> None:
        await self.close()

    def _url(self, path: str) -> str:
        return urljoin(self._base_url, path)

    async def _raise_for_status(self, resp: aiohttp.ClientResponse) -> None:
        if resp.ok:
            return
        detail = (await resp.text()).strip()
        if resp.status == 404:
            raise SandboxNotFoundError(resp.status, detail)
        raise SandboxHTTPError(resp.status, detail)

    async def _get(self, path: str, **kwargs: Any) -> dict:
        session = await self._get_session()
        async with session.get(self._url(path), **kwargs) as resp:
            await self._raise_for_status(resp)
            return await resp.json()

    async def _post(self, path: str, json: Any = None, **kwargs: Any) -> dict:
        session = await self._get_session()
        async with session.post(self._url(path), json=json, **kwargs) as resp:
            await self._raise_for_status(resp)
            text = await resp.text()
            if not text.strip():
                return {}
            return await resp.json()

    async def _put(self, path: str, json: Any = None, **kwargs: Any) -> dict:
        session = await self._get_session()
        async with session.put(self._url(path), json=json, **kwargs) as resp:
            await self._raise_for_status(resp)
            text = await resp.text()
            if not text.strip():
                return {}
            return await resp.json()

    async def _delete(self, path: str, **kwargs: Any) -> None:
        session = await self._get_session()
        async with session.delete(self._url(path), **kwargs) as resp:
            await self._raise_for_status(resp)

    async def start_sandbox(
        self,
        type: str,
        image: str,
        command: Optional[List[str]] = None,
        memory_mb: int = 512,
        cpu_cores: float = 1.0,
        kernel_image: Optional[str] = None,
        initrd_path: Optional[str] = None,
        snapshot_path: Optional[str] = None,
        mem_file_path: Optional[str] = None,
    ) -> "AsyncSandbox":
        from .sandbox import AsyncSandbox

        payload: Dict[str, Any] = {
            "type": type,
            "image": image,
            "command": command or [],
            "memory_mb": memory_mb,
            "cpu_cores": cpu_cores,
        }
        if kernel_image is not None:
            payload["kernel_image"] = kernel_image
        if initrd_path is not None:
            payload["initrd_path"] = initrd_path
        if snapshot_path is not None:
            payload["snapshot_path"] = snapshot_path
        if mem_file_path is not None:
            payload["mem_file_path"] = mem_file_path

        data = await self._post("sandboxes", json=payload)
        return AsyncSandbox(data["id"], self)

    async def stop_sandbox(self, sandbox_id: SandboxID, cleanup: bool = True) -> None:
        sid = _resolve_id(sandbox_id)
        params: Dict[str, str] = {}
        if not cleanup:
            params["cleanup"] = "false"
        await self._delete(f"sandboxes/{sid}", params=params)

    async def exec_command(self, sandbox_id: SandboxID, command: Command) -> ExecResult:
        sid = _resolve_id(sandbox_id)
        argv = _prepare_command(command)
        t0 = _time.monotonic()
        data = await self._post(
            f"sandboxes/{sid}/exec",
            json={"command": argv},
        )
        elapsed = (_time.monotonic() - t0) * 1000.0
        return ExecResult(
            stdout=data.get("stdout", ""),
            stderr=data.get("stderr", ""),
            exit_code=int(data.get("exit_code", 0)),
            elapsed_ms=elapsed,
        )

    async def get_status(self, sandbox_id: SandboxID) -> str:
        sid = _resolve_id(sandbox_id)
        data = await self._get(f"sandboxes/{sid}")
        return data.get("status", "")

    async def get_metrics(self, sandbox_id: SandboxID) -> MetricsResult:
        sid = _resolve_id(sandbox_id)
        data = await self._get(f"sandboxes/{sid}/metrics")
        return MetricsResult(
            memory_usage_bytes=int(data.get("memory_usage_bytes", 0)),
            memory_limit_bytes=int(data.get("memory_limit_bytes", 0)),
            memory_percent=float(data.get("memory_percent", 0.0)),
            cpu_percent=float(data.get("cpu_percent", 0.0)),
            pids_current=int(data.get("pids_current", 0)),
            net_rx_bytes=int(data.get("net_rx_bytes", 0)),
            net_tx_bytes=int(data.get("net_tx_bytes", 0)),
            block_read_bytes=int(data.get("block_read_bytes", 0)),
            block_write_bytes=int(data.get("block_write_bytes", 0)),
        )

    async def snapshot_sandbox(self, sandbox_id: SandboxID) -> Dict[str, str]:
        sid = _resolve_id(sandbox_id)
        data = await self._post(f"sandboxes/{sid}/snapshot")
        return {
            "snapshot_path": data.get("snapshot_path", ""),
            "mem_file_path": data.get("mem_file_path", ""),
        }

    async def resume_sandbox(self, sandbox_id: SandboxID) -> None:
        sid = _resolve_id(sandbox_id)
        await self._post(f"sandboxes/{sid}/resume")

    async def run_python(self, sandbox_id: SandboxID, code: str) -> str:
        """Execute Python code inside a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the target sandbox, or an :class:`AsyncSandbox` object.
        code:
            The Python code snippet to execute.

        Returns
        -------
        str
            The standard output resulting from executing the Python code.
        """
        sid = _resolve_id(sandbox_id)
        data = await self._post(
            f"sandboxes/{sid}/run_python",
            json={"code": code},
        )
        return data.get("result", "")

    async def get_platform_info(self, sandbox_id: SandboxID) -> Dict[str, Any]:
        """Get platform information from a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the target sandbox, or an :class:`AsyncSandbox` object.

        Returns
        -------
        dict
            A dictionary containing platform info (e.g. keys like
            ``"system"``, ``"node"``, ``"release"``, ``"version"``,
            ``"machine"``, ``"processor"``).
        """
        sid = _resolve_id(sandbox_id)
        data = await self._get(f"sandboxes/{sid}/platform_info")
        info = data.get("info", "")
        if not info:
            return {}
        if isinstance(info, dict):
            return info
        return _json.loads(info)

    async def read_file(self, sandbox_id: SandboxID, path: str) -> bytes:
        """Read a file from a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the target sandbox, or an :class:`AsyncSandbox` object.
        path:
            The path of the file to read inside the sandbox.

        Returns
        -------
        bytes
            The contents of the file.
        """
        sid = _resolve_id(sandbox_id)
        data = await self._get(f"sandboxes/{sid}/files", params={"path": path})
        return _base64.b64decode(data.get("content", ""))

    async def write_file(self, sandbox_id: SandboxID, path: str, content: bytes) -> None:
        """Write a file to a running sandbox.

        Parameters
        ----------
        sandbox_id:
            ID of the target sandbox, or an :class:`AsyncSandbox` object.
        path:
            The path of the file to write inside the sandbox.
        content:
            The file content as bytes.
        """
        sid = _resolve_id(sandbox_id)
        await self._put(
            f"sandboxes/{sid}/files",
            json={
                "path": path,
                "content": _base64.b64encode(content).decode("ascii"),
            },
        )
