"""Unit tests for :class:`flash_sandbox.AsyncHTTPClient`.

All HTTP interactions are mocked so the tests run without a live orchestrator.
"""

import json
import sys
import os
from unittest.mock import MagicMock, AsyncMock, patch

import pytest

# Ensure the package root is on sys.path so the import works from any CWD.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from flash_sandbox.http_client import (
    AsyncHTTPClient,
    ExecResult,
    MetricsResult,
    SnapshotResult,
    SandboxHTTPError,
    SandboxNotFoundError,
    _resolve_id,
)
from flash_sandbox.sandbox import AsyncSandbox, Sandbox


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _mock_async_response(
    status: int = 200,
    json_body: object = None,
    text: str = "",
    ok: bool = True,
) -> MagicMock:
    """Build a fake aiohttp.ClientResponse mock."""
    resp = AsyncMock()
    resp.status = status
    resp.ok = ok
    text_val = text or (json.dumps(json_body) if json_body is not None else "")
    resp.text.return_value = text_val
    resp.json.return_value = json_body

    # Mock the async context manager
    ctx = AsyncMock()
    ctx.__aenter__.return_value = resp
    ctx.__aexit__.return_value = None
    return ctx


# ---------------------------------------------------------------------------
# Construction / address normalisation
# ---------------------------------------------------------------------------


class TestAsyncClientInit:
    """Tests for AsyncHTTPClient.__init__ and address normalisation."""

    def test_host_port(self):
        client = AsyncHTTPClient(host="myhost", port=9090)
        assert client._base_url == "http://myhost:9090/"

    def test_host_default_port(self):
        client = AsyncHTTPClient(host="myhost")
        assert client._base_url == "http://myhost:8080/"

    def test_address_simple(self):
        client = AsyncHTTPClient(address="http://10.0.0.1:8080")
        assert client._base_url == "http://10.0.0.1:8080/"

    def test_address_with_path(self):
        client = AsyncHTTPClient(address="localhost:8092/v1/service/sandbox")
        assert "localhost" in client._base_url
        assert "/v1/service/sandbox" in client._base_url

    def test_address_with_scheme_and_path(self):
        client = AsyncHTTPClient(address="https://proxy.example.com:443/prefix")
        assert client._base_url.startswith("https://")
        assert "/prefix" in client._base_url

    def test_address_overrides_host(self):
        client = AsyncHTTPClient(host="ignored", port=1111, address="http://used:2222")
        assert "used:2222" in client._base_url
        assert "ignored" not in client._base_url

    def test_no_host_or_address_raises(self):
        with pytest.raises(ValueError, match="Must provide"):
            AsyncHTTPClient()

    @pytest.mark.asyncio
    async def test_context_manager(self):
        async with AsyncHTTPClient(host="localhost") as client:
            assert client._base_url == "http://localhost:8080/"

    @pytest.mark.asyncio
    async def test_external_session_not_closed(self):
        session = AsyncMock()
        client = AsyncHTTPClient(host="localhost", session=session)
        await client.close()
        session.close.assert_not_called()

    @pytest.mark.asyncio
    async def test_internal_session_closed(self):
        client = AsyncHTTPClient(host="localhost")
        inner_session = AsyncMock()
        client._session = inner_session
        await client.close()
        inner_session.close.assert_called_once()


# ---------------------------------------------------------------------------
# start_sandbox
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestStartSandbox:
    def _make_client(self, session: MagicMock) -> AsyncHTTPClient:
        client = AsyncHTTPClient(host="localhost", port=8080, session=session)
        return client

    async def test_basic(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(json_body={"id": "sb-123"})
        client = self._make_client(session)

        sid = await client.start_sandbox(
            type="docker",
            image="alpine:latest",
            command=["sleep", "3600"],
            memory_mb=256,
            cpu_cores=0.5,
        )

        assert isinstance(sid, AsyncSandbox)
        assert sid.id == "sb-123"
        call_args = session.post.call_args
        assert "sandboxes" in call_args[0][0]
        payload = call_args[1]["json"]
        assert payload["type"] == "docker"
        assert payload["image"] == "alpine:latest"
        assert payload["command"] == ["sleep", "3600"]
        assert payload["memory_mb"] == 256
        assert payload["cpu_cores"] == 0.5

    async def test_defaults(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(json_body={"id": "sb-456"})
        client = self._make_client(session)

        sid = await client.start_sandbox(type="docker", image="ubuntu:22.04")

        assert sid.id == "sb-456"
        payload = session.post.call_args[1]["json"]
        assert payload["command"] == []
        assert payload["memory_mb"] == 512
        assert payload["cpu_cores"] == 1.0

    async def test_firecracker_extra_fields(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(json_body={"id": "fc-001"})
        client = self._make_client(session)

        sid = await client.start_sandbox(
            type="firecracker",
            image="rootfs.ext4",
            kernel_image="/boot/vmlinux",
            initrd_path="/boot/initrd",
        )

        assert sid.id == "fc-001"
        payload = session.post.call_args[1]["json"]
        assert payload["kernel_image"] == "/boot/vmlinux"
        assert payload["initrd_path"] == "/boot/initrd"

    async def test_snapshot_restore_fields(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(json_body={"id": "snap-001"})
        client = self._make_client(session)

        sid = await client.start_sandbox(
            type="firecracker",
            image="rootfs.ext4",
            snapshot_path="/snapshots/snap.bin",
            mem_file_path="/snapshots/mem.bin",
        )

        assert sid.id == "snap-001"
        payload = session.post.call_args[1]["json"]
        assert payload["snapshot_path"] == "/snapshots/snap.bin"
        assert payload["mem_file_path"] == "/snapshots/mem.bin"

    async def test_optional_fields_omitted_when_none(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(json_body={"id": "sb-x"})
        client = self._make_client(session)

        await client.start_sandbox(type="docker", image="alpine")

        payload = session.post.call_args[1]["json"]
        assert "kernel_image" not in payload
        assert "initrd_path" not in payload
        assert "snapshot_path" not in payload
        assert "mem_file_path" not in payload

    async def test_server_error_raises(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(
            status=500,
            ok=False,
            text="backend type bork not registered",
        )
        client = self._make_client(session)

        with pytest.raises(SandboxHTTPError) as exc_info:
            await client.start_sandbox(type="bork", image="nope")
        assert exc_info.value.status_code == 500


# ---------------------------------------------------------------------------
# stop_sandbox
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestStopSandbox:
    async def test_success(self):
        session = MagicMock()
        session.delete.return_value = _mock_async_response(status=204, json_body=None)
        client = AsyncHTTPClient(host="localhost", session=session)

        await client.stop_sandbox("sb-123")

        call_url = session.delete.call_args[0][0]
        assert "sandboxes/sb-123" in call_url

    async def test_cleanup_true_by_default(self):
        """When cleanup is not specified (default True), no query param is sent."""
        session = MagicMock()
        session.delete.return_value = _mock_async_response(status=204, json_body=None)
        client = AsyncHTTPClient(host="localhost", session=session)

        await client.stop_sandbox("sb-123")

        call_kwargs = session.delete.call_args
        params = call_kwargs.kwargs.get("params", {})
        assert "cleanup" not in params

    async def test_cleanup_true_explicit(self):
        """Explicitly passing cleanup=True also omits the query param."""
        session = MagicMock()
        session.delete.return_value = _mock_async_response(status=204, json_body=None)
        client = AsyncHTTPClient(host="localhost", session=session)

        await client.stop_sandbox("sb-123", cleanup=True)

        call_kwargs = session.delete.call_args
        params = call_kwargs.kwargs.get("params", {})
        assert "cleanup" not in params

    async def test_cleanup_false_sends_query_param(self):
        """When cleanup=False, a ?cleanup=false query param is sent."""
        session = MagicMock()
        session.delete.return_value = _mock_async_response(status=204, json_body=None)
        client = AsyncHTTPClient(host="localhost", session=session)

        await client.stop_sandbox("sb-123", cleanup=False)

        call_kwargs = session.delete.call_args
        params = call_kwargs.kwargs.get("params", {})
        assert params.get("cleanup") == "false"

    async def test_cleanup_false_with_sandbox_object(self):
        """cleanup=False works when passing an AsyncSandbox object too."""
        session = MagicMock()
        session.delete.return_value = _mock_async_response(status=204, json_body=None)
        client = AsyncHTTPClient(host="localhost", session=session)
        sb = AsyncSandbox("sb-async-cleanup", client)

        await client.stop_sandbox(sb, cleanup=False)

        call_url = session.delete.call_args[0][0]
        assert "sandboxes/sb-async-cleanup" in call_url
        params = session.delete.call_args.kwargs.get("params", {})
        assert params.get("cleanup") == "false"

    async def test_not_found_raises(self):
        session = MagicMock()
        session.delete.return_value = _mock_async_response(
            status=404,
            ok=False,
            text="sandbox sb-bogus not found",
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        with pytest.raises(SandboxNotFoundError):
            await client.stop_sandbox("sb-bogus")


# ---------------------------------------------------------------------------
# exec_command
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestExecCommand:
    async def test_success(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(
            json_body={"stdout": "hello\n", "stderr": "", "exit_code": 0}
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        result = await client.exec_command("sb-123", ["echo", "hello"])

        assert isinstance(result, ExecResult)
        assert result.stdout == "hello\n"
        assert result.stderr == ""
        assert result.exit_code == 0
        assert result.elapsed_ms > 0

        payload = session.post.call_args[1]["json"]
        assert payload["command"] == ["echo", "hello"]

    async def test_elapsed_ms_populated(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(
            json_body={"stdout": "ok\n", "stderr": "", "exit_code": 0}
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        result = await client.exec_command("sb-123", ["echo", "ok"])

        assert isinstance(result.elapsed_ms, float)
        assert result.elapsed_ms >= 0

    async def test_nonzero_exit(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(
            json_body={"stdout": "", "stderr": "not found\n", "exit_code": 127}
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        result = await client.exec_command("sb-123", ["nonexistent"])

        assert result.exit_code == 127
        assert result.stderr == "not found\n"

    async def test_sandbox_not_found(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(
            status=404,
            ok=False,
            text="sandbox sb-gone not found",
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        with pytest.raises(SandboxNotFoundError):
            await client.exec_command("sb-gone", ["ls"])


# ---------------------------------------------------------------------------
# get_status
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestGetStatus:
    async def test_running(self):
        session = MagicMock()
        session.get.return_value = _mock_async_response(
            json_body={"id": "sb-123", "status": "running"}
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        status = await client.get_status("sb-123")

        assert status == "running"
        call_url = session.get.call_args[0][0]
        assert "sandboxes/sb-123" in call_url

    async def test_stopped(self):
        session = MagicMock()
        session.get.return_value = _mock_async_response(
            json_body={"id": "sb-123", "status": "stopped"}
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        assert await client.get_status("sb-123") == "stopped"

    async def test_not_found(self):
        session = MagicMock()
        session.get.return_value = _mock_async_response(
            status=404,
            ok=False,
            text="sandbox sb-nope not found",
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        with pytest.raises(SandboxNotFoundError):
            await client.get_status("sb-nope")


# ---------------------------------------------------------------------------
# get_metrics
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestGetMetrics:
    SAMPLE_METRICS = {
        "memory_usage_bytes": 104857600,
        "memory_limit_bytes": 536870912,
        "memory_percent": 19.53,
        "cpu_percent": 12.5,
        "pids_current": 7,
        "net_rx_bytes": 1024,
        "net_tx_bytes": 2048,
        "block_read_bytes": 4096,
        "block_write_bytes": 8192,
    }

    async def test_success(self):
        session = MagicMock()
        session.get.return_value = _mock_async_response(json_body=self.SAMPLE_METRICS)
        client = AsyncHTTPClient(host="localhost", session=session)

        m = await client.get_metrics("sb-123")

        assert isinstance(m, MetricsResult)
        assert m.memory_usage_bytes == 104857600
        assert m.memory_limit_bytes == 536870912
        assert m.memory_percent == pytest.approx(19.53)
        assert m.cpu_percent == pytest.approx(12.5)
        assert m.pids_current == 7
        assert m.net_rx_bytes == 1024
        assert m.net_tx_bytes == 2048
        assert m.block_read_bytes == 4096
        assert m.block_write_bytes == 8192

    async def test_missing_fields_default_to_zero(self):
        session = MagicMock()
        session.get.return_value = _mock_async_response(json_body={})
        client = AsyncHTTPClient(host="localhost", session=session)

        m = await client.get_metrics("sb-empty")

        assert m.memory_usage_bytes == 0
        assert m.cpu_percent == 0.0
        assert m.pids_current == 0

    async def test_not_found(self):
        session = MagicMock()
        session.get.return_value = _mock_async_response(
            status=404, ok=False, text="not found"
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        with pytest.raises(SandboxNotFoundError):
            await client.get_metrics("sb-ghost")


# ---------------------------------------------------------------------------
# snapshot_sandbox
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestSnapshotSandbox:
    async def test_success(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(
            json_body={
                "snapshot_path": "/snaps/sb-123/snap.bin",
                "mem_file_path": "/snaps/sb-123/mem.bin",
            }
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        result = await client.snapshot_sandbox("sb-123")

        assert isinstance(result, dict)
        assert result["snapshot_path"] == "/snaps/sb-123/snap.bin"
        assert result["mem_file_path"] == "/snaps/sb-123/mem.bin"

        call_url = session.post.call_args[0][0]
        assert "sandboxes/sb-123/snapshot" in call_url

    async def test_server_error(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(
            status=500, ok=False, text="snapshot not supported"
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        with pytest.raises(SandboxHTTPError) as exc_info:
            await client.snapshot_sandbox("sb-bad")
        assert exc_info.value.status_code == 500


# ---------------------------------------------------------------------------
# resume_sandbox
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestResumeSandbox:
    async def test_success(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(status=200, json_body={})
        client = AsyncHTTPClient(host="localhost", session=session)

        # Should not raise.
        await client.resume_sandbox("sb-123")

        call_url = session.post.call_args[0][0]
        assert "sandboxes/sb-123/resume" in call_url

    async def test_not_found(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(
            status=404, ok=False, text="sandbox not found"
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        with pytest.raises(SandboxNotFoundError):
            await client.resume_sandbox("sb-nope")


# ---------------------------------------------------------------------------
# run_python
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestRunPython:
    async def test_success(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(json_body={"result": "42\n"})
        client = AsyncHTTPClient(host="localhost", session=session)

        result = await client.run_python("sb-123", "print(6 * 7)")

        assert result == "42\n"
        call_url = session.post.call_args[0][0]
        assert "sandboxes/sb-123/run_python" in call_url
        payload = session.post.call_args[1]["json"]
        assert payload["code"] == "print(6 * 7)"

    async def test_empty_result(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(json_body={})
        client = AsyncHTTPClient(host="localhost", session=session)

        result = await client.run_python("sb-123", "x = 1")

        assert result == ""

    async def test_not_found(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(
            status=404, ok=False, text="sandbox sb-999 not found"
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        with pytest.raises(SandboxNotFoundError) as exc_info:
            await client.run_python("sb-999", "print('hi')")
        assert exc_info.value.status_code == 404

    async def test_server_error(self):
        session = MagicMock()
        session.post.return_value = _mock_async_response(
            status=500, ok=False, text="execution failed"
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        with pytest.raises(SandboxHTTPError) as exc_info:
            await client.run_python("sb-123", "raise Exception()")
        assert exc_info.value.status_code == 500


# ---------------------------------------------------------------------------
# get_platform_info
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestGetPlatformInfo:
    async def test_success(self):
        info_dict = {"system": "Linux", "node": "sandbox-1", "release": "5.15.0"}
        info_json = '{"system": "Linux", "node": "sandbox-1", "release": "5.15.0"}'
        session = MagicMock()
        session.get.return_value = _mock_async_response(json_body={"info": info_json})
        client = AsyncHTTPClient(host="localhost", session=session)

        result = await client.get_platform_info("sb-123")

        assert result == info_dict
        assert isinstance(result, dict)
        call_url = session.get.call_args[0][0]
        assert "sandboxes/sb-123/platform_info" in call_url

    async def test_empty_info(self):
        session = MagicMock()
        session.get.return_value = _mock_async_response(json_body={})
        client = AsyncHTTPClient(host="localhost", session=session)

        result = await client.get_platform_info("sb-123")

        assert result == {}
        assert isinstance(result, dict)

    async def test_not_found(self):
        session = MagicMock()
        session.get.return_value = _mock_async_response(
            status=404, ok=False, text="sandbox sb-999 not found"
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        with pytest.raises(SandboxNotFoundError) as exc_info:
            await client.get_platform_info("sb-999")
        assert exc_info.value.status_code == 404

    async def test_server_error(self):
        session = MagicMock()
        session.get.return_value = _mock_async_response(
            status=500, ok=False, text="internal error"
        )
        client = AsyncHTTPClient(host="localhost", session=session)

        with pytest.raises(SandboxHTTPError) as exc_info:
            await client.get_platform_info("sb-123")
        assert exc_info.value.status_code == 500


# ---------------------------------------------------------------------------
# read_file
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestReadFile:
    async def test_success(self):
        session = MagicMock()
        import base64
        encoded = base64.b64encode(b"hello world").decode("ascii")
        session.get.return_value = _mock_async_response(json_body={"content": encoded})
        client = AsyncHTTPClient(host="localhost", session=session)

        result = await client.read_file("sb-123", "/tmp/test.txt")

        assert result == b"hello world"
        call_url = session.get.call_args[0][0]
        assert "sandboxes/sb-123/files" in call_url
        assert session.get.call_args.kwargs["params"]["path"] == "/tmp/test.txt"


# ---------------------------------------------------------------------------
# write_file
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestWriteFile:
    async def test_success(self):
        session = MagicMock()
        session.put.return_value = _mock_async_response(status=204)
        client = AsyncHTTPClient(host="localhost", session=session)

        await client.write_file("sb-123", "/tmp/test.txt", b"hello world")

        call_url = session.put.call_args[0][0]
        assert "sandboxes/sb-123/files" in call_url
        import base64
        payload = session.put.call_args.kwargs["json"]
        assert payload["path"] == "/tmp/test.txt"
        assert payload["content"] == base64.b64encode(b"hello world").decode("ascii")


# ---------------------------------------------------------------------------
# Full lifecycle (integration-style with mocks)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestFullLifecycle:
    """Simulate a complete create → status → exec → snapshot → resume → stop
    flow with mocked HTTP responses."""

    async def test_lifecycle(self):
        session = MagicMock()
        client = AsyncHTTPClient(host="localhost", port=8080, session=session)

        # 1. Start
        session.post.return_value = _mock_async_response(json_body={"id": "sb-lifecycle"})
        sandbox = await client.start_sandbox(
            type="docker",
            image="alpine:latest",
            command=["tail", "-f", "/dev/null"],
            memory_mb=128,
            cpu_cores=0.5,
        )
        assert isinstance(sandbox, AsyncSandbox)
        assert sandbox.id == "sb-lifecycle"
        sid = sandbox.id

        # 2. Status
        session.get.return_value = _mock_async_response(
            json_body={"id": sid, "status": "running"}
        )
        assert await client.get_status(sid) == "running"

        # 3. Exec
        session.post.return_value = _mock_async_response(
            json_body={"stdout": "hello\n", "stderr": "", "exit_code": 0}
        )
        res = await client.exec_command(sid, ["echo", "hello"])
        assert res.stdout == "hello\n"
        assert res.exit_code == 0

        # 4. Metrics
        session.get.return_value = _mock_async_response(
            json_body={
                "memory_usage_bytes": 50000000,
                "memory_limit_bytes": 134217728,
                "memory_percent": 37.25,
                "cpu_percent": 5.3,
                "pids_current": 3,
                "net_rx_bytes": 512,
                "net_tx_bytes": 256,
                "block_read_bytes": 100,
                "block_write_bytes": 200,
            }
        )
        metrics = await client.get_metrics(sid)
        assert metrics.memory_usage_bytes == 50000000
        assert metrics.cpu_percent == pytest.approx(5.3)

        # 5. Snapshot
        session.post.return_value = _mock_async_response(
            json_body={
                "snapshot_path": "/snaps/sb-lifecycle/snap.bin",
                "mem_file_path": "/snaps/sb-lifecycle/mem.bin",
            }
        )
        snap = await client.snapshot_sandbox(sid)
        assert snap["snapshot_path"].endswith("snap.bin")

        # 6. Resume
        session.post.return_value = _mock_async_response(json_body={})
        await client.resume_sandbox(sid)

        # 7. Stop
        session.delete.return_value = _mock_async_response(status=204)
        await client.stop_sandbox(sid)


# ===========================================================================
# Passing AsyncSandbox objects to low-level AsyncHTTPClient methods
# ===========================================================================


@pytest.mark.asyncio
class TestAcceptAsyncSandboxObjects:
    """Verify that every AsyncHTTPClient method accepting a sandbox_id also
    accepts an ``AsyncSandbox`` object directly."""

    def _make_client(self):
        session = MagicMock()
        client = AsyncHTTPClient(host="localhost", port=8080, session=session)
        return client, session

    def _sandbox(self, client):
        return AsyncSandbox("sb-async-obj", client)

    async def test_stop_sandbox_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.delete.return_value = _mock_async_response(status=204)

        await client.stop_sandbox(sb)

        url = session.delete.call_args[0][0]
        assert "sandboxes/sb-async-obj" in url

    async def test_exec_command_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.post.return_value = _mock_async_response(
            json_body={"stdout": "hi\n", "stderr": "", "exit_code": 0}
        )

        result = await client.exec_command(sb, ["echo", "hi"])

        assert result.stdout == "hi\n"
        url = session.post.call_args[0][0]
        assert "sandboxes/sb-async-obj/exec" in url

    async def test_get_status_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.get.return_value = _mock_async_response(
            json_body={"status": "running"}
        )

        status = await client.get_status(sb)

        assert status == "running"
        url = session.get.call_args[0][0]
        assert "sandboxes/sb-async-obj" in url

    async def test_get_metrics_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.get.return_value = _mock_async_response(
            json_body={
                "memory_usage_bytes": 1024,
                "memory_limit_bytes": 4096,
                "memory_percent": 25.0,
                "cpu_percent": 10.0,
                "pids_current": 2,
                "net_rx_bytes": 0,
                "net_tx_bytes": 0,
                "block_read_bytes": 0,
                "block_write_bytes": 0,
            }
        )

        metrics = await client.get_metrics(sb)

        assert metrics.memory_usage_bytes == 1024
        url = session.get.call_args[0][0]
        assert "sandboxes/sb-async-obj/metrics" in url

    async def test_snapshot_sandbox_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.post.return_value = _mock_async_response(
            json_body={"snapshot_path": "/snap.bin", "mem_file_path": "/mem.bin"}
        )

        snap = await client.snapshot_sandbox(sb)

        assert snap["snapshot_path"] == "/snap.bin"
        url = session.post.call_args[0][0]
        assert "sandboxes/sb-async-obj/snapshot" in url

    async def test_resume_sandbox_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.post.return_value = _mock_async_response(json_body={})

        await client.resume_sandbox(sb)

        url = session.post.call_args[0][0]
        assert "sandboxes/sb-async-obj/resume" in url

    async def test_run_python_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.post.return_value = _mock_async_response(
            json_body={"result": "42\n"}
        )

        result = await client.run_python(sb, "print(42)")

        assert result == "42\n"
        url = session.post.call_args[0][0]
        assert "sandboxes/sb-async-obj/run_python" in url

    async def test_get_platform_info_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.get.return_value = _mock_async_response(
            json_body={"info": '{"system": "Linux"}'}
        )

        info = await client.get_platform_info(sb)

        assert info == {"system": "Linux"}
        assert isinstance(info, dict)
        url = session.get.call_args[0][0]
        assert "sandboxes/sb-async-obj/platform_info" in url

    async def test_lifecycle_mixing_sandbox_and_string(self):
        """Verify that both string IDs and AsyncSandbox objects work in the
        same session — callers can freely mix the two forms."""
        client, session = self._make_client()
        sb = self._sandbox(client)

        # Use AsyncSandbox object
        session.get.return_value = _mock_async_response(
            json_body={"status": "running"}
        )
        assert await client.get_status(sb) == "running"

        # Use string ID — same result
        session.get.return_value = _mock_async_response(
            json_body={"status": "running"}
        )
        assert await client.get_status("sb-async-obj") == "running"

        # Use AsyncSandbox object for stop
        session.delete.return_value = _mock_async_response(status=204)
        await client.stop_sandbox(sb)

        url = session.delete.call_args[0][0]
        assert "sandboxes/sb-async-obj" in url

    async def test_accepts_sync_sandbox_object_too(self):
        """AsyncHTTPClient methods should also accept a sync Sandbox object
        (duck-typing via the ``.id`` attribute)."""
        client, session = self._make_client()
        sync_sb = Sandbox("sb-sync-in-async", MagicMock())

        session.get.return_value = _mock_async_response(
            json_body={"status": "running"}
        )

        status = await client.get_status(sync_sb)

        assert status == "running"
        url = session.get.call_args[0][0]
        assert "sandboxes/sb-sync-in-async" in url

    async def test_read_file_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        import base64
        session.get.return_value = _mock_async_response(
            json_body={"content": base64.b64encode(b"hello").decode("ascii")}
        )

        result = await client.read_file(sb, "/tmp/f")

        assert result == b"hello"
        url = session.get.call_args[0][0]
        assert "sandboxes/sb-async-obj/files" in url

    async def test_write_file_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.put.return_value = _mock_async_response(status=204)

        await client.write_file(sb, "/tmp/f", b"hello")

        url = session.put.call_args[0][0]
        assert "sandboxes/sb-async-obj/files" in url
