"""Async SandboxClient class for interacting with the sandbox server API."""

from __future__ import annotations

import asyncio
from typing import Any, Optional

import httpx

from langsmith import utils as ls_utils
from langsmith.sandbox._async_sandbox import AsyncSandbox
from langsmith.sandbox._exceptions import (
    ResourceCreationError,
    ResourceInUseError,
    ResourceNameConflictError,
    ResourceNotFoundError,
    ResourceTimeoutError,
    SandboxAPIError,
    ValidationError,
)
from langsmith.sandbox._helpers import (
    handle_client_http_error,
    handle_pool_error,
    handle_sandbox_creation_error,
    handle_volume_creation_error,
    parse_error_response,
)
from langsmith.sandbox._models import (
    Pool,
    ResourceStatus,
    SandboxTemplate,
    Volume,
    VolumeMountSpec,
)
from langsmith.sandbox._transport import AsyncRetryTransport


def _get_default_api_endpoint() -> str:
    """Get the default sandbox API endpoint from environment.

    Derives the endpoint from LANGSMITH_ENDPOINT (or LANGCHAIN_ENDPOINT).
    """
    base = ls_utils.get_env_var("ENDPOINT", default="https://api.smith.langchain.com")
    return f"{base.rstrip('/')}/v2/sandboxes"


def _get_default_api_key() -> Optional[str]:
    """Get the default API key from environment."""
    return ls_utils.get_env_var("API_KEY")


class AsyncSandboxClient:
    """Async client for interacting with the Sandbox Server API.

    This client provides an async interface for managing sandboxes and templates.

    Example:
        # Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment
        async with AsyncSandboxClient() as client:
            # Create a sandbox and run commands
            async with await client.sandbox(template_name="python-sandbox") as sandbox:
                result = await sandbox.run("python --version")
                print(result.stdout)
    """

    def __init__(
        self,
        *,
        api_endpoint: Optional[str] = None,
        timeout: float = 10.0,
        api_key: Optional[str] = None,
        max_retries: int = 3,
    ):
        """Initialize the AsyncSandboxClient.

        Args:
            api_endpoint: Full URL of the sandbox API endpoint. If not provided,
                          derived from LANGSMITH_ENDPOINT environment variable.
            timeout: Default HTTP timeout in seconds.
            api_key: API key for authentication. If not provided, uses
                     LANGSMITH_API_KEY environment variable.
            max_retries: Maximum number of retries for transient errors (502, 503,
                         504), rate limits (429), and connection failures. Set to 0
                         to disable retries. Default: 3.
        """
        self._base_url = (api_endpoint or _get_default_api_endpoint()).rstrip("/")
        resolved_api_key = api_key or _get_default_api_key()
        self._api_key = resolved_api_key
        headers: dict[str, str] = {}
        if resolved_api_key:
            headers["X-Api-Key"] = resolved_api_key
        transport = AsyncRetryTransport(max_retries=max_retries)
        self._http = httpx.AsyncClient(
            transport=transport, timeout=timeout, headers=headers
        )

    async def aclose(self) -> None:
        """Close the async HTTP client."""
        await self._http.aclose()

    def __del__(self) -> None:
        """Best-effort cleanup of the async HTTP client on garbage collection.

        If an event loop is running, schedules ``aclose()`` as a task.
        Otherwise the underlying sockets will be closed by the GC.
        For deterministic cleanup, use ``async with`` or ``await aclose()``.
        """
        try:
            if not self._http.is_closed:
                try:
                    loop = asyncio.get_running_loop()
                    if not loop.is_closed():
                        loop.create_task(self.aclose())
                except RuntimeError:
                    pass
        except Exception:
            pass

    async def __aenter__(self) -> AsyncSandboxClient:
        """Enter async context manager."""
        return self

    async def __aexit__(
        self,
        exc_type: Optional[type],
        exc_val: Optional[BaseException],
        exc_tb: Optional[Any],
    ) -> None:
        """Exit async context manager."""
        await self.aclose()

    # ========================================================================
    # Volume Operations
    # ========================================================================

    async def create_volume(
        self,
        name: str,
        size: str,
        *,
        timeout: int = 60,
    ) -> Volume:
        """Create a new persistent volume.

        Creates a persistent storage volume that can be referenced in templates.

        Args:
            name: Volume name.
            size: Storage size (e.g., "1Gi", "10Gi").
            timeout: Timeout in seconds when waiting for ready (min: 5, max: 300).

        Returns:
            Created Volume.

        Raises:
            VolumeProvisioningError: If volume provisioning fails.
            ResourceTimeoutError: If volume doesn't become ready within timeout.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/volumes"

        payload = {
            "name": name,
            "size": size,
            "wait_for_ready": True,
            "timeout": timeout,
        }

        try:
            response = await self._http.post(url, json=payload, timeout=timeout + 30)
            response.raise_for_status()
            return Volume.from_dict(response.json())
        except httpx.HTTPStatusError as e:
            handle_volume_creation_error(e)
            raise  # pragma: no cover

    async def get_volume(self, name: str) -> Volume:
        """Get a volume by name.

        Args:
            name: Volume name.

        Returns:
            Volume.

        Raises:
            ResourceNotFoundError: If volume not found.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/volumes/{name}"

        try:
            response = await self._http.get(url)
            response.raise_for_status()
            return Volume.from_dict(response.json())
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Volume '{name}' not found", resource_type="volume"
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def list_volumes(self) -> list[Volume]:
        """List all volumes.

        Returns:
            List of Volumes.
        """
        url = f"{self._base_url}/volumes"

        try:
            response = await self._http.get(url)
            response.raise_for_status()
            data = response.json()
            return [Volume.from_dict(v) for v in data.get("volumes", [])]
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise SandboxAPIError(
                    f"API endpoint not found: {url}. "
                    f"Check that api_endpoint is correct."
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def delete_volume(self, name: str) -> None:
        """Delete a volume.

        Args:
            name: Volume name.

        Raises:
            ResourceNotFoundError: If volume not found.
            ResourceInUseError: If volume is referenced by templates.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/volumes/{name}"

        try:
            response = await self._http.delete(url)
            response.raise_for_status()
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Volume '{name}' not found", resource_type="volume"
                ) from e
            if e.response.status_code == 409:
                data = parse_error_response(e)
                raise ResourceInUseError(data["message"], resource_type="volume") from e
            handle_client_http_error(e)

    async def update_volume(
        self,
        name: str,
        *,
        new_name: Optional[str] = None,
        size: Optional[str] = None,
    ) -> Volume:
        """Update a volume's name and/or size.

        You can update the display name, size, or both in a single request.
        Only storage size increases are allowed (storage backend limitation).

        Args:
            name: Current volume name.
            new_name: New display name (optional).
            size: New storage size (must be >= current size). Optional.

        Returns:
            Updated Volume.

        Raises:
            ResourceNotFoundError: If volume not found.
            VolumeResizeError: If storage decrease attempted.
            ResourceNameConflictError: If new_name is already in use.
            SandboxQuotaExceededError: If storage quota would be exceeded.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/volumes/{name}"
        payload: dict[str, Any] = {}
        if new_name is not None:
            payload["name"] = new_name
        if size is not None:
            payload["size"] = size

        if not payload:
            # Nothing to update, just return the current volume
            return await self.get_volume(name)

        try:
            response = await self._http.patch(url, json=payload)
            response.raise_for_status()
            return Volume.from_dict(response.json())
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Volume '{name}' not found", resource_type="volume"
                ) from e
            if e.response.status_code == 400:
                data = parse_error_response(e)
                raise ValidationError(data["message"], error_type="VolumeResize") from e
            if e.response.status_code == 409:
                data = parse_error_response(e)
                raise ResourceNameConflictError(
                    data["message"], resource_type="volume"
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    # ========================================================================
    # Template Operations
    # ========================================================================

    async def create_template(
        self,
        name: str,
        image: str,
        *,
        cpu: str = "500m",
        memory: str = "512Mi",
        storage: Optional[str] = None,
        volume_mounts: Optional[list[VolumeMountSpec]] = None,
    ) -> SandboxTemplate:
        """Create a new SandboxTemplate.

        Only the container image, resource limits, and volume mounts can be
        configured. All other container details are handled by the server.

        Args:
            name: Template name.
            image: Container image (e.g., "python:3.12-slim").
            cpu: CPU limit (e.g., "500m", "1", "2"). Default: "500m".
            memory: Memory limit (e.g., "256Mi", "1Gi"). Default: "512Mi".
            storage: Ephemeral storage limit (e.g., "1Gi"). Optional.
            volume_mounts: List of volumes to mount in the sandbox. Optional.

        Returns:
            Created SandboxTemplate.

        Raises:
            SandboxClientError: If creation fails.
        """
        url = f"{self._base_url}/templates"

        payload: dict[str, Any] = {
            "name": name,
            "image": image,
            "resources": {
                "cpu": cpu,
                "memory": memory,
            },
        }
        if storage:
            payload["resources"]["storage"] = storage
        if volume_mounts:
            payload["volume_mounts"] = [
                {"volume_name": vm.volume_name, "mount_path": vm.mount_path}
                for vm in volume_mounts
            ]

        try:
            response = await self._http.post(url, json=payload)
            response.raise_for_status()
            return SandboxTemplate.from_dict(response.json())
        except httpx.HTTPStatusError as e:
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def get_template(self, name: str) -> SandboxTemplate:
        """Get a SandboxTemplate by name.

        Args:
            name: Template name.

        Returns:
            SandboxTemplate.

        Raises:
            ResourceNotFoundError: If template not found.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/templates/{name}"

        try:
            response = await self._http.get(url)
            response.raise_for_status()
            return SandboxTemplate.from_dict(response.json())
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Template '{name}' not found", resource_type="template"
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def list_templates(self) -> list[SandboxTemplate]:
        """List all SandboxTemplates.

        Returns:
            List of SandboxTemplates.
        """
        url = f"{self._base_url}/templates"

        try:
            response = await self._http.get(url)
            response.raise_for_status()
            data = response.json()
            return [SandboxTemplate.from_dict(t) for t in data.get("templates", [])]
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise SandboxAPIError(
                    f"API endpoint not found: {url}. "
                    f"Check that api_endpoint is correct."
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def update_template(self, name: str, *, new_name: str) -> SandboxTemplate:
        """Update a template's display name.

        Args:
            name: Current template name.
            new_name: New display name.

        Returns:
            Updated SandboxTemplate.

        Raises:
            ResourceNotFoundError: If template not found.
            ResourceNameConflictError: If new_name is already in use.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/templates/{name}"
        payload = {"name": new_name}

        try:
            response = await self._http.patch(url, json=payload)
            response.raise_for_status()
            return SandboxTemplate.from_dict(response.json())
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Template '{name}' not found", resource_type="template"
                ) from e
            if e.response.status_code == 409:
                data = parse_error_response(e)
                raise ResourceNameConflictError(
                    data["message"], resource_type="template"
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def delete_template(self, name: str) -> None:
        """Delete a SandboxTemplate.

        Args:
            name: Template name.

        Raises:
            ResourceNotFoundError: If template not found.
            ResourceInUseError: If template is referenced by sandboxes or pools.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/templates/{name}"

        try:
            response = await self._http.delete(url)
            response.raise_for_status()
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Template '{name}' not found", resource_type="template"
                ) from e
            if e.response.status_code == 409:
                data = parse_error_response(e)
                raise ResourceInUseError(
                    data["message"], resource_type="template"
                ) from e
            handle_client_http_error(e)

    # ========================================================================
    # Pool Operations
    # ========================================================================

    async def create_pool(
        self,
        name: str,
        template_name: str,
        replicas: int,
        *,
        timeout: int = 30,
    ) -> Pool:
        """Create a new Sandbox Pool.

        Pools pre-provision sandboxes from a template for faster startup.

        Args:
            name: Pool name (lowercase letters, numbers, hyphens; max 63 chars).
            template_name: Name of the SandboxTemplate to use (no volume mounts).
            replicas: Number of sandboxes to pre-provision (1-100).
            timeout: Timeout in seconds when waiting for ready (10-600).

        Returns:
            Created Pool.

        Raises:
            ResourceNotFoundError: If template not found.
            ValidationError: If template has volumes attached.
            ResourceAlreadyExistsError: If pool with this name already exists.
            ResourceTimeoutError: If pool doesn't reach ready state within timeout.
            SandboxQuotaExceededError: If organization quota is exceeded.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/pools"

        payload: dict[str, Any] = {
            "name": name,
            "template_name": template_name,
            "replicas": replicas,
            "wait_for_ready": True,
            "timeout": timeout,
        }

        try:
            http_timeout = timeout + 30
            response = await self._http.post(url, json=payload, timeout=http_timeout)
            response.raise_for_status()
            return Pool.from_dict(response.json())
        except httpx.HTTPStatusError as e:
            handle_pool_error(e)
            raise  # pragma: no cover

    async def get_pool(self, name: str) -> Pool:
        """Get a Pool by name.

        Args:
            name: Pool name.

        Returns:
            Pool.

        Raises:
            ResourceNotFoundError: If pool not found.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/pools/{name}"

        try:
            response = await self._http.get(url)
            response.raise_for_status()
            return Pool.from_dict(response.json())
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Pool '{name}' not found", resource_type="pool"
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def list_pools(self) -> list[Pool]:
        """List all Pools.

        Returns:
            List of Pools.
        """
        url = f"{self._base_url}/pools"

        try:
            response = await self._http.get(url)
            response.raise_for_status()
            data = response.json()
            return [Pool.from_dict(p) for p in data.get("pools", [])]
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise SandboxAPIError(
                    f"API endpoint not found: {url}. "
                    f"Check that api_endpoint is correct."
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def update_pool(
        self,
        name: str,
        *,
        new_name: Optional[str] = None,
        replicas: Optional[int] = None,
    ) -> Pool:
        """Update a Pool's name and/or replica count.

        You can update the display name, replica count, or both.
        The template reference cannot be changed after creation.

        Args:
            name: Current pool name.
            new_name: New display name (optional).
            replicas: New number of replicas (0-100). Set to 0 to pause.

        Returns:
            Updated Pool.

        Raises:
            ResourceNotFoundError: If pool not found.
            ValidationError: If template was deleted.
            ResourceNameConflictError: If new_name is already in use.
            SandboxQuotaExceededError: If quota exceeded when scaling up.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/pools/{name}"

        payload: dict[str, Any] = {}
        if new_name is not None:
            payload["name"] = new_name
        if replicas is not None:
            payload["replicas"] = replicas

        if not payload:
            # Nothing to update, just return the current pool
            return await self.get_pool(name)

        try:
            response = await self._http.patch(url, json=payload)
            response.raise_for_status()
            return Pool.from_dict(response.json())
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Pool '{name}' not found", resource_type="pool"
                ) from e
            if e.response.status_code == 409:
                data = parse_error_response(e)
                raise ResourceNameConflictError(
                    data["message"], resource_type="pool"
                ) from e
            handle_pool_error(e)
            raise  # pragma: no cover

    async def delete_pool(self, name: str) -> None:
        """Delete a Pool.

        This will terminate all sandboxes in the pool.

        Args:
            name: Pool name.

        Raises:
            ResourceNotFoundError: If pool not found.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/pools/{name}"

        try:
            response = await self._http.delete(url)
            response.raise_for_status()
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Pool '{name}' not found", resource_type="pool"
                ) from e
            handle_client_http_error(e)

    # ========================================================================
    # Sandbox Operations
    # ========================================================================

    async def sandbox(
        self,
        template_name: str,
        *,
        name: Optional[str] = None,
        timeout: int = 30,
    ) -> AsyncSandbox:
        """Create a sandbox and return an AsyncSandbox instance.

        This is the primary method for creating sandboxes. Use it as an
        async context manager for automatic cleanup:

            async with await client.sandbox(template_name="my-template") as sandbox:
                result = await sandbox.run("echo hello")

        The sandbox is automatically deleted when exiting the context manager.
        For sandboxes with manual lifecycle management, use create_sandbox().

        Args:
            template_name: Name of the SandboxTemplate to use.
            name: Optional sandbox name (auto-generated if not provided).
            timeout: Timeout in seconds when waiting for ready.

        Returns:
            AsyncSandbox instance.

        Raises:
            ResourceTimeoutError: If timeout waiting for sandbox to be ready.
            ResourceCreationError: If sandbox creation fails.
            SandboxClientError: For other errors.
        """
        sb = await self.create_sandbox(
            template_name=template_name,
            name=name,
            timeout=timeout,
        )
        sb._auto_delete = True
        return sb

    async def create_sandbox(
        self,
        template_name: str,
        *,
        name: Optional[str] = None,
        timeout: int = 30,
        wait_for_ready: bool = True,
    ) -> AsyncSandbox:
        """Create a new Sandbox.

        The sandbox is NOT automatically deleted. Use delete_sandbox() for cleanup,
        or use sandbox() for automatic cleanup with a context manager.

        Args:
            template_name: Name of the SandboxTemplate to use.
            name: Optional sandbox name (auto-generated if not provided).
            timeout: Timeout in seconds when waiting for ready (only used when
                wait_for_ready=True).
            wait_for_ready: If True (default), block until sandbox is ready.
                If False, return immediately with status "provisioning". Use
                get_sandbox_status() or wait_for_sandbox() to poll for readiness.

        Returns:
            Created AsyncSandbox. When wait_for_ready=False, the sandbox will have
            status="provisioning" and cannot be used for operations until ready.

        Raises:
            ResourceTimeoutError: If timeout waiting for sandbox to be ready.
            ResourceCreationError: If sandbox creation fails.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/boxes"

        payload: dict[str, Any] = {
            "template_name": template_name,
            "wait_for_ready": wait_for_ready,
        }
        if wait_for_ready:
            payload["timeout"] = timeout
        if name:
            payload["name"] = name

        http_timeout = (timeout + 30) if wait_for_ready else 30

        try:
            response = await self._http.post(url, json=payload, timeout=http_timeout)
            response.raise_for_status()
            return AsyncSandbox.from_dict(
                response.json(), client=self, auto_delete=False
            )
        except httpx.HTTPStatusError as e:
            handle_sandbox_creation_error(e)
            raise  # pragma: no cover

    async def get_sandbox(self, name: str) -> AsyncSandbox:
        """Get a Sandbox by name.

        The sandbox is NOT automatically deleted. Use delete_sandbox() for cleanup.

        Args:
            name: Sandbox name.

        Returns:
            AsyncSandbox.

        Raises:
            ResourceNotFoundError: If sandbox not found.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/boxes/{name}"

        try:
            response = await self._http.get(url)
            response.raise_for_status()
            return AsyncSandbox.from_dict(
                response.json(), client=self, auto_delete=False
            )
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Sandbox '{name}' not found", resource_type="sandbox"
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def list_sandboxes(self) -> list[AsyncSandbox]:
        """List all Sandboxes.

        Returns:
            List of AsyncSandboxes.
        """
        url = f"{self._base_url}/boxes"

        try:
            response = await self._http.get(url)
            response.raise_for_status()
            data = response.json()
            return [
                AsyncSandbox.from_dict(c, client=self, auto_delete=False)
                for c in data.get("sandboxes", [])
            ]
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise SandboxAPIError(
                    f"API endpoint not found: {url}. "
                    f"Check that api_endpoint is correct."
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def update_sandbox(self, name: str, *, new_name: str) -> AsyncSandbox:
        """Update a sandbox's display name.

        Args:
            name: Current sandbox name.
            new_name: New display name.

        Returns:
            Updated AsyncSandbox.

        Raises:
            ResourceNotFoundError: If sandbox not found.
            ResourceNameConflictError: If new_name is already in use.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/boxes/{name}"
        payload = {"name": new_name}

        try:
            response = await self._http.patch(url, json=payload)
            response.raise_for_status()
            return AsyncSandbox.from_dict(
                response.json(), client=self, auto_delete=False
            )
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Sandbox '{name}' not found", resource_type="sandbox"
                ) from e
            if e.response.status_code == 409:
                raise ResourceNameConflictError(
                    f"Sandbox name '{new_name}' already in use",
                    resource_type="sandbox",
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def delete_sandbox(self, name: str) -> None:
        """Delete a Sandbox.

        Args:
            name: Sandbox name.

        Raises:
            ResourceNotFoundError: If sandbox not found.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/boxes/{name}"

        try:
            response = await self._http.delete(url)
            response.raise_for_status()
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Sandbox '{name}' not found", resource_type="sandbox"
                ) from e
            handle_client_http_error(e)

    async def get_sandbox_status(self, name: str) -> ResourceStatus:
        """Get the provisioning status of a sandbox.

        This is a lightweight endpoint designed for high-frequency polling
        during sandbox provisioning. It returns only the status fields
        without full sandbox data.

        Args:
            name: Sandbox name.

        Returns:
            ResourceStatus with status and status_message.

        Raises:
            ResourceNotFoundError: If sandbox not found.
            SandboxClientError: For other errors.
        """
        url = f"{self._base_url}/boxes/{name}/status"

        try:
            response = await self._http.get(url)
            response.raise_for_status()
            return ResourceStatus.from_dict(response.json())
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ResourceNotFoundError(
                    f"Sandbox '{name}' not found", resource_type="sandbox"
                ) from e
            handle_client_http_error(e)
            raise  # pragma: no cover

    async def wait_for_sandbox(
        self,
        name: str,
        *,
        timeout: int = 120,
        poll_interval: float = 1.0,
    ) -> AsyncSandbox:
        """Poll until a sandbox reaches "ready" or "failed" status.

        Uses the lightweight status endpoint for polling, then fetches the
        full sandbox data once ready.

        Args:
            name: Sandbox name.
            timeout: Maximum time to wait in seconds.
            poll_interval: Time between status checks in seconds.

        Returns:
            AsyncSandbox in "ready" status.

        Raises:
            ResourceCreationError: If sandbox status becomes "failed".
            ResourceTimeoutError: If timeout expires while still "provisioning".
            ResourceNotFoundError: If sandbox not found.
            SandboxClientError: For other errors.
        """
        import time

        deadline = time.monotonic() + timeout
        while True:
            status = await self.get_sandbox_status(name)
            if status.status == "ready":
                return await self.get_sandbox(name)
            if status.status == "failed":
                raise ResourceCreationError(
                    status.status_message or "Sandbox provisioning failed",
                    resource_type="sandbox",
                )
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise ResourceTimeoutError(
                    f"Sandbox '{name}' not ready after {timeout}s",
                    resource_type="sandbox",
                    last_status=status.status,
                )
            await asyncio.sleep(min(poll_interval, remaining))
