from __future__ import annotations

import hashlib
import io
import os
from collections.abc import Awaitable, Callable, Sequence
from pathlib import Path, PureWindowsPath

import pytest

import agents.sandbox.entries.artifacts as artifacts_module
from agents.sandbox import SandboxConcurrencyLimits, SandboxPathGrant
from agents.sandbox.entries import (
    Dir,
    File,
    GitRepo,
    LocalDir,
    LocalFile,
    resolve_workspace_path,
)
from agents.sandbox.errors import (
    ExecNonZeroError,
    GitCloneError,
    GitCopyError,
    GitSubpathError,
    InvalidManifestPathError,
    LocalDirReadError,
    LocalFileReadError,
)
from agents.sandbox.manifest import Manifest
from agents.sandbox.materialization import MaterializedFile
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
from agents.sandbox.snapshot import NoopSnapshot
from agents.sandbox.types import ExecResult, User
from tests.utils.factories import TestSessionState


class _RecordingSession(BaseSandboxSession):
    def __init__(self, manifest: Manifest | None = None) -> None:
        self.state = TestSessionState(
            manifest=manifest or Manifest(),
            snapshot=NoopSnapshot(id="noop"),
        )
        self.exec_calls: list[tuple[str, ...]] = []
        self.writes: dict[Path, bytes] = {}

    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        _ = timeout
        cmd = tuple(str(part) for part in command)
        self.exec_calls.append(cmd)
        return ExecResult(stdout=b"", stderr=b"", exit_code=0)

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

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

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

    async def persist_workspace(self) -> io.IOBase:
        return io.BytesIO()

    async def hydrate_workspace(self, data: io.IOBase) -> None:
        _ = data

    async def shutdown(self) -> None:
        return


class _GitRefSession(_RecordingSession):
    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        _ = timeout
        cmd = tuple(str(part) for part in command)
        self.exec_calls.append(cmd)
        if cmd == ("command -v git >/dev/null 2>&1",):
            return ExecResult(stdout=b"/usr/bin/git\n", stderr=b"", exit_code=0)
        if cmd[:2] == ("git", "clone"):
            return ExecResult(stdout=b"", stderr=b"unexpected clone path", exit_code=1)
        return ExecResult(stdout=b"", stderr=b"", exit_code=0)


class _GitFailureSession(_RecordingSession):
    def __init__(self, *, fail_on: str) -> None:
        super().__init__()
        self.fail_on = fail_on

    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        _ = timeout
        cmd = tuple(str(part) for part in command)
        self.exec_calls.append(cmd)
        if cmd == ("command -v git >/dev/null 2>&1",):
            return ExecResult(stdout=b"/usr/bin/git\n", stderr=b"", exit_code=0)
        if self.fail_on == "clone" and cmd[:2] == ("git", "clone"):
            return ExecResult(stdout=b"", stderr=b"clone failed", exit_code=1)
        if self.fail_on == "copy" and cmd[:1] == ("cp",):
            return ExecResult(stdout=b"", stderr=b"copy failed", exit_code=1)
        return ExecResult(stdout=b"", stderr=b"", exit_code=0)


class _MetadataFailureSession(_RecordingSession):
    def __init__(
        self,
        manifest: Manifest | None = None,
        *,
        fail_commands: set[str],
    ) -> None:
        super().__init__(manifest)
        self.fail_commands = fail_commands

    async def _exec_internal(
        self,
        *command: str | Path,
        timeout: float | None = None,
    ) -> ExecResult:
        _ = timeout
        cmd = tuple(str(part) for part in command)
        self.exec_calls.append(cmd)
        if cmd and cmd[0] in self.fail_commands:
            return ExecResult(stdout=b"", stderr=b"metadata failed", exit_code=1)
        return ExecResult(stdout=b"", stderr=b"", exit_code=0)


def test_resolve_workspace_path_rejects_windows_drive_absolute_path() -> None:
    with pytest.raises(InvalidManifestPathError) as exc_info:
        resolve_workspace_path(
            Path("/workspace"),
            PureWindowsPath("C:/tmp/secret.txt"),
            allow_absolute_within_root=True,
        )

    assert str(exc_info.value) == "manifest path must be relative: C:/tmp/secret.txt"
    assert exc_info.value.context == {"rel": "C:/tmp/secret.txt", "reason": "absolute"}


def test_resolve_workspace_path_rejects_absolute_escape_after_normalization() -> None:
    with pytest.raises(InvalidManifestPathError) as exc_info:
        resolve_workspace_path(
            Path("/workspace"),
            "/workspace/../etc/passwd",
            allow_absolute_within_root=True,
        )

    assert str(exc_info.value) == "manifest path must be relative: /etc/passwd"
    assert exc_info.value.context == {"rel": "/etc/passwd", "reason": "absolute"}


def test_resolve_workspace_path_rejects_absolute_symlink_escape_for_host_root(
    tmp_path: Path,
) -> None:
    root = tmp_path / "workspace"
    outside = tmp_path / "outside"
    root.mkdir()
    outside.mkdir()
    link = root / "link"
    try:
        os.symlink(outside, link, target_is_directory=True)
    except (NotImplementedError, OSError) as exc:
        pytest.skip(f"symlink unavailable: {exc}")

    escaped = link / "secret.txt"

    with pytest.raises(InvalidManifestPathError) as exc_info:
        resolve_workspace_path(
            root,
            escaped,
            allow_absolute_within_root=True,
        )

    assert str(exc_info.value) == f"manifest path must be relative: {escaped.as_posix()}"
    assert exc_info.value.context == {"rel": escaped.as_posix(), "reason": "absolute"}


def _symlink_or_skip(path: Path, target: Path, *, target_is_directory: bool = False) -> None:
    try:
        path.symlink_to(target, target_is_directory=target_is_directory)
    except OSError as e:
        if os.name == "nt" and getattr(e, "winerror", None) == 1314:
            pytest.skip("symlink creation requires elevated privileges on Windows")
        raise


@pytest.mark.asyncio
async def test_base_sandbox_session_uses_current_working_directory_for_local_file_sources(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    source = tmp_path / "source.txt"
    source.write_text("hello", encoding="utf-8")
    monkeypatch.chdir(tmp_path)
    session = _RecordingSession(
        Manifest(
            entries={"copied.txt": LocalFile(src=Path("source.txt"))},
        ),
    )

    result = await session.apply_manifest()

    assert result.files[0].path == Path("/workspace/copied.txt")
    assert result.files[0].sha256 == hashlib.sha256(b"hello").hexdigest()
    assert session.writes[Path("/workspace/copied.txt")] == b"hello"


@pytest.mark.asyncio
async def test_local_file_rejects_absolute_source_outside_base_dir(tmp_path: Path) -> None:
    base = tmp_path / "base"
    outside = tmp_path / "outside"
    base.mkdir()
    outside.mkdir()
    (outside / "secret.txt").write_text("secret", encoding="utf-8")
    session = _RecordingSession()

    with pytest.raises(LocalFileReadError) as excinfo:
        await LocalFile(src=outside / "secret.txt").apply(
            session,
            Path("/workspace/copied.txt"),
            base,
        )

    assert excinfo.value.context["reason"] == "outside_base_dir"
    assert excinfo.value.context["base_dir"] == str(base)
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_file_rejects_relative_source_outside_base_dir(tmp_path: Path) -> None:
    base = tmp_path / "base"
    outside = tmp_path / "outside"
    base.mkdir()
    outside.mkdir()
    (outside / "secret.txt").write_text("secret", encoding="utf-8")
    session = _RecordingSession()

    with pytest.raises(LocalFileReadError) as excinfo:
        await LocalFile(src=Path("../outside/secret.txt")).apply(
            session,
            Path("/workspace/copied.txt"),
            base,
        )

    assert excinfo.value.context["reason"] == "outside_base_dir"
    assert excinfo.value.context["base_dir"] == str(base)
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_file_allows_extra_path_granted_source_outside_base_dir(
    tmp_path: Path,
) -> None:
    base = tmp_path / "base"
    outside = tmp_path / "outside"
    base.mkdir()
    outside.mkdir()
    (outside / "secret.txt").write_text("secret", encoding="utf-8")
    session = _RecordingSession(
        Manifest(extra_path_grants=(SandboxPathGrant(path=str(outside)),)),
    )

    result = await LocalFile(src=outside / "secret.txt").apply(
        session,
        Path("/workspace/copied.txt"),
        base,
    )

    assert result[0].path == Path("/workspace/copied.txt")
    assert session.writes[Path("/workspace/copied.txt")] == b"secret"


@pytest.mark.asyncio
async def test_local_file_rejects_source_outside_extra_path_grants(tmp_path: Path) -> None:
    base = tmp_path / "base"
    outside = tmp_path / "outside"
    other = tmp_path / "other"
    base.mkdir()
    outside.mkdir()
    other.mkdir()
    (outside / "secret.txt").write_text("secret", encoding="utf-8")
    session = _RecordingSession(
        Manifest(extra_path_grants=(SandboxPathGrant(path=str(other)),)),
    )

    with pytest.raises(LocalFileReadError) as excinfo:
        await LocalFile(src=outside / "secret.txt").apply(
            session,
            Path("/workspace/copied.txt"),
            base,
        )

    assert excinfo.value.context["reason"] == "outside_base_dir"
    assert excinfo.value.context["extra_path_grants"] == [str(other)]
    assert session.writes == {}


@pytest.mark.asyncio
async def test_serialized_manifest_extra_path_grant_allows_local_file_source(
    tmp_path: Path,
) -> None:
    base = tmp_path / "base"
    outside = tmp_path / "outside"
    base.mkdir()
    outside.mkdir()
    (outside / "secret.txt").write_text("secret", encoding="utf-8")
    manifest = Manifest.model_validate(
        {
            "extra_path_grants": [{"path": str(outside)}],
            "entries": {
                "copied.txt": {
                    "type": "local_file",
                    "src": str(outside / "secret.txt"),
                }
            },
        }
    )
    session = _RecordingSession(manifest)

    result = await session._apply_entry_batch(
        [(Path("/workspace/copied.txt"), manifest.entries["copied.txt"])],
        base_dir=base,
    )

    assert result[0].path == Path("/workspace/copied.txt")
    assert session.writes[Path("/workspace/copied.txt")] == b"secret"


@pytest.mark.asyncio
async def test_local_file_allows_absolute_source_inside_base_dir(tmp_path: Path) -> None:
    base = tmp_path / "base"
    source_dir = base / "source"
    source_dir.mkdir(parents=True)
    (source_dir / "safe.txt").write_text("safe", encoding="utf-8")
    session = _RecordingSession()

    result = await LocalFile(src=source_dir / "safe.txt").apply(
        session,
        Path("/workspace/copied.txt"),
        base,
    )

    assert result[0].path == Path("/workspace/copied.txt")
    assert session.writes[Path("/workspace/copied.txt")] == b"safe"


@pytest.mark.asyncio
async def test_local_file_rejects_symlinked_source_ancestors(tmp_path: Path) -> None:
    target_dir = tmp_path / "secret-dir"
    target_dir.mkdir()
    nested_dir = target_dir / "sub"
    nested_dir.mkdir()
    (nested_dir / "secret.txt").write_text("secret", encoding="utf-8")
    _symlink_or_skip(tmp_path / "link", target_dir, target_is_directory=True)
    session = _RecordingSession()

    with pytest.raises(LocalFileReadError) as excinfo:
        await LocalFile(src=Path("link/sub/secret.txt")).apply(
            session,
            Path("/workspace/copied.txt"),
            tmp_path,
        )

    assert excinfo.value.context["reason"] == "symlink_not_supported"
    assert excinfo.value.context["child"] == "link"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_file_rejects_symlinked_source_leaf(tmp_path: Path) -> None:
    secret = tmp_path / "secret.txt"
    secret.write_text("secret", encoding="utf-8")
    _symlink_or_skip(tmp_path / "link.txt", secret)
    session = _RecordingSession()

    with pytest.raises(LocalFileReadError) as excinfo:
        await LocalFile(src=Path("link.txt")).apply(
            session,
            Path("/workspace/copied.txt"),
            tmp_path,
        )

    assert excinfo.value.context["reason"] == "symlink_not_supported"
    assert excinfo.value.context["child"] == "link.txt"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_file_rejects_symlinked_source_before_checksum(tmp_path: Path) -> None:
    target_dir = tmp_path / "secret-dir"
    target_dir.mkdir()
    _symlink_or_skip(tmp_path / "link.txt", target_dir, target_is_directory=True)
    session = _RecordingSession()

    with pytest.raises(LocalFileReadError) as excinfo:
        await LocalFile(src=Path("link.txt")).apply(
            session,
            Path("/workspace/copied.txt"),
            tmp_path,
        )

    assert excinfo.value.context["reason"] == "symlink_not_supported"
    assert excinfo.value.context["child"] == "link.txt"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_dir_copy_falls_back_when_safe_dir_fd_open_unavailable(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    src_root = tmp_path / "src"
    src_root.mkdir()
    src_file = src_root / "safe.txt"
    src_file.write_text("safe", encoding="utf-8")
    session = _RecordingSession()
    local_dir = LocalDir(src=Path("src"))

    monkeypatch.setattr("agents.sandbox.entries.artifacts._OPEN_SUPPORTS_DIR_FD", False)
    monkeypatch.setattr("agents.sandbox.entries.artifacts._HAS_O_DIRECTORY", False)

    result = await local_dir._copy_local_dir_file(
        base_dir=tmp_path,
        session=session,
        src_root=src_root,
        src=src_file,
        dest_root=Path("/workspace/copied"),
    )

    assert result.path == Path("/workspace/copied/safe.txt")
    assert session.writes[Path("/workspace/copied/safe.txt")] == b"safe"


@pytest.mark.asyncio
async def test_local_dir_copy_revalidates_swapped_paths_during_open(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
        pytest.skip("safe dir_fd open pinning is unavailable on this platform")

    src_root = tmp_path / "src"
    src_root.mkdir()
    src_file = src_root / "safe.txt"
    src_file.write_text("safe", encoding="utf-8")
    secret = tmp_path / "secret.txt"
    secret.write_text("secret", encoding="utf-8")
    session = _RecordingSession()
    local_dir = LocalDir(src=Path("src"))
    original_open = os.open
    swapped = False

    def swap_then_open(
        path: str | Path,
        flags: int,
        mode: int = 0o777,
        *,
        dir_fd: int | None = None,
    ) -> int:
        nonlocal swapped
        if (path == "safe.txt" or Path(path) == src_file) and not swapped:
            src_file.unlink()
            _symlink_or_skip(src_file, secret)
            swapped = True
        if dir_fd is None:
            return original_open(path, flags, mode)
        return original_open(path, flags, mode, dir_fd=dir_fd)

    monkeypatch.setattr("agents.sandbox.entries.artifacts.os.open", swap_then_open)

    with pytest.raises(LocalDirReadError) as excinfo:
        await local_dir._copy_local_dir_file(
            base_dir=tmp_path,
            session=session,
            src_root=src_root,
            src=src_file,
            dest_root=Path("/workspace/copied"),
        )

    assert excinfo.value.context["reason"] in {
        "symlink_not_supported",
        "path_changed_during_copy",
    }
    assert excinfo.value.context["child"] == "safe.txt"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_dir_copy_pins_parent_directories_during_open(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
        pytest.skip("safe dir_fd open pinning is unavailable on this platform")

    src_root = tmp_path / "src"
    src_root.mkdir()
    nested_dir = src_root / "nested"
    nested_dir.mkdir()
    src_file = nested_dir / "safe.txt"
    src_file.write_text("safe", encoding="utf-8")
    secret_dir = tmp_path / "secret-dir"
    secret_dir.mkdir()
    (secret_dir / "safe.txt").write_text("secret", encoding="utf-8")
    session = _RecordingSession()
    local_dir = LocalDir(src=Path("src"))
    original_open = os.open
    swapped = False

    def swap_parent_then_open(
        path: str | Path,
        flags: int,
        mode: int = 0o777,
        *,
        dir_fd: int | None = None,
    ) -> int:
        nonlocal swapped
        if path == "safe.txt" and not swapped:
            (src_root / "nested").rename(src_root / "nested-original")
            _symlink_or_skip(src_root / "nested", secret_dir, target_is_directory=True)
            swapped = True
        if dir_fd is None:
            return original_open(path, flags, mode)
        return original_open(path, flags, mode, dir_fd=dir_fd)

    monkeypatch.setattr("agents.sandbox.entries.artifacts.os.open", swap_parent_then_open)

    result = await local_dir._copy_local_dir_file(
        base_dir=tmp_path,
        session=session,
        src_root=src_root,
        src=src_file,
        dest_root=Path("/workspace/copied"),
    )

    assert result.path == Path("/workspace/copied/nested/safe.txt")
    assert session.writes[Path("/workspace/copied/nested/safe.txt")] == b"safe"


@pytest.mark.asyncio
async def test_local_dir_copy_fallback_rejects_swapped_parent_directory(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    src_root = tmp_path / "src"
    src_root.mkdir()
    nested_dir = src_root / "nested"
    nested_dir.mkdir()
    src_file = nested_dir / "safe.txt"
    src_file.write_text("safe", encoding="utf-8")
    secret_dir = tmp_path / "secret-dir"
    secret_dir.mkdir()
    (secret_dir / "safe.txt").write_text("secret", encoding="utf-8")
    session = _RecordingSession()
    local_dir = LocalDir(src=Path("src"))
    original_open = os.open
    swapped = False

    monkeypatch.setattr("agents.sandbox.entries.artifacts._OPEN_SUPPORTS_DIR_FD", False)
    monkeypatch.setattr("agents.sandbox.entries.artifacts._HAS_O_DIRECTORY", False)

    def swap_parent_then_open(
        path: str | Path,
        flags: int,
        mode: int = 0o777,
        *,
        dir_fd: int | None = None,
    ) -> int:
        nonlocal swapped
        if Path(path) == src_file and not swapped:
            nested_dir.rename(src_root / "nested-original")
            _symlink_or_skip(src_root / "nested", secret_dir, target_is_directory=True)
            swapped = True
        if dir_fd is None:
            return original_open(path, flags, mode)
        return original_open(path, flags, mode, dir_fd=dir_fd)

    monkeypatch.setattr("agents.sandbox.entries.artifacts.os.open", swap_parent_then_open)

    with pytest.raises(LocalDirReadError) as excinfo:
        await local_dir._copy_local_dir_file(
            base_dir=tmp_path,
            session=session,
            src_root=src_root,
            src=src_file,
            dest_root=Path("/workspace/copied"),
        )

    assert excinfo.value.context["reason"] == "symlink_not_supported"
    assert excinfo.value.context["child"] == "src/nested"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_dir_apply_rejects_source_root_swapped_to_symlink_after_validation(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
        pytest.skip("safe dir_fd open pinning is unavailable on this platform")

    src_root = tmp_path / "src"
    src_root.mkdir()
    (src_root / "safe.txt").write_text("safe", encoding="utf-8")
    secret_dir = tmp_path / "secret-dir"
    secret_dir.mkdir()
    (secret_dir / "secret.txt").write_text("secret", encoding="utf-8")
    session = _RecordingSession()
    local_dir = LocalDir(src=Path("src"))
    original_open = os.open
    swapped = False

    def swap_root_then_open(
        path: str | Path,
        flags: int,
        mode: int = 0o777,
        *,
        dir_fd: int | None = None,
    ) -> int:
        nonlocal swapped
        if (path == "src" or Path(path) in {src_root, src_root / "safe.txt"}) and not swapped:
            src_root.rename(tmp_path / "src-original")
            (tmp_path / "src").symlink_to(secret_dir, target_is_directory=True)
            swapped = True
        if dir_fd is None:
            return original_open(path, flags, mode)
        return original_open(path, flags, mode, dir_fd=dir_fd)

    monkeypatch.setattr("agents.sandbox.entries.artifacts.os.open", swap_root_then_open)

    with pytest.raises(LocalDirReadError) as excinfo:
        await local_dir.apply(session, Path("/workspace/copied"), tmp_path)

    assert excinfo.value.context["reason"] == "symlink_not_supported"
    assert excinfo.value.context["child"] == "src"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_dir_apply_fallback_rejects_source_root_swapped_to_symlink_after_validation(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    src_root = tmp_path / "src"
    src_root.mkdir()
    (src_root / "safe.txt").write_text("safe", encoding="utf-8")
    secret_dir = tmp_path / "secret-dir"
    secret_dir.mkdir()
    session = _RecordingSession()
    local_dir = LocalDir(src=Path("src"))
    original_open = os.open
    swapped = False

    monkeypatch.setattr("agents.sandbox.entries.artifacts._OPEN_SUPPORTS_DIR_FD", False)
    monkeypatch.setattr("agents.sandbox.entries.artifacts._HAS_O_DIRECTORY", False)

    def swap_root_then_open(
        path: str | Path,
        flags: int,
        mode: int = 0o777,
        *,
        dir_fd: int | None = None,
    ) -> int:
        nonlocal swapped
        if Path(path) == src_root / "safe.txt" and not swapped:
            src_root.rename(tmp_path / "src-original")
            _symlink_or_skip(tmp_path / "src", secret_dir, target_is_directory=True)
            swapped = True
        if dir_fd is None:
            return original_open(path, flags, mode)
        return original_open(path, flags, mode, dir_fd=dir_fd)

    monkeypatch.setattr("agents.sandbox.entries.artifacts.os.open", swap_root_then_open)

    with pytest.raises(LocalDirReadError) as excinfo:
        await local_dir.apply(session, Path("/workspace/copied"), tmp_path)

    assert excinfo.value.context["reason"] == "symlink_not_supported"
    assert excinfo.value.context["child"] == "src"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_dir_apply_uses_configured_file_copy_fanout(
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    src_root = tmp_path / "src"
    src_root.mkdir()
    (src_root / "a.txt").write_text("a", encoding="utf-8")
    (src_root / "b.txt").write_text("b", encoding="utf-8")
    session = _RecordingSession()
    session._set_concurrency_limits(
        SandboxConcurrencyLimits(
            manifest_entries=4,
            local_dir_files=2,
        )
    )
    observed_limits: list[int | None] = []

    async def gather_with_limit_recording(
        task_factories: Sequence[Callable[[], Awaitable[MaterializedFile]]],
        *,
        max_concurrency: int | None = None,
    ) -> list[MaterializedFile]:
        observed_limits.append(max_concurrency)
        return [await factory() for factory in task_factories]

    monkeypatch.setattr(
        artifacts_module,
        "gather_in_order",
        gather_with_limit_recording,
    )

    result = await LocalDir(src=Path("src")).apply(
        session,
        Path("/workspace/copied"),
        tmp_path,
    )

    assert observed_limits == [2]
    assert sorted(file.path.as_posix() for file in result) == [
        "/workspace/copied/a.txt",
        "/workspace/copied/b.txt",
    ]
    assert session.writes == {
        Path("/workspace/copied/a.txt"): b"a",
        Path("/workspace/copied/b.txt"): b"b",
    }


@pytest.mark.asyncio
async def test_local_dir_rejects_absolute_source_outside_base_dir(tmp_path: Path) -> None:
    base = tmp_path / "base"
    outside = tmp_path / "outside"
    base.mkdir()
    outside.mkdir()
    (outside / "secret.txt").write_text("secret", encoding="utf-8")
    session = _RecordingSession()

    with pytest.raises(LocalDirReadError) as excinfo:
        await LocalDir(src=outside).apply(session, Path("/workspace/copied"), base)

    assert excinfo.value.context["reason"] == "outside_base_dir"
    assert excinfo.value.context["base_dir"] == str(base)
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_dir_rejects_relative_source_outside_base_dir(tmp_path: Path) -> None:
    base = tmp_path / "base"
    outside = tmp_path / "outside"
    base.mkdir()
    outside.mkdir()
    (outside / "secret.txt").write_text("secret", encoding="utf-8")
    session = _RecordingSession()

    with pytest.raises(LocalDirReadError) as excinfo:
        await LocalDir(src=Path("../outside")).apply(session, Path("/workspace/copied"), base)

    assert excinfo.value.context["reason"] == "outside_base_dir"
    assert excinfo.value.context["base_dir"] == str(base)
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_dir_allows_extra_path_granted_source_outside_base_dir(
    tmp_path: Path,
) -> None:
    base = tmp_path / "base"
    outside = tmp_path / "outside"
    base.mkdir()
    outside.mkdir()
    (outside / "secret.txt").write_text("secret", encoding="utf-8")
    session = _RecordingSession(
        Manifest(extra_path_grants=(SandboxPathGrant(path=str(outside), read_only=True),)),
    )

    result = await LocalDir(src=outside).apply(
        session,
        Path("/workspace/copied"),
        base,
    )

    assert result[0].path == Path("/workspace/copied/secret.txt")
    assert session.writes[Path("/workspace/copied/secret.txt")] == b"secret"


@pytest.mark.asyncio
async def test_local_dir_allows_absolute_source_inside_base_dir(tmp_path: Path) -> None:
    base = tmp_path / "base"
    source = base / "source"
    source.mkdir(parents=True)
    (source / "safe.txt").write_text("safe", encoding="utf-8")
    session = _RecordingSession()

    result = await LocalDir(src=source).apply(session, Path("/workspace/copied"), base)

    assert result[0].path == Path("/workspace/copied/safe.txt")
    assert session.writes[Path("/workspace/copied/safe.txt")] == b"safe"


@pytest.mark.asyncio
async def test_local_dir_rejects_symlinked_source_ancestors(tmp_path: Path) -> None:
    target_dir = tmp_path / "secret-dir"
    target_dir.mkdir()
    nested_dir = target_dir / "sub"
    nested_dir.mkdir()
    (nested_dir / "secret.txt").write_text("secret", encoding="utf-8")
    _symlink_or_skip(tmp_path / "link", target_dir, target_is_directory=True)
    session = _RecordingSession()

    with pytest.raises(LocalDirReadError) as excinfo:
        await LocalDir(src=Path("link/sub")).apply(session, Path("/workspace/copied"), tmp_path)

    assert excinfo.value.context["reason"] == "symlink_not_supported"
    assert excinfo.value.context["child"] == "link"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_dir_rejects_symlinked_source_root(tmp_path: Path) -> None:
    target_dir = tmp_path / "secret-dir"
    target_dir.mkdir()
    (target_dir / "secret.txt").write_text("secret", encoding="utf-8")
    _symlink_or_skip(tmp_path / "src", target_dir, target_is_directory=True)
    session = _RecordingSession()

    with pytest.raises(LocalDirReadError) as excinfo:
        await LocalDir(src=Path("src")).apply(session, Path("/workspace/copied"), tmp_path)

    assert excinfo.value.context["reason"] == "symlink_not_supported"
    assert excinfo.value.context["child"] == "src"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_dir_rejects_symlinked_files(tmp_path: Path) -> None:
    src_root = tmp_path / "src"
    src_root.mkdir()
    (src_root / "safe.txt").write_text("safe", encoding="utf-8")
    secret = tmp_path / "secret.txt"
    secret.write_text("secret", encoding="utf-8")
    _symlink_or_skip(src_root / "link.txt", secret)
    session = _RecordingSession()

    with pytest.raises(LocalDirReadError) as excinfo:
        await LocalDir(src=Path("src")).apply(session, Path("/workspace/copied"), tmp_path)

    assert excinfo.value.context["reason"] == "symlink_not_supported"
    assert excinfo.value.context["child"] == "link.txt"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_local_dir_rejects_symlinked_directories(tmp_path: Path) -> None:
    src_root = tmp_path / "src"
    src_root.mkdir()
    (src_root / "safe.txt").write_text("safe", encoding="utf-8")
    target_dir = tmp_path / "secret-dir"
    target_dir.mkdir()
    (target_dir / "secret.txt").write_text("secret", encoding="utf-8")
    _symlink_or_skip(src_root / "linked-dir", target_dir, target_is_directory=True)
    session = _RecordingSession()

    with pytest.raises(LocalDirReadError) as excinfo:
        await LocalDir(src=Path("src")).apply(session, Path("/workspace/copied"), tmp_path)

    assert excinfo.value.context["reason"] == "symlink_not_supported"
    assert excinfo.value.context["child"] == "linked-dir"
    assert session.writes == {}


@pytest.mark.asyncio
async def test_git_repo_uses_fetch_checkout_path_for_commit_refs() -> None:
    session = _GitRefSession()
    repo = GitRepo(repo="openai/example", ref="deadbeef")

    await repo.apply(session, Path("/workspace/repo"), Path("/ignored"))

    assert not any(call[:2] == ("git", "clone") for call in session.exec_calls)
    assert any(call[:2] == ("git", "init") for call in session.exec_calls)
    assert any(
        len(call) >= 7
        and call[:2] == ("git", "-C")
        and call[3:6] == ("remote", "add", "origin")
        and call[6] == "https://github.com/openai/example.git"
        for call in session.exec_calls
    )
    assert any(
        len(call) >= 9
        and call[:2] == ("git", "-C")
        and call[3:7] == ("fetch", "--depth", "1", "--no-tags")
        and call[-2:] == ("origin", "deadbeef")
        for call in session.exec_calls
    )
    assert any(
        len(call) >= 6
        and call[:2] == ("git", "-C")
        and call[3:5] == ("checkout", "--detach")
        and call[-1] == "FETCH_HEAD"
        for call in session.exec_calls
    )


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("subpath", "reason"),
    [
        ("   ", "empty"),
        ("/docs", "absolute"),
        ("../outside", "parent_traversal"),
        ("docs/../../outside", "parent_traversal"),
        ("C:/repo", "windows_path"),
        ("docs\\outside", "windows_path"),
    ],
)
async def test_git_repo_rejects_invalid_subpath_before_copy(
    subpath: str,
    reason: str,
) -> None:
    session = _GitFailureSession(fail_on="clone")
    repo = GitRepo(repo="openai/example", ref="main", subpath=subpath)

    with pytest.raises(GitSubpathError) as excinfo:
        await repo.apply(session, Path("/workspace/repo"), Path("/ignored"))

    assert excinfo.value.context["reason"] == reason
    assert excinfo.value.context["subpath"] == subpath
    assert session.exec_calls == []


@pytest.mark.asyncio
@pytest.mark.parametrize("subpath", ["", ".", "./", "./.", " ./ "])
async def test_git_repo_root_subpath_alias_copies_repo_root(subpath: str) -> None:
    session = _RecordingSession()
    repo = GitRepo(repo="openai/example", ref="main", subpath=subpath)

    await repo.apply(session, Path("/workspace/repo"), Path("/ignored"))

    copy_call = next(call for call in session.exec_calls if call[:1] == ("cp",))
    assert copy_call[3].startswith("/tmp/sandbox-git-")
    assert copy_call[3].endswith("/.")
    assert not copy_call[3].endswith("//.")
    assert copy_call[4].replace("\\", "/") == "/workspace/repo/"


@pytest.mark.asyncio
async def test_git_repo_allows_relative_subpath_copy() -> None:
    session = _RecordingSession()
    repo = GitRepo(repo="openai/example", ref="main", subpath="docs/reference")

    await repo.apply(session, Path("/workspace/repo"), Path("/ignored"))

    copy_call = next(call for call in session.exec_calls if call[:1] == ("cp",))
    assert copy_call[3].endswith("/docs/reference/.")
    assert copy_call[4].replace("\\", "/") == "/workspace/repo/"


def _git_temp_cleanup_calls(session: _RecordingSession) -> list[tuple[str, ...]]:
    return [call for call in session.exec_calls if call[:3] == ("rm", "-rf", "--")]


def _git_temp_cleanup_call_indices(session: _RecordingSession) -> list[int]:
    return [i for i, call in enumerate(session.exec_calls) if call[:3] == ("rm", "-rf", "--")]


@pytest.mark.asyncio
async def test_git_repo_cleans_temp_clone_after_copy_failure() -> None:
    session = _GitFailureSession(fail_on="copy")
    repo = GitRepo(repo="openai/example", ref="main")

    with pytest.raises(GitCopyError):
        await repo.apply(session, Path("/workspace/repo"), Path("/ignored"))

    cleanup_calls = _git_temp_cleanup_calls(session)
    cleanup_indices = _git_temp_cleanup_call_indices(session)
    assert len(cleanup_calls) == 2
    assert cleanup_calls[0] == cleanup_calls[1]
    assert cleanup_indices[1] > next(
        i for i, call in enumerate(session.exec_calls) if call[:1] == ("cp",)
    )


@pytest.mark.asyncio
async def test_git_repo_cleans_temp_clone_after_clone_failure() -> None:
    session = _GitFailureSession(fail_on="clone")
    repo = GitRepo(repo="openai/example", ref="main")

    with pytest.raises(GitCloneError):
        await repo.apply(session, Path("/workspace/repo"), Path("/ignored"))

    cleanup_calls = _git_temp_cleanup_calls(session)
    cleanup_indices = _git_temp_cleanup_call_indices(session)
    assert len(cleanup_calls) == 2
    assert cleanup_calls[0] == cleanup_calls[1]
    assert cleanup_indices[1] > next(
        i for i, call in enumerate(session.exec_calls) if call[:2] == ("git", "clone")
    )


@pytest.mark.asyncio
async def test_dir_metadata_strips_file_type_bits_before_chmod() -> None:
    session = _RecordingSession()
    dest = Path("/workspace/dir")

    await Dir()._apply_metadata(session, dest)

    assert ("chmod", "0755", "/workspace/dir") in session.exec_calls


@pytest.mark.asyncio
async def test_apply_manifest_raises_on_chmod_failure() -> None:
    session = _MetadataFailureSession(
        Manifest(entries={"copied.txt": File(content=b"hello")}),
        fail_commands={"chmod"},
    )

    with pytest.raises(ExecNonZeroError):
        await session.apply_manifest()


@pytest.mark.asyncio
async def test_apply_manifest_raises_on_chgrp_failure() -> None:
    session = _MetadataFailureSession(
        Manifest(
            entries={
                "copied.txt": File(
                    content=b"hello",
                    group=User(name="sandbox-user"),
                )
            }
        ),
        fail_commands={"chgrp"},
    )

    with pytest.raises(ExecNonZeroError):
        await session.apply_manifest()

    assert ("chgrp", "sandbox-user", "/workspace/copied.txt") in session.exec_calls
    assert not any(call[0] == "chmod" for call in session.exec_calls)
