from __future__ import annotations

import asyncio
import builtins
import importlib
import io
import shlex
import sys
import types
import uuid
from collections import deque
from pathlib import Path
from typing import Any, Literal, cast
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from pydantic import Field, PrivateAttr

import agents.extensions.sandbox.daytona.mounts as _daytona_mounts
from agents.extensions.sandbox.daytona.mounts import (
    DaytonaCloudBucketMountStrategy,
    _assert_daytona_session,
    _ensure_fuse_support,
    _ensure_rclone,
    _has_command,
    _pkg_install,
)
from agents.sandbox import Manifest, SandboxPathGrant
from agents.sandbox.entries import (
    Dir,
    InContainerMountStrategy,
    Mount,
    MountpointMountPattern,
    RcloneMountPattern,
    S3Mount,
)
from agents.sandbox.entries.mounts.base import InContainerMountAdapter
from agents.sandbox.errors import ExecTimeoutError, ExecTransportError, MountConfigError
from agents.sandbox.files import EntryKind
from agents.sandbox.manifest import Environment
from agents.sandbox.materialization import MaterializedFile
from agents.sandbox.session.base_sandbox_session import (
    _MKDIR_ACCESS_CHECK_SCRIPT,
    BaseSandboxSession,
)
from agents.sandbox.session.dependencies import Dependencies
from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase
from agents.sandbox.types import ExecResult, ExposedPortEndpoint, User
from tests._fake_workspace_paths import resolve_fake_workspace_path
from tests.utils.factories import TestSessionState


class _RestorableSnapshot(SnapshotBase):
    type: Literal["test-restorable-daytona"] = "test-restorable-daytona"
    payload: bytes = b"restored"

    async def persist(self, data: io.IOBase, *, dependencies: Dependencies | None = None) -> None:
        _ = (data, dependencies)

    async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
        _ = dependencies
        return io.BytesIO(self.payload)

    async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
        _ = dependencies
        return True


class _FakeExecResult:
    def __init__(self, *, exit_code: int = 0, result: str = "") -> None:
        self.exit_code = exit_code
        self.result = result


class _FakePtyHandle:
    def __init__(self, on_data: object) -> None:
        self._on_data = on_data
        self.exit_code: int | None = None
        self._done = asyncio.Event()

    async def wait_for_connection(self) -> None:
        return None

    async def send_input(self, chars: str) -> None:
        if chars.endswith("\n") and "python3" in chars:
            await cast(Any, self._on_data)(b">>> ")
        elif chars == "5 + 5\n":
            await cast(Any, self._on_data)(b"10\n")
        elif chars == "exit\n":
            self.exit_code = 0
            self._done.set()

    async def wait(self) -> None:
        await self._done.wait()


class _FakeProcess:
    def __init__(self) -> None:
        self.exec_calls: list[tuple[str, dict[str, object]]] = []
        self.next_result = _FakeExecResult()
        self.next_session_command_result = types.SimpleNamespace(
            cmd_id="cmd-123",
            exit_code=0,
            stdout="",
            stderr="",
            output="",
        )
        self.create_pty_session_calls: list[dict[str, object]] = []
        self.create_session_calls: list[str] = []
        self.create_session_error: BaseException | None = None
        self.create_session_delay_s: float = 0.0
        self.kill_pty_session_calls: list[str] = []
        self.delete_session_calls: list[str] = []
        self.execute_session_command_calls: list[tuple[str, object, dict[str, object]]] = []
        self.get_session_command_logs_error: BaseException | None = None
        self.session_command_exit_code: int | None = 0
        self._pty_handles: dict[str, _FakePtyHandle] = {}
        self.create_pty_session_error: BaseException | None = None
        self.symlinks: dict[str, str] = {}
        self.workspace_roots: set[str] = set()
        self.require_workspace_root_for_cd = False

    async def exec(self, cmd: str, **kwargs: object) -> _FakeExecResult:
        self.exec_calls.append((cmd, dict(kwargs)))
        parts = shlex.split(cmd)
        if len(parts) >= 4 and parts[:3] == ["mkdir", "-p", "--"]:
            self.workspace_roots.add(parts[3])
        if "sleep 0.5" in cmd:
            await asyncio.sleep(0.5)
        result = self.next_result
        self.next_result = _FakeExecResult()
        return result

    async def create_pty_session(self, **kwargs: object) -> _FakePtyHandle:
        if self.create_pty_session_error is not None:
            raise self.create_pty_session_error
        self.create_pty_session_calls.append(dict(kwargs))
        session_id = cast(str, kwargs["id"])
        handle = _FakePtyHandle(kwargs["on_data"])
        self._pty_handles[session_id] = handle
        return handle

    async def kill_pty_session(self, session_id: str) -> None:
        self.kill_pty_session_calls.append(session_id)

    async def create_session(self, session_id: str) -> None:
        self.create_session_calls.append(session_id)
        if self.create_session_delay_s:
            await asyncio.sleep(self.create_session_delay_s)
        if self.create_session_error is not None:
            raise self.create_session_error

    async def execute_session_command(
        self, session_id: str, request: object, **kwargs: object
    ) -> object:
        self.execute_session_command_calls.append((session_id, request, dict(kwargs)))
        command = cast(str, getattr(request, "command", ""))
        parts = shlex.split(command)
        if (
            self.require_workspace_root_for_cd
            and len(parts) >= 3
            and parts[0] == "cd"
            and parts[2] == "&&"
            and parts[1] not in self.workspace_roots
        ):
            return types.SimpleNamespace(
                cmd_id="cmd-123",
                exit_code=1,
                stdout="",
                stderr=f"cd: no such file or directory: {parts[1]}",
                output=f"cd: no such file or directory: {parts[1]}",
            )
        resolved = resolve_fake_workspace_path(
            command,
            symlinks=self.symlinks,
            home_dir="/home/daytona/workspace",
        )
        if resolved is not None:
            return types.SimpleNamespace(
                exit_code=resolved.exit_code,
                stdout=resolved.stdout,
                stderr=resolved.stderr,
                output=resolved.stdout,
            )
        if "sleep 0.5" in command:
            await asyncio.sleep(0.5)
        if getattr(request, "run_async", None):
            return types.SimpleNamespace(cmd_id="cmd-123")
        result = self.next_session_command_result
        self.next_session_command_result = types.SimpleNamespace(
            cmd_id="cmd-123",
            exit_code=0,
            stdout="",
            stderr="",
            output="",
        )
        return result

    async def get_session_command_logs_async(
        self,
        session_id: str,
        cmd_id: str,
        on_stdout: object,
        on_stderr: object,
    ) -> None:
        _ = (session_id, cmd_id, on_stderr)
        if self.get_session_command_logs_error is not None:
            raise self.get_session_command_logs_error
        await cast(Any, on_stdout)("started\n")

    async def get_session_command(self, session_id: str, cmd_id: str) -> object:
        _ = (session_id, cmd_id)
        return types.SimpleNamespace(exit_code=self.session_command_exit_code)

    async def delete_session(self, session_id: str) -> None:
        self.delete_session_calls.append(session_id)


class _FakeFs:
    def __init__(self) -> None:
        self.create_folder_calls: list[tuple[str, str]] = []
        self.download_file_calls: list[tuple[str, float | None]] = []
        self.upload_file_calls: list[tuple[bytes, str, float | None]] = []
        self.download_value: bytes = b""

    async def create_folder(self, path: str, mode: str) -> None:
        self.create_folder_calls.append((path, mode))

    async def download_file(self, path: str, timeout: float | None = None) -> bytes:
        self.download_file_calls.append((path, timeout))
        return self.download_value

    async def upload_file(self, data: bytes, path: str, *, timeout: float | None = None) -> None:
        self.upload_file_calls.append((data, path, timeout))


class _FakeDaytonaSandbox:
    def __init__(self, *, sandbox_id: str = "sandbox-123") -> None:
        self.id = sandbox_id
        self.state = "started"
        self.process = _FakeProcess()
        self.fs = _FakeFs()
        self.start_calls: list[int | None] = []
        self.stop_calls = 0
        self.delete_calls = 0
        self.signed_preview_url_calls: list[tuple[int, int | None]] = []

    async def refresh_data(self) -> None:
        return None

    async def start(self, *, timeout: int | None = None) -> None:
        self.start_calls.append(timeout)
        self.state = "started"

    async def stop(self) -> None:
        self.stop_calls += 1

    async def delete(self) -> None:
        self.delete_calls += 1

    async def create_signed_preview_url(
        self,
        port: int,
        expires_in_seconds: int | None = None,
    ) -> object:
        self.signed_preview_url_calls.append((port, expires_in_seconds))
        return types.SimpleNamespace(
            url=f"https://{port}-signed-token.daytonaproxy01.net",
            token="signed-token",
        )


class _FakeAsyncDaytona:
    create_calls: list[tuple[object, int | None]] = []
    get_calls: list[str] = []
    current_sandbox: _FakeDaytonaSandbox | None = None
    get_error: BaseException | None = None

    def __init__(self, config: object | None = None) -> None:
        _ = config

    @classmethod
    def reset(cls) -> None:
        cls.create_calls = []
        cls.get_calls = []
        cls.current_sandbox = None
        cls.get_error = None

    async def create(self, params: object, timeout: int | None = None) -> _FakeDaytonaSandbox:
        type(self).create_calls.append((params, timeout))
        sandbox = _FakeDaytonaSandbox()
        type(self).current_sandbox = sandbox
        return sandbox

    async def get(self, sandbox_id: str) -> _FakeDaytonaSandbox:
        type(self).get_calls.append(sandbox_id)
        get_error = type(self).get_error
        if get_error is not None:
            raise get_error
        if type(self).current_sandbox is None:
            type(self).current_sandbox = _FakeDaytonaSandbox(sandbox_id=sandbox_id)
        sandbox = type(self).current_sandbox
        assert sandbox is not None
        return sandbox

    async def close(self) -> None:
        return None


def _load_daytona_module(monkeypatch: pytest.MonkeyPatch) -> Any:
    _FakeAsyncDaytona.reset()

    class _FakeParams:
        def __init__(self, **kwargs: object) -> None:
            for key, value in kwargs.items():
                setattr(self, key, value)

    class _FakeDaytonaConfig:
        def __init__(self, api_key: str | None = None, api_url: str | None = None) -> None:
            self.api_key = api_key
            self.api_url = api_url

    class _FakePtySize:
        def __init__(self, *, cols: int, rows: int) -> None:
            self.cols = cols
            self.rows = rows

    class _FakeResources:
        def __init__(
            self,
            *,
            cpu: int | None = None,
            memory: int | None = None,
            disk: int | None = None,
        ) -> None:
            self.cpu = cpu
            self.memory = memory
            self.disk = disk

    fake_daytona: Any = types.ModuleType("daytona")
    fake_daytona.AsyncDaytona = _FakeAsyncDaytona
    fake_daytona.DaytonaConfig = _FakeDaytonaConfig
    fake_daytona.CreateSandboxFromSnapshotParams = _FakeParams
    fake_daytona.CreateSandboxFromImageParams = _FakeParams
    fake_daytona.SessionExecuteRequest = _FakeParams
    fake_daytona.Resources = _FakeResources
    fake_daytona.SandboxState = types.SimpleNamespace(STARTED="started")

    fake_daytona_common: Any = types.ModuleType("daytona.common")
    fake_daytona_common_pty: Any = types.ModuleType("daytona.common.pty")
    fake_daytona_common_pty.PtySize = _FakePtySize

    monkeypatch.setitem(sys.modules, "daytona", fake_daytona)
    monkeypatch.setitem(sys.modules, "daytona.common", fake_daytona_common)
    monkeypatch.setitem(sys.modules, "daytona.common.pty", fake_daytona_common_pty)
    sys.modules.pop("agents.extensions.sandbox.daytona.sandbox", None)
    sys.modules.pop("agents.extensions.sandbox.daytona", None)
    return importlib.import_module("agents.extensions.sandbox.daytona.sandbox")


def test_daytona_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPatch) -> None:
    daytona_module = _load_daytona_module(monkeypatch)
    package_module = importlib.import_module("agents.extensions.sandbox.daytona")

    assert package_module.DaytonaSandboxClient is daytona_module.DaytonaSandboxClient


class _RecordingMount(Mount):
    type: str = "daytona_recording_mount"
    mount_strategy: InContainerMountStrategy = Field(
        default_factory=lambda: InContainerMountStrategy(pattern=MountpointMountPattern())
    )
    _mounted_paths: list[Path] = PrivateAttr(default_factory=list)
    _unmounted_paths: list[Path] = PrivateAttr(default_factory=list)
    _events: list[tuple[str, str]] = PrivateAttr(default_factory=list)

    def bind_events(self, events: list[tuple[str, str]]) -> _RecordingMount:
        self._events = events
        return self

    def supported_in_container_patterns(
        self,
    ) -> tuple[builtins.type[MountpointMountPattern], ...]:
        return (MountpointMountPattern,)

    def build_docker_volume_driver_config(
        self,
        strategy: object,
    ) -> tuple[str, dict[str, str], bool]:
        _ = strategy
        raise MountConfigError(
            message="docker-volume mounts are not supported for this mount type",
            context={"mount_type": self.type},
        )

    def in_container_adapter(self) -> InContainerMountAdapter:
        mount = self

        class _Adapter(InContainerMountAdapter):
            def validate(self, strategy: InContainerMountStrategy) -> None:
                _ = strategy

            async def activate(
                self,
                strategy: InContainerMountStrategy,
                session: BaseSandboxSession,
                dest: Path,
                base_dir: Path,
            ) -> list[MaterializedFile]:
                _ = (strategy, session, base_dir)
                path = mount._resolve_mount_path(session, dest)
                mount._events.append(("mount", path.as_posix()))
                mount._mounted_paths.append(path)
                return []

            async def deactivate(
                self,
                strategy: InContainerMountStrategy,
                session: BaseSandboxSession,
                dest: Path,
                base_dir: Path,
            ) -> None:
                _ = (strategy, session, base_dir)
                path = mount._resolve_mount_path(session, dest)
                mount._events.append(("unmount", path.as_posix()))
                mount._unmounted_paths.append(path)

            async def teardown_for_snapshot(
                self,
                strategy: InContainerMountStrategy,
                session: BaseSandboxSession,
                path: Path,
            ) -> None:
                _ = (strategy, session)
                mount._events.append(("unmount", path.as_posix()))
                mount._unmounted_paths.append(path)

            async def restore_after_snapshot(
                self,
                strategy: InContainerMountStrategy,
                session: BaseSandboxSession,
                path: Path,
            ) -> None:
                _ = (strategy, session)
                mount._events.append(("mount", path.as_posix()))
                mount._mounted_paths.append(path)

        return _Adapter(self)

    async def mount(self, session: object, path: Path) -> None:
        _ = session
        self._events.append(("mount", path.as_posix()))
        self._mounted_paths.append(path)

    async def unmount_path(self, session: object, path: Path) -> None:
        _ = session
        self._events.append(("unmount", path.as_posix()))
        self._unmounted_paths.append(path)


class _FailingUnmountMount(_RecordingMount):
    type: str = "daytona_failing_unmount_mount"

    def in_container_adapter(self) -> InContainerMountAdapter:
        mount = self
        base_adapter = super().in_container_adapter()

        class _Adapter(InContainerMountAdapter):
            def validate(self, strategy: InContainerMountStrategy) -> None:
                base_adapter.validate(strategy)

            async def activate(
                self,
                strategy: InContainerMountStrategy,
                session: BaseSandboxSession,
                dest: Path,
                base_dir: Path,
            ) -> list[MaterializedFile]:
                return await base_adapter.activate(strategy, session, dest, base_dir)

            async def deactivate(
                self,
                strategy: InContainerMountStrategy,
                session: BaseSandboxSession,
                dest: Path,
                base_dir: Path,
            ) -> None:
                _ = (strategy, session, base_dir)
                path = mount._resolve_mount_path(session, dest)
                mount._events.append(("unmount_fail", path.as_posix()))
                raise RuntimeError("boom while unmounting second mount")

            async def teardown_for_snapshot(
                self,
                strategy: InContainerMountStrategy,
                session: BaseSandboxSession,
                path: Path,
            ) -> None:
                _ = (strategy, session)
                mount._events.append(("unmount_fail", path.as_posix()))
                raise RuntimeError("boom while unmounting second mount")

            async def restore_after_snapshot(
                self,
                strategy: InContainerMountStrategy,
                session: BaseSandboxSession,
                path: Path,
            ) -> None:
                await base_adapter.restore_after_snapshot(strategy, session, path)

        return _Adapter(self)

    async def unmount_path(self, session: object, path: Path) -> None:
        _ = session
        self._events.append(("unmount_fail", path.as_posix()))
        raise RuntimeError("boom while unmounting second mount")


class TestDaytonaSandbox:
    @pytest.mark.asyncio
    async def test_create_uses_daytona_safe_default_workspace_root(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify omitted manifests default to a writable Daytona workspace root."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())

        assert session.state.manifest.root == daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT

    @pytest.mark.asyncio
    async def test_start_prepares_workspace_root_before_runtime_helpers(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify Daytona creates the root before exec uses it as cwd."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
            sandbox = _FakeAsyncDaytona.current_sandbox
            assert sandbox is not None
            sandbox.process.require_workspace_root_for_cd = True

            await session.start()

        root = daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT
        assert root in sandbox.process.workspace_roots
        assert sandbox.process.exec_calls[0][0] == f"mkdir -p -- {root}"
        assert sandbox.process.execute_session_command_calls
        _session_id, request, _kwargs = sandbox.process.execute_session_command_calls[0]
        assert cast(str, cast(Any, request).command).startswith(f"cd {root} && ")
        assert session.state.workspace_root_ready is True

    @pytest.mark.asyncio
    async def test_start_wraps_workspace_root_prepare_failure(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify Daytona surfaces root preparation failures as start errors."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
            sandbox = _FakeAsyncDaytona.current_sandbox
            assert sandbox is not None
            sandbox.process.next_result = _FakeExecResult(exit_code=2, result="mkdir failed")

            with pytest.raises(daytona_module.WorkspaceStartError) as exc_info:
                await session.start()

        assert exc_info.value.context == {
            "path": daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
            "backend": "daytona",
            "reason": "workspace_root_nonzero_exit",
            "exit_code": 2,
            "output": "mkdir failed",
        }
        assert str(exc_info.value) == (
            "failed to start session: Daytona workspace root setup exited with 2: mkdir failed"
        )
        assert sandbox.process.execute_session_command_calls == []
        assert session.state.workspace_root_ready is False

    @pytest.mark.asyncio
    async def test_create_passes_only_option_env_vars_to_daytona(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify manifest env vars are not passed into Daytona's create-time env shell."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            await client.create(
                manifest=Manifest(
                    root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
                    environment=Environment(value={"SHARED": "manifest", "ONLY_MANIFEST": "1"}),
                ),
                options=daytona_module.DaytonaSandboxClientOptions(
                    env_vars={"SHARED": "option", "ONLY_OPTION": "1"},
                ),
            )

        assert _FakeAsyncDaytona.create_calls
        params, _timeout = _FakeAsyncDaytona.create_calls[0]
        assert cast(Any, params).env_vars == {
            "SHARED": "option",
            "ONLY_OPTION": "1",
        }

    @pytest.mark.asyncio
    async def test_exec_enforces_subsecond_caller_timeout(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify a sub-second user timeout fails even though the SDK timeout is ceiled."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())

            with pytest.raises(ExecTimeoutError):
                await session.exec("sleep 0.5", shell=False, timeout=0.1)

        sandbox = _FakeAsyncDaytona.current_sandbox
        assert sandbox is not None
        _session_id, _request, kwargs = sandbox.process.execute_session_command_calls[0]
        assert kwargs["timeout"] == 2

    @pytest.mark.asyncio
    async def test_exec_timeout_budget_includes_session_create(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
            sandbox = _FakeAsyncDaytona.current_sandbox
            assert sandbox is not None
            sandbox.process.create_session_delay_s = 0.2

            await session.exec("echo", "done", shell=False, timeout=1.1)

        assert sandbox.process.create_session_calls
        _session_id, _request, kwargs = sandbox.process.execute_session_command_calls[0]
        assert kwargs["timeout"] == 2

    @pytest.mark.asyncio
    async def test_exec_delete_session_cleanup_is_bounded(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)
        real_wait_for = asyncio.wait_for
        cleanup_timeouts: list[float | None] = []

        async def _record_cleanup_wait_for(awaitable: Any, timeout: float | None = None) -> Any:
            code = getattr(awaitable, "cr_code", None)
            if getattr(code, "co_name", None) == "delete_session":
                awaitable.close()
                cleanup_timeouts.append(timeout)
                return None
            return await real_wait_for(awaitable, timeout=timeout)

        monkeypatch.setattr(daytona_module.asyncio, "wait_for", _record_cleanup_wait_for)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(
                options=daytona_module.DaytonaSandboxClientOptions(
                    timeouts=daytona_module.DaytonaSandboxTimeouts(cleanup_s=7)
                )
            )
            await session.exec("echo", "done", shell=False, timeout=5.0)

        assert cleanup_timeouts == [7]

    @pytest.mark.asyncio
    async def test_exec_merges_manifest_env_with_option_precedence(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify manifest env vars are applied through the adapter-controlled exec path."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(
                manifest=Manifest(
                    root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
                    environment=Environment(value={"SHARED": "manifest", "ONLY_MANIFEST": "1"}),
                ),
                options=daytona_module.DaytonaSandboxClientOptions(
                    env_vars={"SHARED": "option", "ONLY_OPTION": "1"},
                ),
            )
            await session.exec("printenv", "SHARED", shell=False, timeout=5.0)

        sandbox = _FakeAsyncDaytona.current_sandbox
        assert sandbox is not None
        _session_id, request, _kwargs = sandbox.process.execute_session_command_calls[0]
        command = cast(str, cast(Any, request).command)
        assert "env --" in command
        assert "SHARED=manifest" in command
        assert "ONLY_MANIFEST=1" in command
        assert "ONLY_OPTION=1" in command

    @pytest.mark.asyncio
    async def test_exec_preserves_session_command_stdout_and_stderr(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
            sandbox = _FakeAsyncDaytona.current_sandbox
            assert sandbox is not None
            sandbox.process.next_session_command_result = types.SimpleNamespace(
                cmd_id="cmd-123",
                exit_code=7,
                stdout="hello stdout",
                stderr="hello stderr",
                output="hello stdouthello stderr",
            )
            result = await session.exec("sh", "-c", "printf out; printf err >&2", shell=False)

        assert result.exit_code == 7
        assert result.stdout == b"hello stdout"
        assert result.stderr == b"hello stderr"

    @pytest.mark.asyncio
    async def test_resume_reconnects_paused_sandbox_and_preserves_state(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify pause-on-exit resumes an existing sandbox instead of creating a new one."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(
                options=daytona_module.DaytonaSandboxClientOptions(pause_on_exit=True),
            )
            state = session.state
            _FakeAsyncDaytona.create_calls.clear()

            resumed = await client.resume(state)

        assert _FakeAsyncDaytona.get_calls == [state.sandbox_id]
        assert _FakeAsyncDaytona.create_calls == []
        assert resumed._inner._workspace_state_preserved_on_start() is True  # noqa: SLF001
        assert resumed._inner._system_state_preserved_on_start() is True  # noqa: SLF001
        assert resumed._inner._can_reuse_preserved_workspace_on_resume() is False  # noqa: SLF001

    @pytest.mark.asyncio
    async def test_resume_reconnects_unpaused_live_sandbox_after_unclean_worker_exit(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify resume reconnects to a live sandbox that was never cleanly deleted."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
            state = session.state
            _FakeAsyncDaytona.create_calls.clear()

            resumed = await client.resume(state)

        assert _FakeAsyncDaytona.get_calls == [state.sandbox_id]
        assert _FakeAsyncDaytona.create_calls == []
        assert resumed.state.sandbox_id == state.sandbox_id
        assert resumed._inner._workspace_state_preserved_on_start() is True  # noqa: SLF001
        assert resumed._inner._system_state_preserved_on_start() is True  # noqa: SLF001

    @pytest.mark.asyncio
    async def test_resume_recreates_unpaused_sandbox_when_reconnect_fails(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify resume falls back to a fresh Daytona sandbox when the old id is gone."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
            state = session.state
            old_sandbox_id = state.sandbox_id
            _FakeAsyncDaytona.create_calls.clear()
            _FakeAsyncDaytona.get_error = RuntimeError("sandbox_not_found")

            resumed = await client.resume(state)

        assert _FakeAsyncDaytona.get_calls == [old_sandbox_id]
        assert len(_FakeAsyncDaytona.create_calls) == 1
        assert resumed.state.sandbox_id == "sandbox-123"
        assert resumed._inner._workspace_state_preserved_on_start() is False  # noqa: SLF001
        assert resumed._inner._system_state_preserved_on_start() is False  # noqa: SLF001

    @pytest.mark.asyncio
    async def test_preserved_start_rehydrates_when_snapshot_gate_requests_restore(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify resumed paused sandboxes can still rehydrate when the fingerprint gate fails."""

        daytona_module = _load_daytona_module(monkeypatch)
        session = daytona_module.DaytonaSandboxSession.from_state(
            daytona_module.DaytonaSandboxSessionState(
                manifest=Manifest(root="/workspace"),
                snapshot=_RestorableSnapshot(id="snapshot"),
                sandbox_id="sandbox-123",
                pause_on_exit=True,
                workspace_root_ready=True,
            ),
            sandbox=_FakeDaytonaSandbox(),
        )
        session._set_start_state_preserved(True)  # noqa: SLF001

        events: list[object] = []

        async def _running() -> bool:
            return True

        async def _gate(*, is_running: bool) -> bool:
            events.append(("gate", is_running))
            return False

        async def _restore() -> None:
            events.append("restore")

        async def _reapply() -> None:
            events.append("reapply")

        monkeypatch.setattr(session, "running", _running)
        session._can_skip_snapshot_restore_on_resume = _gate
        monkeypatch.setattr(session, "_restore_snapshot_into_workspace_on_resume", _restore)
        monkeypatch.setattr(session, "_reapply_ephemeral_manifest_on_resume", _reapply)

        await session.start()

        assert events == [("gate", True), "restore", "reapply"]

    @pytest.mark.asyncio
    async def test_resolve_exposed_port_uses_signed_preview_url(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify Daytona maps signed preview URLs to the shared exposed-port endpoint shape."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(
                options=daytona_module.DaytonaSandboxClientOptions(
                    exposed_ports=(4500,),
                    exposed_port_url_ttl_s=1800,
                ),
            )

            endpoint = await session.resolve_exposed_port(4500)

        assert endpoint == ExposedPortEndpoint(
            host="4500-signed-token.daytonaproxy01.net",
            port=443,
            tls=True,
        )
        sandbox = _FakeAsyncDaytona.current_sandbox
        assert sandbox is not None
        assert sandbox.signed_preview_url_calls == [(4500, 1800)]

    @pytest.mark.asyncio
    async def test_resolve_exposed_port_rejects_invalid_preview_urls(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify malformed Daytona preview URLs become ExposedPortUnavailableError."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(
                options=daytona_module.DaytonaSandboxClientOptions(exposed_ports=(4500,)),
            )
            sandbox = _FakeAsyncDaytona.current_sandbox
            assert sandbox is not None

            async def _bad_preview_url(
                port: int,
                expires_in_seconds: int | None = None,
            ) -> object:
                _ = (port, expires_in_seconds)
                return types.SimpleNamespace(url=":", token="bad")

            sandbox.create_signed_preview_url = _bad_preview_url  # type: ignore[method-assign]

            with pytest.raises(daytona_module.ExposedPortUnavailableError) as exc_info:
                await session.resolve_exposed_port(4500)

        assert exc_info.value.context["detail"] == "invalid_preview_url"

    @pytest.mark.asyncio
    async def test_normalize_path_rejects_workspace_escape_and_allows_absolute_in_root(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify Daytona normalizes paths without host resolution and enforces the root."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
            inner = session._inner  # noqa: SLF001

            with pytest.raises(daytona_module.InvalidManifestPathError):
                inner.normalize_path("../outside")
            with pytest.raises(daytona_module.InvalidManifestPathError):
                inner.normalize_path("/etc/passwd")

            assert inner.normalize_path(
                f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/nested/file.txt"
            ) == Path(f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/nested/file.txt")

    @pytest.mark.asyncio
    async def test_read_and_write_reject_paths_outside_workspace_root(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify Daytona read/write reject absolute and traversal paths before remote FS calls."""

        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())

            with pytest.raises(daytona_module.InvalidManifestPathError):
                await session.read("../outside.txt")
            with pytest.raises(daytona_module.InvalidManifestPathError):
                await session.write("/etc/passwd", io.BytesIO(b"nope"))

    @pytest.mark.asyncio
    async def test_read_rejects_workspace_symlink_to_ungranted_path(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
            sandbox = _FakeAsyncDaytona.current_sandbox
            assert sandbox is not None
            sandbox.process.symlinks[f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link"] = (
                "/private"
            )

            with pytest.raises(daytona_module.InvalidManifestPathError) as exc_info:
                await session.read("link/secret.txt")

        assert sandbox.fs.download_file_calls == []
        assert str(exc_info.value) == "manifest path must not escape root: link/secret.txt"
        assert exc_info.value.context == {
            "rel": "link/secret.txt",
            "reason": "escape_root",
            "resolved_path": "workspace escape: /private/secret.txt",
        }

    @pytest.mark.asyncio
    async def test_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(
                manifest=Manifest(
                    root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
                    extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),),
                ),
                options=daytona_module.DaytonaSandboxClientOptions(),
            )
            sandbox = _FakeAsyncDaytona.current_sandbox
            assert sandbox is not None
            sandbox.process.symlinks[f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link"] = (
                "/tmp/protected"
            )

            with pytest.raises(daytona_module.WorkspaceArchiveWriteError) as exc_info:
                await session.write("link/out.txt", io.BytesIO(b"blocked"))

        assert sandbox.fs.upload_file_calls == []
        assert str(exc_info.value) == (
            "failed to write archive for path: "
            f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link/out.txt"
        )
        assert exc_info.value.context == {
            "path": f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link/out.txt",
            "reason": "read_only_extra_path_grant",
            "grant_path": "/tmp/protected",
            "resolved_path": "/tmp/protected/out.txt",
        }

    @pytest.mark.asyncio
    async def test_mkdir_rejects_workspace_symlink_to_read_only_extra_path_grant(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(
                manifest=Manifest(
                    root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
                    extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),),
                ),
                options=daytona_module.DaytonaSandboxClientOptions(),
            )
            sandbox = _FakeAsyncDaytona.current_sandbox
            assert sandbox is not None
            sandbox.process.symlinks[f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link"] = (
                "/tmp/protected"
            )

            with pytest.raises(daytona_module.WorkspaceArchiveWriteError) as exc_info:
                await session.mkdir("link/newdir")

        assert sandbox.fs.create_folder_calls == []
        assert str(exc_info.value) == (
            "failed to write archive for path: "
            f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link/newdir"
        )
        assert exc_info.value.context == {
            "path": f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link/newdir",
            "reason": "read_only_extra_path_grant",
            "grant_path": "/tmp/protected",
            "resolved_path": "/tmp/protected/newdir",
        }

    @pytest.mark.asyncio
    async def test_mkdir_as_user_checks_permissions_then_uses_files_api(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)

        async with daytona_module.DaytonaSandboxClient() as client:
            session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
            sandbox = _FakeAsyncDaytona.current_sandbox
            assert sandbox is not None

            await session.mkdir("nested", user=User(name="sandbox-user"))

        assert sandbox.fs.create_folder_calls == [
            (f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/nested", "755")
        ]
        commands = [
            cast(str, cast(Any, request).command)
            for _session_id, request, _kwargs in sandbox.process.execute_session_command_calls
        ]
        expected_cmd = f"cd {daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT} && " + shlex.join(
            [
                "sudo",
                "-u",
                "sandbox-user",
                "--",
                "sh",
                "-lc",
                _MKDIR_ACCESS_CHECK_SCRIPT,
                "sh",
                f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/nested",
                "0",
            ]
        )
        assert commands[-1] == expected_cmd

    @pytest.mark.asyncio
    async def test_persist_workspace_remounts_mounts_after_snapshot(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify mounts are restored after a Daytona workspace snapshot completes."""

        daytona_module = _load_daytona_module(monkeypatch)
        mount = _RecordingMount()
        sandbox = _FakeDaytonaSandbox()
        sandbox.fs.download_value = b"fake-tar-bytes"
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(
                root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
                entries={"mount": mount},
            ),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)

        archive = await session.persist_workspace()

        assert archive.read() == b"fake-tar-bytes"
        mount_path = Path(f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/mount")
        assert mount._unmounted_paths == [mount_path]
        assert mount._mounted_paths == [mount_path]

    @pytest.mark.asyncio
    async def test_persist_workspace_marks_stopped_sandbox_non_retryable(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify stopped Daytona sandboxes expose provider-neutral retryability."""

        daytona_module = _load_daytona_module(monkeypatch)
        sandbox = _FakeDaytonaSandbox()
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)

        async def _raise_stopped_sandbox(_cmd: str, **_kwargs: object) -> object:
            raise RuntimeError(
                "bad request: failed to resolve container IP after 3 attempts: "
                "no IP address found. Is the Sandbox started?"
            )

        monkeypatch.setattr(sandbox.process, "exec", _raise_stopped_sandbox)

        with pytest.raises(daytona_module.WorkspaceArchiveReadError) as exc_info:
            await session.persist_workspace()

        assert exc_info.value.retryable is False
        assert exc_info.value.context["backend"] == "daytona"
        assert exc_info.value.context["reason"] == "sandbox_not_running"

    @pytest.mark.asyncio
    async def test_persist_workspace_uses_nested_mount_targets_and_runtime_skip_paths(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify Daytona excludes nested mount targets and runtime-registered skip paths."""

        daytona_module = _load_daytona_module(monkeypatch)
        parent_mount = _RecordingMount(mount_path=Path("repo"))
        child_mount = _RecordingMount(mount_path=Path("repo/sub"))
        events: list[tuple[str, str]] = []
        sandbox = _FakeDaytonaSandbox()
        sandbox.fs.download_value = b"fake-tar-bytes"
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(
                root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
                entries={
                    "parent": parent_mount.bind_events(events),
                    "nested": Dir(children={"child": child_mount.bind_events(events)}),
                },
            ),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
        session.register_persist_workspace_skip_path("runtime.tmp")

        archive = await session.persist_workspace()

        assert archive.read() == b"fake-tar-bytes"
        assert {path for kind, path in events if kind == "unmount"} == {
            f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/repo",
            f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/repo/sub",
        }
        assert {path for kind, path in events if kind == "mount"} == {
            f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/repo",
            f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/repo/sub",
        }
        tar_command = sandbox.process.exec_calls[0][0]
        assert "--exclude=repo" in tar_command
        assert "--exclude=./repo" in tar_command
        assert "--exclude=repo/sub" in tar_command
        assert "--exclude=./repo/sub" in tar_command
        assert "--exclude=runtime.tmp" in tar_command

    @pytest.mark.asyncio
    async def test_persist_workspace_remounts_prior_mounts_after_unmount_failure(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify a partial Daytona unmount failure remounts earlier mounts before raising."""

        daytona_module = _load_daytona_module(monkeypatch)
        events: list[tuple[str, str]] = []
        sandbox = _FakeDaytonaSandbox()
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(
                root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
                entries={
                    "repo": Dir(
                        children={
                            "mount1": _RecordingMount().bind_events(events),
                            "mount2": _FailingUnmountMount().bind_events(events),
                        }
                    )
                },
            ),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)

        with pytest.raises(daytona_module.WorkspaceArchiveReadError):
            await session.persist_workspace()

        assert [kind for kind, _path in events] == [
            "unmount",
            "unmount_fail",
            "mount",
        ]
        assert sandbox.process.exec_calls == []

    @pytest.mark.asyncio
    async def test_clear_workspace_root_on_resume_preserves_nested_mounts(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Verify inherited resume cleanup skips mounted directories."""

        daytona_module = _load_daytona_module(monkeypatch)
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(
                root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
                entries={
                    "a/b": _RecordingMount(),
                },
            ),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id="sandbox-123",
        )
        session = daytona_module.DaytonaSandboxSession.from_state(
            state,
            sandbox=_FakeDaytonaSandbox(),
        )
        workspace_root = Path(daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT)
        ls_calls: list[Path] = []
        rm_calls: list[tuple[Path, bool]] = []

        async def _fake_ls(path: Path | str) -> list[object]:
            rendered = Path(path)
            ls_calls.append(rendered)
            if rendered == workspace_root:
                return [
                    types.SimpleNamespace(
                        path=str(workspace_root / "a"),
                        kind=EntryKind.DIRECTORY,
                    ),
                    types.SimpleNamespace(
                        path=str(workspace_root / "root.txt"),
                        kind=EntryKind.FILE,
                    ),
                ]
            if rendered == workspace_root / "a":
                return [
                    types.SimpleNamespace(
                        path=str(workspace_root / "a/b"),
                        kind=EntryKind.DIRECTORY,
                    ),
                    types.SimpleNamespace(
                        path=str(workspace_root / "a/local.txt"),
                        kind=EntryKind.FILE,
                    ),
                ]
            raise AssertionError(f"unexpected ls path: {rendered}")

        async def _fake_rm(path: Path | str, *, recursive: bool = False) -> None:
            rm_calls.append((Path(path), recursive))

        monkeypatch.setattr(session, "ls", _fake_ls)
        monkeypatch.setattr(session, "rm", _fake_rm)

        await session._clear_workspace_root_on_resume()  # noqa: SLF001

        assert ls_calls == [workspace_root, workspace_root / "a"]
        assert rm_calls == [
            (workspace_root / "a/local.txt", True),
            (workspace_root / "root.txt", True),
        ]

    @pytest.mark.asyncio
    async def test_pty_start_write_and_exit(self, monkeypatch: pytest.MonkeyPatch) -> None:
        daytona_module = _load_daytona_module(monkeypatch)
        sandbox = _FakeDaytonaSandbox()
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)

        started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.05)

        assert started.process_id is not None
        assert b">>>" in started.output

        updated = await session.pty_write_stdin(
            session_id=started.process_id,
            chars="5 + 5\n",
            yield_time_s=0.05,
        )
        assert updated.process_id == started.process_id
        assert b"10" in updated.output

        finished = await session.pty_write_stdin(
            session_id=started.process_id,
            chars="exit\n",
            yield_time_s=0.05,
        )
        assert finished.process_id is None
        assert finished.exit_code == 0

    @pytest.mark.asyncio
    async def test_stop_terminates_live_pty_sessions(self, monkeypatch: pytest.MonkeyPatch) -> None:
        daytona_module = _load_daytona_module(monkeypatch)
        sandbox = _FakeDaytonaSandbox()
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)

        started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.05)
        assert started.process_id is not None

        await session.stop()

        assert sandbox.process.kill_pty_session_calls

    @pytest.mark.asyncio
    async def test_pty_start_wraps_startup_failures(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)
        sandbox = _FakeDaytonaSandbox()
        sandbox.process.create_pty_session_error = FileNotFoundError("missing-shell")
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)

        with pytest.raises(ExecTransportError) as exc_info:
            await session.pty_exec_start("python3", shell=False, tty=True)
        assert str(exc_info.value) == "Daytona exec failed: FileNotFoundError: missing-shell"
        assert exc_info.value.context["backend"] == "daytona"
        assert exc_info.value.context["provider_error"] == "FileNotFoundError: missing-shell"

    @pytest.mark.asyncio
    async def test_pty_start_maps_sdk_timeout_failures(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)

        class _FakeTimeout(Exception):
            pass

        monkeypatch.setattr(
            daytona_module,
            "_daytona_timeout_error_types",
            lambda: (_FakeTimeout,),
        )

        sandbox = _FakeDaytonaSandbox()
        sandbox.process.create_session_error = _FakeTimeout("timed out")
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)

        with pytest.raises(ExecTimeoutError):
            await session.pty_exec_start("python3", shell=False, tty=False, timeout=2.0)

    @pytest.mark.asyncio
    async def test_pty_start_marks_documented_sdk_not_found_non_retryable(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)

        class _FakeNotFound(Exception):
            status_code = 404
            error_code = "sandbox_not_found"

        monkeypatch.setattr(
            daytona_module,
            "_daytona_non_retryable_error_types",
            lambda: (_FakeNotFound,),
        )
        monkeypatch.setattr(daytona_module, "_daytona_retryable_error_types", lambda: ())

        sandbox = _FakeDaytonaSandbox()
        sandbox.process.create_pty_session_error = _FakeNotFound("sandbox not found")
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)

        with pytest.raises(ExecTransportError) as exc_info:
            await session.pty_exec_start("python3", shell=False, tty=True)

        assert exc_info.value.retryable is False
        assert exc_info.value.context["backend"] == "daytona"
        assert exc_info.value.context["http_status"] == 404
        assert exc_info.value.context["provider_error_code"] == "sandbox_not_found"
        assert exc_info.value.context["reason"] == "sandbox_not_found"

    @pytest.mark.asyncio
    async def test_pty_start_marks_documented_sdk_rate_limit_retryable(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)

        class _FakeRateLimit(Exception):
            status_code = 429
            error_code = "rate_limit_exceeded"

        monkeypatch.setattr(
            daytona_module,
            "_daytona_retryable_error_types",
            lambda: (_FakeRateLimit,),
        )
        monkeypatch.setattr(daytona_module, "_daytona_non_retryable_error_types", lambda: ())

        sandbox = _FakeDaytonaSandbox()
        sandbox.process.create_pty_session_error = _FakeRateLimit("rate limit exceeded")
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)

        with pytest.raises(ExecTransportError) as exc_info:
            await session.pty_exec_start("python3", shell=False, tty=True)

        assert exc_info.value.retryable is True
        assert exc_info.value.context["backend"] == "daytona"
        assert exc_info.value.context["http_status"] == 429
        assert exc_info.value.context["provider_error_code"] == "rate_limit_exceeded"
        assert exc_info.value.context["reason"] == "rate_limit_exceeded"

    @pytest.mark.parametrize(
        ("status", "expected_retryable"),
        [
            (400, False),
            (401, False),
            (403, False),
            (404, False),
            (409, False),
            (429, True),
            (500, True),
            (502, True),
            (503, True),
            (504, True),
        ],
    )
    def test_daytona_retryability_status_table(
        self,
        monkeypatch: pytest.MonkeyPatch,
        status: int,
        expected_retryable: bool,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)
        monkeypatch.setattr(daytona_module, "_daytona_non_retryable_error_types", lambda: ())
        monkeypatch.setattr(daytona_module, "_daytona_retryable_error_types", lambda: ())

        class FakeStatusError(Exception):
            status_code = status

        retryable, reason = daytona_module._daytona_provider_retryability(FakeStatusError())

        assert retryable is expected_retryable
        assert reason == f"http_{status}"

    @pytest.mark.asyncio
    async def test_session_reader_keeps_entry_live_when_logs_fail_without_exit_code(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)
        sandbox = _FakeDaytonaSandbox()
        sandbox.process.get_session_command_logs_error = RuntimeError("logs failed")
        sandbox.process.session_command_exit_code = None
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
        entry = daytona_module._DaytonaPtySessionEntry(  # noqa: SLF001
            daytona_session_id="session-123",
            pty_handle=object(),
            tty=False,
            cmd_id="cmd-123",
        )

        await session._run_session_reader(  # noqa: SLF001
            entry,
            "session-123",
            "cmd-123",
            lambda _chunk: None,
        )

        assert entry.done is False
        assert entry.exit_code is None

    @pytest.mark.asyncio
    async def test_terminate_pty_entry_awaits_worker_finalizer(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)
        sandbox = _FakeDaytonaSandbox()
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
        entry = daytona_module._DaytonaPtySessionEntry(  # noqa: SLF001
            daytona_session_id="session-123",
            pty_handle=object(),
            tty=False,
            cmd_id="cmd-123",
        )
        finalizer_finished = asyncio.Event()

        async def worker() -> None:
            try:
                await asyncio.Event().wait()
            finally:
                await asyncio.sleep(0)
                finalizer_finished.set()

        entry.worker_task = asyncio.create_task(worker())
        await asyncio.sleep(0)

        await session._terminate_pty_entry(entry)  # noqa: SLF001

        assert finalizer_finished.is_set()
        assert entry.worker_task is None
        assert sandbox.process.delete_session_calls == ["session-123"]

    @pytest.mark.asyncio
    async def test_terminate_pty_entry_bounds_worker_finalizer(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        daytona_module = _load_daytona_module(monkeypatch)
        sandbox = _FakeDaytonaSandbox()
        state = daytona_module.DaytonaSandboxSessionState(
            manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
            snapshot=NoopSnapshot(id="snapshot"),
            sandbox_id=sandbox.id,
        )
        monkeypatch.setattr(state.timeouts, "cleanup_s", 0.01)
        session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
        entry = daytona_module._DaytonaPtySessionEntry(  # noqa: SLF001
            daytona_session_id="session-123",
            pty_handle=object(),
            tty=False,
            cmd_id="cmd-123",
        )
        logs_started = asyncio.Event()
        finalizer_started = asyncio.Event()

        async def read_logs(*_args: object) -> None:
            logs_started.set()
            await asyncio.Event().wait()

        async def get_command(*_args: object) -> object:
            finalizer_started.set()
            await asyncio.Event().wait()
            return types.SimpleNamespace(exit_code=None)

        monkeypatch.setattr(sandbox.process, "get_session_command_logs_async", read_logs)
        monkeypatch.setattr(sandbox.process, "get_session_command", get_command)

        worker_task = asyncio.create_task(
            session._run_session_reader(  # noqa: SLF001
                entry,
                "session-123",
                "cmd-123",
                lambda _chunk: None,
            )
        )
        entry.worker_task = worker_task
        await logs_started.wait()

        await asyncio.wait_for(session._terminate_pty_entry(entry), timeout=0.5)  # noqa: SLF001

        assert finalizer_started.is_set()
        assert worker_task.done()
        assert entry.worker_task is None
        assert sandbox.process.delete_session_calls == ["session-123"]


# ---------------------------------------------------------------------------
# DaytonaCloudBucketMountStrategy tests
# ---------------------------------------------------------------------------


class _FakePreflightSession(BaseSandboxSession):
    """Fake session for testing mount preflights with queued exec results."""

    # Make type(instance).__name__ return "DaytonaSandboxSession" so the session guard passes.
    __name__ = "DaytonaSandboxSession"

    def __init__(self, results: list[ExecResult] | None = None) -> None:
        self.state = TestSessionState(
            session_id=uuid.uuid4(),
            manifest=Manifest(root="/workspace"),
            snapshot=NoopSnapshot(id="test"),
        )
        self._results: deque[ExecResult] = deque(results or [])
        self.exec_calls: list[str] = []

    def _ok(self) -> ExecResult:
        return ExecResult(stdout=b"", stderr=b"", exit_code=0)

    def _fail(self) -> ExecResult:
        return ExecResult(stdout=b"", stderr=b"", exit_code=1)

    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        _ = timeout
        cmd_str = " ".join(str(c) for c in command)
        self.exec_calls.append(cmd_str)
        if self._results:
            return self._results.popleft()
        return self._ok()

    async def read(self, path: Path, *, user: object = None) -> io.IOBase:
        _ = (path, user)
        return io.BytesIO(b"")

    async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
        _ = (path, data, user)

    async def running(self) -> bool:
        return True

    async def persist_workspace(self) -> io.IOBase:
        raise AssertionError("not expected")

    async def hydrate_workspace(self, data: io.IOBase) -> None:
        raise AssertionError("not expected")


# Override __name__ at the class level so type(instance).__name__ == "DaytonaSandboxSession".
_FakePreflightSession.__name__ = "DaytonaSandboxSession"


def _ok() -> ExecResult:
    return ExecResult(stdout=b"", stderr=b"", exit_code=0)


def _fail() -> ExecResult:
    return ExecResult(stdout=b"", stderr=b"", exit_code=1)


# --- Export & Construction ---


def test_daytona_mount_strategy_importable(monkeypatch: pytest.MonkeyPatch) -> None:
    _load_daytona_module(monkeypatch)
    package = importlib.import_module("agents.extensions.sandbox.daytona")
    assert hasattr(package, "DaytonaCloudBucketMountStrategy")
    assert package.DaytonaCloudBucketMountStrategy is DaytonaCloudBucketMountStrategy


def test_daytona_mount_strategy_type_and_default_pattern() -> None:
    strategy = DaytonaCloudBucketMountStrategy()
    assert strategy.type == "daytona_cloud_bucket"
    assert isinstance(strategy.pattern, RcloneMountPattern)
    assert strategy.pattern.mode == "fuse"


def test_daytona_mount_strategy_round_trips_through_manifest(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    _load_daytona_module(monkeypatch)

    manifest = Manifest.model_validate(
        {
            "root": "/workspace",
            "entries": {
                "bucket": {
                    "type": "s3_mount",
                    "bucket": "my-bucket",
                    "mount_strategy": {"type": "daytona_cloud_bucket"},
                }
            },
        }
    )
    mount = manifest.entries["bucket"]
    assert isinstance(mount, S3Mount)
    assert isinstance(mount.mount_strategy, DaytonaCloudBucketMountStrategy)


# --- Session Guard ---


def test_daytona_session_guard_rejects_wrong_type() -> None:
    class _WrongSession:
        pass

    with pytest.raises(MountConfigError, match="DaytonaSandboxSession"):
        _assert_daytona_session(_WrongSession())  # type: ignore[arg-type]


def test_daytona_session_guard_accepts_correct_type() -> None:
    session = _FakePreflightSession()
    _assert_daytona_session(session)  # should not raise


# --- _has_command ---


@pytest.mark.asyncio
async def test_has_command_found() -> None:
    session = _FakePreflightSession([_ok()])
    assert await _has_command(session, "rclone") is True
    assert len(session.exec_calls) == 1
    assert "command -v rclone" in session.exec_calls[0]


@pytest.mark.asyncio
async def test_has_command_not_found() -> None:
    session = _FakePreflightSession([_fail()])
    assert await _has_command(session, "rclone") is False


# --- _pkg_install ---


@pytest.mark.asyncio
async def test_pkg_install_via_apt() -> None:
    session = _FakePreflightSession(
        [
            _ok(),  # _has_command("apt-get") → found
            _ok(),  # install succeeds
        ]
    )
    await _pkg_install(session, "rclone", what="rclone")
    assert any("apt-get" in c and "rclone" in c for c in session.exec_calls)
    assert any(c.startswith("sudo -u root --") and "apt-get" in c for c in session.exec_calls)


@pytest.mark.asyncio
async def test_pkg_install_via_apk() -> None:
    session = _FakePreflightSession(
        [
            _fail(),  # _has_command("apt-get") → not found
            _ok(),  # _has_command("apk") → found
            _ok(),  # install succeeds
        ]
    )
    await _pkg_install(session, "fuse3", what="fusermount")
    assert any("apk add" in c and "fuse3" in c for c in session.exec_calls)
    assert any(c.startswith("sudo -u root --") and "apk add" in c for c in session.exec_calls)


@pytest.mark.asyncio
async def test_pkg_install_no_package_manager() -> None:
    session = _FakePreflightSession(
        [
            _fail(),  # _has_command("apt-get") → not found
            _fail(),  # _has_command("apk") → not found
        ]
    )
    with pytest.raises(MountConfigError, match="no supported package manager"):
        await _pkg_install(session, "rclone", what="rclone")


@pytest.mark.asyncio
async def test_pkg_install_retries_then_fails() -> None:
    session = _FakePreflightSession(
        [
            _ok(),  # _has_command("apt-get") → found
            _fail(),  # install attempt 1
            _fail(),  # install attempt 2
            _fail(),  # install attempt 3
        ]
    )
    with pytest.raises(MountConfigError, match="after 3 attempts"):
        await _pkg_install(session, "rclone", what="rclone")
    # 1 check + 3 install attempts = 4 exec calls.
    assert len(session.exec_calls) == 4
    assert all(c.startswith("sudo -u root --") for c in session.exec_calls[1:])


# --- _ensure_fuse_support ---


@pytest.mark.asyncio
async def test_ensure_fuse_dev_fuse_missing() -> None:
    session = _FakePreflightSession([_fail()])
    with pytest.raises(MountConfigError, match="/dev/fuse not available"):
        await _ensure_fuse_support(session)


@pytest.mark.asyncio
async def test_ensure_fuse_kernel_module_missing() -> None:
    session = _FakePreflightSession(
        [
            _ok(),  # /dev/fuse exists
            _fail(),  # fuse not in /proc/filesystems
        ]
    )
    with pytest.raises(MountConfigError, match="FUSE kernel module not loaded"):
        await _ensure_fuse_support(session)


@pytest.mark.asyncio
async def test_ensure_fuse_fusermount_present() -> None:
    session = _FakePreflightSession(
        [
            _ok(),  # /dev/fuse
            _ok(),  # /proc/filesystems
            _ok(),  # _has_command("fusermount3") → found
        ]
    )
    await _ensure_fuse_support(session)
    assert len(session.exec_calls) == 3


@pytest.mark.asyncio
async def test_ensure_fuse_installs_when_missing() -> None:
    session = _FakePreflightSession(
        [
            _ok(),  # /dev/fuse
            _ok(),  # /proc/filesystems
            _fail(),  # _has_command("fusermount3") → not found
            _fail(),  # _has_command("fusermount") → not found
            _ok(),  # _has_command("apt-get") → found (inside _pkg_install)
            _ok(),  # apt-get install fuse3 → success
            _ok(),  # re-check: _has_command("fusermount3") → found
        ]
    )
    await _ensure_fuse_support(session)
    assert any("fuse3" in c for c in session.exec_calls)
    assert len(session.exec_calls) == 7


# --- _ensure_rclone ---


@pytest.mark.asyncio
async def test_ensure_rclone_present() -> None:
    session = _FakePreflightSession([_ok()])
    await _ensure_rclone(session)
    assert len(session.exec_calls) == 1


@pytest.mark.asyncio
async def test_ensure_rclone_installs_when_missing() -> None:
    session = _FakePreflightSession(
        [
            _fail(),  # _has_command("rclone") → not found
            _ok(),  # _has_command("apt-get") → found (inside _pkg_install)
            _ok(),  # apt-get install rclone → success
            _ok(),  # re-check: _has_command("rclone") → found
        ]
    )
    await _ensure_rclone(session)
    assert any("rclone" in c for c in session.exec_calls)
    assert len(session.exec_calls) == 4


# --- Strategy lifecycle ---


@pytest.mark.asyncio
async def test_activate_calls_preflights_and_delegates() -> None:
    strategy = DaytonaCloudBucketMountStrategy()
    mount = MagicMock()
    session = _FakePreflightSession()
    dest = Path("/workspace")
    base_dir = Path("/workspace")

    with (
        patch.object(_daytona_mounts, "_ensure_fuse_support", new_callable=AsyncMock) as fuse_mock,
        patch.object(_daytona_mounts, "_ensure_rclone", new_callable=AsyncMock) as rclone_mock,
        patch.object(
            InContainerMountStrategy, "activate", new_callable=AsyncMock, return_value=[]
        ) as delegate_mock,
    ):
        await strategy.activate(mount, session, dest, base_dir)
        fuse_mock.assert_awaited_once_with(session)
        rclone_mock.assert_awaited_once_with(session)
        delegate_mock.assert_awaited_once()


@pytest.mark.asyncio
async def test_deactivate_delegates_without_preflights() -> None:
    strategy = DaytonaCloudBucketMountStrategy()
    mount = MagicMock()
    session = _FakePreflightSession()
    dest = Path("/workspace")
    base_dir = Path("/workspace")

    with (
        patch.object(_daytona_mounts, "_ensure_fuse_support", new_callable=AsyncMock) as fuse_mock,
        patch.object(_daytona_mounts, "_ensure_rclone", new_callable=AsyncMock) as rclone_mock,
        patch.object(
            InContainerMountStrategy, "deactivate", new_callable=AsyncMock
        ) as delegate_mock,
    ):
        await strategy.deactivate(mount, session, dest, base_dir)
        fuse_mock.assert_not_awaited()
        rclone_mock.assert_not_awaited()
        delegate_mock.assert_awaited_once()


@pytest.mark.asyncio
async def test_teardown_delegates_without_preflights() -> None:
    strategy = DaytonaCloudBucketMountStrategy()
    mount = MagicMock()
    session = _FakePreflightSession()
    path = Path("/workspace/bucket")

    with (
        patch.object(_daytona_mounts, "_ensure_fuse_support", new_callable=AsyncMock) as fuse_mock,
        patch.object(_daytona_mounts, "_ensure_rclone", new_callable=AsyncMock) as rclone_mock,
        patch.object(
            InContainerMountStrategy, "teardown_for_snapshot", new_callable=AsyncMock
        ) as delegate_mock,
    ):
        await strategy.teardown_for_snapshot(mount, session, path)
        fuse_mock.assert_not_awaited()
        rclone_mock.assert_not_awaited()
        delegate_mock.assert_awaited_once()


@pytest.mark.asyncio
async def test_restore_after_snapshot_reruns_preflights() -> None:
    strategy = DaytonaCloudBucketMountStrategy()
    mount = MagicMock()
    session = _FakePreflightSession()
    path = Path("/workspace/bucket")

    with (
        patch.object(_daytona_mounts, "_ensure_fuse_support", new_callable=AsyncMock) as fuse_mock,
        patch.object(_daytona_mounts, "_ensure_rclone", new_callable=AsyncMock) as rclone_mock,
        patch.object(
            InContainerMountStrategy, "restore_after_snapshot", new_callable=AsyncMock
        ) as delegate_mock,
    ):
        await strategy.restore_after_snapshot(mount, session, path)
        fuse_mock.assert_awaited_once_with(session)
        rclone_mock.assert_awaited_once_with(session)
        delegate_mock.assert_awaited_once()


def test_build_docker_volume_driver_config_returns_none() -> None:
    strategy = DaytonaCloudBucketMountStrategy()
    mount = MagicMock()
    assert strategy.build_docker_volume_driver_config(mount) is None
