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

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

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

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 (
    HTTPClient,
    ExecResult,
    MetricsResult,
    SnapshotResult,
    SandboxHTTPError,
    SandboxNotFoundError,
    _resolve_id,
    _prepare_command,
)
from flash_sandbox.sandbox import Sandbox, AsyncSandbox


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


def _mock_response(
    status_code: int = 200,
    json_body: object = None,
    text: str = "",
    ok: bool = True,
) -> MagicMock:
    """Build a fake :class:`requests.Response`."""
    resp = MagicMock()
    resp.status_code = status_code
    resp.ok = ok
    resp.text = text or (json.dumps(json_body) if json_body is not None else "")
    resp.json.return_value = json_body
    return resp


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


class TestClientInit:
    """Tests for HTTPClient.__init__ and address normalisation."""

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

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

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

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

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

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

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

    def test_context_manager(self):
        with HTTPClient(host="localhost") as client:
            assert client._base_url == "http://localhost:8080/"

    def test_external_session_not_closed(self):
        session = MagicMock()
        client = HTTPClient(host="localhost", session=session)
        client.close()
        session.close.assert_not_called()

    def test_internal_session_closed(self):
        client = HTTPClient(host="localhost")
        inner_session = client._session
        with patch.object(inner_session, "close") as mock_close:
            client.close()
            mock_close.assert_called_once()


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


class TestStartSandbox:
    def _make_client(self, session: MagicMock) -> HTTPClient:
        return HTTPClient(host="localhost", port=8080, session=session)

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

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

        assert isinstance(sid, Sandbox)
        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

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

        sid = 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

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

        sid = 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"

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

        sid = 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"

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

        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

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

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


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


class TestStopSandbox:
    def test_success(self):
        session = MagicMock()
        session.delete.return_value = _mock_response(status_code=204, json_body=None)
        client = HTTPClient(host="localhost", session=session)

        client.stop_sandbox("sb-123")

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

    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_response(status_code=204, json_body=None)
        client = HTTPClient(host="localhost", session=session)

        client.stop_sandbox("sb-123")

        call_kwargs = session.delete.call_args
        # No params dict, or params is empty — cleanup=true is the default
        params = call_kwargs.kwargs.get("params", {})
        assert "cleanup" not in params

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

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

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

    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_response(status_code=204, json_body=None)
        client = HTTPClient(host="localhost", session=session)

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

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

    def test_cleanup_false_with_sandbox_object(self):
        """cleanup=False works when passing a Sandbox object too."""
        session = MagicMock()
        session.delete.return_value = _mock_response(status_code=204, json_body=None)
        client = HTTPClient(host="localhost", session=session)
        sb = Sandbox("sb-obj-cleanup", client)

        client.stop_sandbox(sb, cleanup=False)

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

    def test_not_found_raises(self):
        session = MagicMock()
        session.delete.return_value = _mock_response(
            status_code=404,
            ok=False,
            text="sandbox sb-bogus not found",
        )
        client = HTTPClient(host="localhost", session=session)

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


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


class TestExecCommand:
    def test_success(self):
        session = MagicMock()
        session.post.return_value = _mock_response(
            json_body={"stdout": "hello\n", "stderr": "", "exit_code": 0}
        )
        client = HTTPClient(host="localhost", session=session)

        result = 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"]

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

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

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

    def test_exec_result_is_frozen(self):
        result = ExecResult(stdout="hi", stderr="", exit_code=0)
        with pytest.raises(AttributeError):
            result.stdout = "modified"

    def test_elapsed_ms_default(self):
        """ExecResult defaults elapsed_ms to 0.0."""
        result = ExecResult(stdout="hi", stderr="", exit_code=0)
        assert result.elapsed_ms == 0.0

    def test_elapsed_ms_set_explicitly(self):
        """ExecResult accepts an explicit elapsed_ms value."""
        result = ExecResult(stdout="", stderr="", exit_code=0, elapsed_ms=123.45)
        assert result.elapsed_ms == 123.45

    def test_elapsed_ms_populated_by_exec_command(self):
        """exec_command should populate elapsed_ms with a positive value."""
        session = MagicMock()
        session.post.return_value = _mock_response(
            json_body={"stdout": "ok\n", "stderr": "", "exit_code": 0}
        )
        client = HTTPClient(host="localhost", session=session)

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

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

    def test_oci_error_moved_to_stderr(self):
        """OCI error in stdout should be moved to stderr with stdout cleared."""
        oci_stdout = (
            'OCI runtime exec failed: exec failed: unable to start container process: exec: '
            '"find /path/to -type f -name \\"*.py\\" | grep -i separable | head -20": '
            'stat find /path/to -type f -name "*.py" | grep -i separable | head -20: '
            'no such file or directory\r\n'
        )
        session = MagicMock()
        session.post.return_value = _mock_response(
            json_body={"stdout": oci_stdout, "stderr": "", "exit_code": 127}
        )
        client = HTTPClient(host="localhost", session=session)

        result = client.exec_command("sb-123", ["find", "/path/to"])

        # stdout should be empty — no real command output was produced.
        assert result.stdout == ""
        # The error lands in stderr, without OCI boilerplate or command echo.
        assert "OCI runtime exec failed" not in result.stderr
        assert "no such file or directory" in result.stderr
        # The command itself should NOT be echoed in the error.
        assert not result.stderr.startswith("find")
        assert result.exit_code == 127

    def test_oci_error_in_stderr_cleaned(self):
        """OCI runtime prefix in stderr should also be cleaned."""
        oci_stderr = (
            'OCI runtime exec failed: exec failed: unable to start container process: exec: '
            '"bad_cmd": stat bad_cmd: no such file or directory\r\n'
        )
        result = ExecResult(stdout="", stderr=oci_stderr, exit_code=126)

        assert result.stdout == ""
        assert "OCI runtime exec failed" not in result.stderr
        assert "no such file or directory" in result.stderr
        # The command should not be echoed back.
        assert "bad_cmd" not in result.stderr

    def test_oci_error_executable_not_found(self):
        """'executable file not found in $PATH' errors are handled."""
        oci_stdout = (
            'OCI runtime exec failed: exec failed: unable to start container process: exec: '
            '"find . -type f -name \\"*.py\\"": '
            'executable file not found in $PATH\r\n'
        )
        result = ExecResult(stdout=oci_stdout, stderr="", exit_code=127)

        assert result.stdout == ""
        assert result.stderr == "executable file not found in $PATH\n"

    def test_normal_stdout_not_modified(self):
        """Normal (non-OCI) output must pass through unchanged."""
        result = ExecResult(stdout="hello world\n", stderr="", exit_code=0)
        assert result.stdout == "hello world\n"
        assert result.stderr == ""

    def test_oci_error_unknown_suffix_stripped(self):
        """Trailing ': unknown' in OCI errors should be stripped."""
        oci_stdout = (
            'OCI runtime exec failed: exec failed: unable to start container process: exec: '
            '"my_binary": stat my_binary: no such file or directory: unknown\r\n'
        )
        result = ExecResult(stdout=oci_stdout, stderr="", exit_code=127)

        # Error goes to stderr, stdout is cleared.
        assert result.stdout == ""
        assert "OCI runtime exec failed" not in result.stderr
        assert ": unknown" not in result.stderr
        assert "no such file or directory" in result.stderr

    def test_exec_command_with_string(self):
        """exec_command accepts a plain string and wraps it in sh -c."""
        session = MagicMock()
        session.post.return_value = _mock_response(
            json_body={"stdout": "file.py\n", "stderr": "", "exit_code": 0}
        )
        client = HTTPClient(host="localhost", session=session)

        result = client.exec_command("sb-123", "find . -name '*.py' | head -5")

        payload = session.post.call_args[1]["json"]
        assert payload["command"] == ["sh", "-c", "find . -name '*.py' | head -5"]
        assert result.stdout == "file.py\n"

    def test_exec_command_with_single_element_shell_list(self):
        """exec_command wraps single-element lists containing shell metacharacters."""
        session = MagicMock()
        session.post.return_value = _mock_response(
            json_body={"stdout": "ok\n", "stderr": "", "exit_code": 0}
        )
        client = HTTPClient(host="localhost", session=session)

        result = client.exec_command("sb-123", ["ls -al"])

        payload = session.post.call_args[1]["json"]
        assert payload["command"] == ["sh", "-c", "ls -al"]

    def test_exec_command_with_multi_element_list(self):
        """exec_command passes multi-element lists through unchanged."""
        session = MagicMock()
        session.post.return_value = _mock_response(
            json_body={"stdout": "ok\n", "stderr": "", "exit_code": 0}
        )
        client = HTTPClient(host="localhost", session=session)

        result = client.exec_command("sb-123", ["ls", "-al"])

        payload = session.post.call_args[1]["json"]
        assert payload["command"] == ["ls", "-al"]

    def test_sandbox_not_found(self):
        session = MagicMock()
        session.post.return_value = _mock_response(
            status_code=404,
            ok=False,
            text="sandbox sb-gone not found",
        )
        client = HTTPClient(host="localhost", session=session)

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


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


# ---------------------------------------------------------------------------
# _prepare_command
# ---------------------------------------------------------------------------


class TestPrepareCommand:
    """Unit tests for the _prepare_command helper."""

    # -- plain strings are always wrapped in sh -c --------------------------

    def test_simple_string(self):
        assert _prepare_command("ls -al") == ["sh", "-c", "ls -al"]

    def test_string_with_pipes(self):
        assert _prepare_command("cat foo | grep bar") == [
            "sh", "-c", "cat foo | grep bar",
        ]

    def test_string_with_redirects(self):
        assert _prepare_command("echo hi > out.txt") == [
            "sh", "-c", "echo hi > out.txt",
        ]

    def test_string_single_word(self):
        """Even a single-word string goes through the shell."""
        assert _prepare_command("ls") == ["sh", "-c", "ls"]

    def test_string_with_glob(self):
        assert _prepare_command("ls *.py") == ["sh", "-c", "ls *.py"]

    def test_string_with_subshell(self):
        assert _prepare_command("echo $(whoami)") == [
            "sh", "-c", "echo $(whoami)",
        ]

    # -- single-element lists with shell features are wrapped ---------------

    def test_single_element_with_space(self):
        assert _prepare_command(["ls -al"]) == ["sh", "-c", "ls -al"]

    def test_single_element_with_pipe(self):
        assert _prepare_command(["cat foo | head"]) == [
            "sh", "-c", "cat foo | head",
        ]

    def test_single_element_with_semicolon(self):
        assert _prepare_command(["cd /tmp; ls"]) == [
            "sh", "-c", "cd /tmp; ls",
        ]

    def test_single_element_with_glob(self):
        assert _prepare_command(["ls *.py"]) == ["sh", "-c", "ls *.py"]

    def test_single_element_with_ampersand(self):
        assert _prepare_command(["sleep 1 &"]) == ["sh", "-c", "sleep 1 &"]

    def test_single_element_with_dollar(self):
        assert _prepare_command(["echo $HOME"]) == ["sh", "-c", "echo $HOME"]

    def test_single_element_with_backtick(self):
        assert _prepare_command(["echo `date`"]) == ["sh", "-c", "echo `date`"]

    def test_single_element_with_tab(self):
        assert _prepare_command(["echo\tfoo"]) == ["sh", "-c", "echo\tfoo"]

    # -- single-element lists without shell features stay as-is -------------

    def test_single_element_plain_binary(self):
        assert _prepare_command(["ls"]) == ["ls"]

    def test_single_element_path(self):
        assert _prepare_command(["/usr/bin/python3"]) == ["/usr/bin/python3"]

    # -- multi-element lists are never wrapped ------------------------------

    def test_multi_element_list(self):
        assert _prepare_command(["ls", "-al"]) == ["ls", "-al"]

    def test_multi_element_with_args(self):
        assert _prepare_command(["grep", "-r", "foo", "."]) == [
            "grep", "-r", "foo", ".",
        ]

    def test_multi_element_not_wrapped_even_with_meta(self):
        """Multi-element lists are passed through even if args contain metacharacters."""
        assert _prepare_command(["echo", "hello | world"]) == [
            "echo", "hello | world",
        ]

    # -- tuple / other sequence types work ----------------------------------

    def test_tuple_input(self):
        assert _prepare_command(("ls", "-al")) == ["ls", "-al"]

    def test_single_element_tuple_with_space(self):
        assert _prepare_command(("ls -al",)) == ["sh", "-c", "ls -al"]


class TestGetStatus:
    def test_running(self):
        session = MagicMock()
        session.get.return_value = _mock_response(
            json_body={"id": "sb-123", "status": "running"}
        )
        client = HTTPClient(host="localhost", session=session)

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

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

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

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

    def test_not_found(self):
        session = MagicMock()
        session.get.return_value = _mock_response(
            status_code=404,
            ok=False,
            text="sandbox sb-nope not found",
        )
        client = HTTPClient(host="localhost", session=session)

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


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


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,
    }

    def test_success(self):
        session = MagicMock()
        session.get.return_value = _mock_response(json_body=self.SAMPLE_METRICS)
        client = HTTPClient(host="localhost", session=session)

        m = 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

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

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

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

    def test_metrics_result_is_frozen(self):
        m = MetricsResult(memory_usage_bytes=1)
        with pytest.raises(AttributeError):
            m.memory_usage_bytes = 999

    def test_not_found(self):
        session = MagicMock()
        session.get.return_value = _mock_response(
            status_code=404, ok=False, text="not found"
        )
        client = HTTPClient(host="localhost", session=session)

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


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


class TestSnapshotSandbox:
    def test_success(self):
        session = MagicMock()
        session.post.return_value = _mock_response(
            json_body={
                "snapshot_path": "/snaps/sb-123/snap.bin",
                "mem_file_path": "/snaps/sb-123/mem.bin",
            }
        )
        client = HTTPClient(host="localhost", session=session)

        result = 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

    def test_server_error(self):
        session = MagicMock()
        session.post.return_value = _mock_response(
            status_code=500, ok=False, text="snapshot not supported"
        )
        client = HTTPClient(host="localhost", session=session)

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


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


class TestResumeSandbox:
    def test_success(self):
        session = MagicMock()
        session.post.return_value = _mock_response(status_code=200, json_body={})
        client = HTTPClient(host="localhost", session=session)

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

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

    def test_not_found(self):
        session = MagicMock()
        session.post.return_value = _mock_response(
            status_code=404, ok=False, text="sandbox not found"
        )
        client = HTTPClient(host="localhost", session=session)

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


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


class TestRunPython:
    def _make_client(self, session: MagicMock) -> HTTPClient:
        return HTTPClient(host="localhost", port=8080, session=session)

    def test_success(self):
        session = MagicMock()
        session.post.return_value = _mock_response(json_body={"result": "42\n"})
        client = self._make_client(session)

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

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

    def test_empty_result(self):
        session = MagicMock()
        session.post.return_value = _mock_response(json_body={})
        client = self._make_client(session)

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

        assert result == ""

    def test_not_found(self):
        session = MagicMock()
        session.post.return_value = _mock_response(
            status_code=404,
            ok=False,
            text="sandbox sb-999 not found",
        )
        client = self._make_client(session)

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

    def test_server_error(self):
        session = MagicMock()
        session.post.return_value = _mock_response(
            status_code=500,
            ok=False,
            text="execution failed",
        )
        client = self._make_client(session)

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


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


class TestGetPlatformInfo:
    def _make_client(self, session: MagicMock) -> HTTPClient:
        return HTTPClient(host="localhost", port=8080, session=session)

    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_response(json_body={"info": info_json})
        client = self._make_client(session)

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

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

    def test_empty_info(self):
        session = MagicMock()
        session.get.return_value = _mock_response(json_body={})
        client = self._make_client(session)

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

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

    def test_not_found(self):
        session = MagicMock()
        session.get.return_value = _mock_response(
            status_code=404,
            ok=False,
            text="sandbox sb-999 not found",
        )
        client = self._make_client(session)

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

    def test_server_error(self):
        session = MagicMock()
        session.get.return_value = _mock_response(
            status_code=500,
            ok=False,
            text="internal error",
        )
        client = self._make_client(session)

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


# ---------------------------------------------------------------------------
# SnapshotResult dataclass
# ---------------------------------------------------------------------------


class TestSnapshotResult:
    def test_defaults(self):
        sr = SnapshotResult()
        assert sr.snapshot_path == ""
        assert sr.mem_file_path == ""

    def test_frozen(self):
        sr = SnapshotResult(snapshot_path="/a", mem_file_path="/b")
        with pytest.raises(AttributeError):
            sr.snapshot_path = "/c"


# ---------------------------------------------------------------------------
# URL construction
# ---------------------------------------------------------------------------


class TestURLConstruction:
    """Verify that requests are sent to the correct URLs."""

    def test_simple_base(self):
        session = MagicMock()
        session.get.return_value = _mock_response(
            json_body={"id": "x", "status": "running"}
        )
        client = HTTPClient(host="10.0.0.1", port=8080, session=session)

        client.get_status("abc")

        url = session.get.call_args[0][0]
        assert url == "http://10.0.0.1:8080/sandboxes/abc"

    def test_proxy_prefix(self):
        session = MagicMock()
        session.get.return_value = _mock_response(
            json_body={"id": "x", "status": "running"}
        )
        client = HTTPClient(
            address="localhost:8092/v1/service/sandbox", session=session
        )

        client.get_status("abc")

        url = session.get.call_args[0][0]
        assert "/v1/service/sandbox/" in url
        assert url.endswith("sandboxes/abc")


# ---------------------------------------------------------------------------
# Timeout propagation
# ---------------------------------------------------------------------------


class TestTimeout:
    def test_custom_timeout(self):
        session = MagicMock()
        session.get.return_value = _mock_response(
            json_body={"id": "x", "status": "ok"}
        )
        client = HTTPClient(host="localhost", session=session, timeout=5.0)

        client.get_status("sb-1")

        assert session.get.call_args[1]["timeout"] == 5.0

    def test_no_timeout(self):
        session = MagicMock()
        session.get.return_value = _mock_response(
            json_body={"id": "x", "status": "ok"}
        )
        client = HTTPClient(host="localhost", session=session, timeout=None)

        client.get_status("sb-1")

        assert session.get.call_args[1]["timeout"] is None


# ---------------------------------------------------------------------------
# Error hierarchy
# ---------------------------------------------------------------------------


class TestExceptions:
    def test_not_found_is_http_error(self):
        assert issubclass(SandboxNotFoundError, SandboxHTTPError)

    def test_http_error_attributes(self):
        err = SandboxHTTPError(502, "bad gateway")
        assert err.status_code == 502
        assert err.detail == "bad gateway"
        assert "502" in str(err)
        assert "bad gateway" in str(err)

    def test_not_found_error_attributes(self):
        err = SandboxNotFoundError(404, "sandbox sb-x not found")
        assert err.status_code == 404
        assert "sb-x" in err.detail


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


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

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

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

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

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

        # 4. Metrics
        session.get.return_value = _mock_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 = 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_response(
            json_body={
                "snapshot_path": "/snaps/sb-lifecycle/snap.bin",
                "mem_file_path": "/snaps/sb-lifecycle/mem.bin",
            }
        )
        snap = client.snapshot_sandbox(sid)
        assert snap["snapshot_path"].endswith("snap.bin")

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

        # 7. Stop
        session.delete.return_value = _mock_response(status_code=204)
        client.stop_sandbox(sid)


# ===========================================================================
# _resolve_id
# ===========================================================================


class TestResolveId:
    """Tests for the ``_resolve_id`` helper."""

    def test_string_passthrough(self):
        assert _resolve_id("sb-123") == "sb-123"

    def test_sandbox_object(self):
        sb = Sandbox("sb-obj", MagicMock())
        assert _resolve_id(sb) == "sb-obj"

    def test_async_sandbox_object(self):
        sb = AsyncSandbox("sb-async-obj", MagicMock())
        assert _resolve_id(sb) == "sb-async-obj"

    def test_duck_typed_object_with_id(self):
        """Any object with a string ``.id`` attribute should work."""
        obj = MagicMock()
        obj.id = "sb-duck"
        assert _resolve_id(obj) == "sb-duck"

    def test_invalid_type_raises(self):
        with pytest.raises(TypeError, match="Expected a sandbox ID string"):
            _resolve_id(42)

    def test_none_raises(self):
        with pytest.raises(TypeError, match="Expected a sandbox ID string"):
            _resolve_id(None)


# ===========================================================================
# Passing Sandbox objects to low-level HTTPClient methods
# ===========================================================================


class TestAcceptSandboxObjects:
    """Verify that every HTTPClient method accepting a sandbox_id also
    accepts a ``Sandbox`` object directly."""

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

    def _sandbox(self, client):
        return Sandbox("sb-obj-test", client)

    def test_stop_sandbox_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.delete.return_value = _mock_response(status_code=204)

        client.stop_sandbox(sb)

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

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

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

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

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

        status = client.get_status(sb)

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

    def test_get_metrics_with_object(self):
        client, session = self._make_client()
        sb = self._sandbox(client)
        session.get.return_value = _mock_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 = client.get_metrics(sb)

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

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

        snap = client.snapshot_sandbox(sb)

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

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

        client.resume_sandbox(sb)

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

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

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

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

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

        info = client.get_platform_info(sb)

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

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

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

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

        # Use Sandbox object for stop
        session.delete.return_value = _mock_response(status_code=204)
        client.stop_sandbox(sb)

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