# SPDX-FileCopyrightText: 2025 CoreWeave, Inc.
# SPDX-License-Identifier: Apache-2.0
# SPDX-PackageName: cwsandbox-client

"""Integration tests for cwsandbox._sandbox module.

These tests require a running CWSandbox backend.
Set CWSANDBOX_BASE_URL and CWSANDBOX_API_KEY environment variables before running.
"""

import time
import uuid

import httpx
import pytest

from cwsandbox import NetworkOptions, Sandbox, SandboxDefaults, Session
from tests.integration.cwsandbox.conftest import _SESSION_TAG


def test_sandbox_lifecycle(sandbox_defaults: SandboxDefaults) -> None:
    """Test basic sandbox lifecycle: create, exec, stop."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        assert sandbox.sandbox_id is not None

        result = sandbox.exec(["echo", "hello"]).result()

        assert result.returncode == 0
        assert result.stdout.strip() == "hello"


def test_sandbox_run_factory(sandbox_defaults: SandboxDefaults) -> None:
    """Test Sandbox.run factory method returns sandbox with ID and reaches running state."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        assert sandbox.sandbox_id is not None
        sandbox.wait()
        assert sandbox.status == "running"


def test_sandbox_run_factory_no_defaults() -> None:
    """Test Sandbox.run factory method without defaults - exercises default config path."""
    sandbox = Sandbox.run("echo", "hello", "world", tags=[_SESSION_TAG])

    try:
        assert sandbox.sandbox_id is not None
    finally:
        sandbox.stop().result()


def test_sandbox_no_defaults_max_lifetime_only() -> None:
    """Test sandbox creation with only max_lifetime_seconds configured."""
    defaults = SandboxDefaults(max_lifetime_seconds=60)
    sandbox = Sandbox.run("echo", "hello", "world", defaults=defaults, tags=[_SESSION_TAG])

    try:
        assert sandbox.sandbox_id is not None
    finally:
        sandbox.stop().result()


def test_sandbox_no_defaults_tags_only() -> None:
    """Test sandbox creation with only tags configured."""
    defaults = SandboxDefaults(tags=("isolation-test", _SESSION_TAG))
    sandbox = Sandbox.run("echo", "hello", "world", defaults=defaults)

    try:
        assert sandbox.sandbox_id is not None
    finally:
        sandbox.stop().result()


def test_sandbox_no_defaults_sleep_command() -> None:
    """Test sandbox creation with sleep infinity command and no defaults."""
    sandbox = Sandbox.run("sleep", "infinity", tags=[_SESSION_TAG])

    try:
        assert sandbox.sandbox_id is not None
    finally:
        sandbox.stop().result()


def test_sandbox_file_operations(sandbox_defaults: SandboxDefaults) -> None:
    """Test sandbox file read/write operations."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        test_content = b"Hello, World!"
        filepath = f"/tmp/test_file_{uuid.uuid4().hex}.txt"

        sandbox.write_file(filepath, test_content).result()

        content = sandbox.read_file(filepath).result()
        assert content == test_content


def test_sandbox_with_defaults() -> None:
    """Test sandbox creation with SandboxDefaults."""
    defaults = SandboxDefaults(
        max_lifetime_seconds=60,
        tags=("test-integration",),
        resources={"cpu": "500m", "memory": "256Mi"},
    )

    with Sandbox.run("sleep", "infinity", defaults=defaults) as sandbox:
        assert sandbox.sandbox_id is not None

        result = sandbox.exec(["python", "--version"]).result()
        assert result.returncode == 0
        assert "Python 3.11" in result.stdout


def test_sandbox_python_exec(sandbox_defaults: SandboxDefaults) -> None:
    """Test executing Python code in sandbox."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        result = sandbox.exec(["python", "-c", "print(2 + 2)"]).result()

        assert result.returncode == 0
        assert result.stdout.strip() == "4"


def test_sandbox_exec_check_raises_on_failure(sandbox_defaults: SandboxDefaults) -> None:
    """Test exec(check=True) raises SandboxExecutionError on non-zero exit."""
    from cwsandbox.exceptions import SandboxExecutionError

    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        with pytest.raises(SandboxExecutionError) as exc_info:
            sandbox.exec(["sh", "-c", "exit 42"], check=True).result()

        assert exc_info.value.exec_result is not None
        assert exc_info.value.exec_result.returncode == 42


def test_sandbox_exec_captures_stderr(sandbox_defaults: SandboxDefaults) -> None:
    """Test stderr is properly captured from commands."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        result = sandbox.exec(["sh", "-c", "echo error_output >&2"]).result()

        assert "error_output" in result.stderr


def test_sandbox_exec_check_false_returns_result(sandbox_defaults: SandboxDefaults) -> None:
    """Test exec(check=False) returns result even on non-zero exit."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        result = sandbox.exec(["sh", "-c", "exit 1"]).result()

        assert result.returncode == 1


def test_sandbox_read_nonexistent_file(sandbox_defaults: SandboxDefaults) -> None:
    """Test read_file raises SandboxFileError for missing files."""
    from cwsandbox.exceptions import SandboxFileError

    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        with pytest.raises(SandboxFileError) as exc_info:
            sandbox.read_file("/nonexistent/path/to/file.txt").result()

        assert exc_info.value.filepath == "/nonexistent/path/to/file.txt"


def test_sandbox_file_operations_binary(sandbox_defaults: SandboxDefaults) -> None:
    """Test reading/writing binary content (non-UTF8)."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        binary_content = bytes(range(256))
        filepath = f"/tmp/binary_test_{uuid.uuid4().hex}.bin"

        sandbox.write_file(filepath, binary_content).result()
        content = sandbox.read_file(filepath).result()

        assert content == binary_content


# Streaming exec tests (regression coverage for queue-decoupled pattern fix)


def test_sandbox_exec_streaming_basic(sandbox_defaults: SandboxDefaults) -> None:
    """Test basic streaming exec with sync iteration."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["echo", "hello"])  # stream=True default
        lines = list(process.stdout)
        result = process.result()

        assert lines == ["hello\n"]
        assert result.returncode == 0
        assert result.stdout == "hello\n"


def test_sandbox_exec_streaming_stderr(sandbox_defaults: SandboxDefaults) -> None:
    """Test streaming exec captures both stdout and stderr."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        # Command producing both stdout and stderr
        process = sandbox.exec(["sh", "-c", "echo stdout_line && echo stderr_line >&2"])

        stdout_lines = list(process.stdout)
        stderr_lines = list(process.stderr)
        result = process.result()

        assert stdout_lines == ["stdout_line\n"]
        assert stderr_lines == ["stderr_line\n"]
        assert result.returncode == 0
        assert result.stdout == "stdout_line\n"
        assert result.stderr == "stderr_line\n"


def test_sandbox_exec_streaming_returncode_lifecycle(
    sandbox_defaults: SandboxDefaults,
) -> None:
    """Test returncode and poll() lifecycle during streaming."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["sh", "-c", "echo line1; echo line2"])

        # Before iteration completes, returncode should be None
        assert process.returncode is None

        # Iterate through stdout (output may arrive in chunks, not line-by-line)
        chunks = list(process.stdout)
        combined = "".join(chunks)
        assert "line1" in combined
        assert "line2" in combined

        # After result(), returncode should be populated
        result = process.result()
        assert process.returncode == 0
        assert result.returncode == 0


def test_sandbox_exec_streaming_poll_wait(sandbox_defaults: SandboxDefaults) -> None:
    """Test poll() and wait() methods on streaming process."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["echo", "test"])

        # Consume the stream first to let process complete
        _ = list(process.stdout)

        # After stream consumption, wait should return exit code
        exit_code = process.wait(timeout=5.0)
        assert exit_code == 0

        # poll() should also return exit code after completion
        assert process.poll() == 0


def test_sandbox_exec_streaming_large_output(sandbox_defaults: SandboxDefaults) -> None:
    """Test streaming exec with large output.

    Generates 2000 lines to validate streaming handles large outputs correctly.
    Critical regression test for commit 2ef1acb queue-decoupled pattern fix.

    Note: Streaming delivers data in chunks (not individual lines). The test
    validates all content arrives correctly by checking the combined output.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(
            ["python", "-c", "for i in range(2000): print(f'line_{i}')"],
            timeout_seconds=30.0,
        )

        chunks = list(process.stdout)
        result = process.result()

        # Combine all chunks and split by newlines to count actual lines
        combined = "".join(chunks)
        lines = [line for line in combined.split("\n") if line]

        assert len(lines) == 2000, f"Expected 2000 lines, got {len(lines)}"
        assert lines[0] == "line_0"
        assert lines[-1] == "line_1999"
        assert result.returncode == 0


def test_sandbox_exec_streaming_live_iteration(sandbox_defaults: SandboxDefaults) -> None:
    """Test lines arrive incrementally while command is running (proves true streaming).

    Uses slow-producing command and records timestamps for each line to verify
    that lines arrive spread out over time (streaming) vs all at once (buffered).
    """
    import time

    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        # Command takes ~2 seconds total (10 lines x 0.2s sleep)
        process = sandbox.exec(
            ["bash", "-c", "for i in {1..10}; do echo line$i; sleep 0.2; done"],
            timeout_seconds=30.0,
        )

        line_times: list[float] = []
        lines: list[str] = []
        for line in process.stdout:
            line_times.append(time.time())
            lines.append(line)

        result = process.result()

        # Verify we got all lines
        assert len(lines) == 10
        assert lines[0] == "line1\n"
        assert lines[-1] == "line10\n"
        assert result.returncode == 0

        # Streaming proof: lines should arrive spread out over time, not all at once.
        # If buffered, all 10 lines would arrive within milliseconds of each other.
        # If streaming, lines arrive ~0.2s apart as the command produces them.
        # We check that the time spread from first to last line is significant.
        first_to_last_spread = line_times[-1] - line_times[0]

        # Command produces lines over ~2s (10 x 0.2s). With streaming, spread should
        # be at least 1s. With buffering, spread would be nearly 0.
        assert first_to_last_spread > 1.0, (
            f"Lines arrived over only {first_to_last_spread:.2f}s spread - "
            "expected incremental delivery proving true streaming"
        )


def test_sandbox_exec_streaming_check_raises(sandbox_defaults: SandboxDefaults) -> None:
    """Test check=True raises SandboxExecutionError after stream completes."""
    from cwsandbox.exceptions import SandboxExecutionError

    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(
            ["sh", "-c", "echo 'output before exit' && exit 42"],
            check=True,
        )

        # Consume the stream - should complete without error
        _ = list(process.stdout)

        # Error should be raised when getting result with check=True
        with pytest.raises(SandboxExecutionError) as exc_info:
            process.result()

        # Verify exception contains captured output
        assert exc_info.value.exec_result is not None
        assert exc_info.value.exec_result.returncode == 42
        assert "output before exit" in exc_info.value.exec_result.stdout
        assert exc_info.value.exec_result.command == [
            "sh",
            "-c",
            "echo 'output before exit' && exit 42",
        ]


def test_sandbox_environment_variables(sandbox_defaults: SandboxDefaults) -> None:
    """Test environment variables are passed into the sandbox."""
    defaults = sandbox_defaults.with_overrides(
        tags=sandbox_defaults.merge_tags(["env-vars"]),
        environment_variables={
            "PROJECT_ID": "test-project-123",
            "LOG_LEVEL": "info",
        },
    )

    with Session(defaults) as session:
        # Test 1: Environment variables in sandbox exec
        with session.sandbox(
            command="sleep",
            args=["infinity"],
            environment_variables={
                "MODEL_NAME": "resnet50",
                "LOG_LEVEL": "debug",  # Override session default
            },
        ) as sandbox:
            result = sandbox.exec(
                [
                    "python",
                    "-c",
                    "import os; "
                    "print(os.environ.get('PROJECT_ID')); "
                    "print(os.environ.get('LOG_LEVEL')); "
                    "print(os.environ.get('MODEL_NAME')); ",
                ]
            ).result()

            assert result.returncode == 0
            lines = result.stdout.strip().split("\n")
            # Session defaults are inherited
            assert lines[0] == "test-project-123"
            # Session default is overridden
            assert lines[1] == "debug"
            # Task-specific environment variables are added
            assert lines[2] == "resnet50"


def test_function_environment_variables(sandbox_defaults: SandboxDefaults) -> None:
    """Test environment variables are passed into the function."""
    defaults = sandbox_defaults.with_overrides(
        tags=sandbox_defaults.merge_tags(["env-vars"]),
        environment_variables={
            "PROJECT_ID": "test-project-123",
            "LOG_LEVEL": "info",
        },
    )
    with Session(defaults) as session:
        additional_environment_variables = {
            "MODEL_VERSION": "v2.0",
            "LOG_LEVEL": "debug",
        }

        @session.function(environment_variables=additional_environment_variables)
        def process_request(task_id: int) -> dict:
            import os

            return {
                "task_id": task_id,
                "project": os.environ.get("PROJECT_ID"),
                "version": os.environ.get("MODEL_VERSION"),
                "log_level": os.environ.get("LOG_LEVEL"),
            }

        # Test 1: Function inherits session defaults and adds/overrides env vars
        result = process_request.remote(42).result()

        assert result == {
            "task_id": 42,
            "project": "test-project-123",  # From session defaults
            "version": "v2.0",  # From function decorator
            "log_level": "debug",  # Overridden by function decorator
        }

        # Test 2: Verify that mutations to the environment variables are reflected
        # in subsequent function calls
        additional_environment_variables["MODEL_VERSION"] = "v3.0"

        result = process_request.remote(43).result()

        assert result == {
            "task_id": 43,
            "project": "test-project-123",
            "version": "v3.0",  # Mutated value
            "log_level": "debug",
        }


# Async context manager tests


@pytest.mark.asyncio
async def test_sandbox_async_context_manager(sandbox_defaults: SandboxDefaults) -> None:
    """Test async context manager for Sandbox.

    Verifies that 'async with Sandbox.run(...)' properly:
    1. Creates and starts the sandbox
    2. Allows await on exec() and file operations
    3. Properly cleans up (stops sandbox) on exit

    This is a regression test for event loop routing bugs where __aexit__
    directly awaited _stop_async() instead of routing through _LoopManager.
    """
    async with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        assert sandbox.sandbox_id is not None

        # Use await pattern (not .result())
        result = await sandbox.exec(["echo", "async context manager"])
        assert result.returncode == 0
        assert result.stdout.strip() == "async context manager"

        # Verify file operations also work with await
        await sandbox.write_file("/tmp/async_test.txt", b"async content")
        content = await sandbox.read_file("/tmp/async_test.txt")
        assert content == b"async content"

    # After exiting, sandbox should be stopped
    # (sandbox._is_done should be True, but we can't easily verify this externally)


# Infrastructure filtering tests (runway_ids, tower_ids)


def test_sandbox_with_runway_and_tower_ids(sandbox_defaults: SandboxDefaults) -> None:
    """Test sandbox creation with specific runway_ids and tower_ids.

    Creates a sandbox to discover valid runway/tower IDs, then creates another
    sandbox targeting those specific IDs to verify the parameters work.
    """
    # First, create a sandbox to discover valid infrastructure IDs
    with Sandbox.run(defaults=sandbox_defaults) as discovery_sandbox:
        discovery_sandbox.wait()
        discovered_runway_id = discovery_sandbox.runway_id
        discovered_tower_id = discovery_sandbox.tower_id

        assert discovered_runway_id is not None, "Discovery sandbox should have runway_id"
        assert discovered_tower_id is not None, "Discovery sandbox should have tower_id"

        # Create a second sandbox targeting those specific IDs while first is still running
        with Sandbox.run(
            runway_ids=[discovered_runway_id],
            tower_ids=[discovered_tower_id],
            defaults=sandbox_defaults,
        ) as targeted_sandbox:
            targeted_sandbox.wait()

            # Verify sandbox started successfully on the targeted infrastructure
            assert targeted_sandbox.sandbox_id is not None
            assert targeted_sandbox.status == "running"

            # The sandbox should land on the specified runway/tower
            assert targeted_sandbox.runway_id is not None
            assert targeted_sandbox.tower_id is not None


def test_sandbox_with_empty_runway_and_tower_ids(sandbox_defaults: SandboxDefaults) -> None:
    """Test sandbox creation with empty runway_ids and tower_ids lists.

    Verifies that empty lists are accepted (clearing any defaults).
    """
    with Sandbox.run(
        runway_ids=[],
        tower_ids=[],
        defaults=sandbox_defaults,
    ) as sandbox:
        sandbox.wait()
        assert sandbox.sandbox_id is not None
        assert sandbox.status == "running"


# Service address and exposed ports tests


def test_sandbox_with_network_options(sandbox_defaults: SandboxDefaults) -> None:
    """Test sandbox creation with NetworkOptions.

    Creates a sandbox with typed NetworkOptions for network configuration
    and verifies that applied_* modes are captured from the backend response.
    """
    network = NetworkOptions(
        ingress_mode="public",
        exposed_ports=(8080,),
        egress_mode="internet",
    )

    with Sandbox.run(
        "sleep",
        "infinity",
        defaults=sandbox_defaults,
        network=network,
    ) as sandbox:
        sandbox.wait()

        # Verify applied modes are captured from StartSandboxResponse
        assert sandbox.applied_ingress_mode is not None
        assert sandbox.applied_egress_mode is not None

        # Verify service_address is set for public ingress
        # (may be None depending on infrastructure configuration)
        if sandbox.service_address is not None:
            assert ":" in sandbox.service_address or "." in sandbox.service_address


def test_sandbox_public_service_address(sandbox_defaults: SandboxDefaults) -> None:
    """Test sandbox with public ingress returns service_address.

    Creates a sandbox with NetworkOptions for public ingress and verifies that
    service_address is populated in the response.
    """
    network = NetworkOptions(ingress_mode="public", exposed_ports=(8080,))

    with Sandbox.run(
        defaults=sandbox_defaults,
        network=network,
    ) as sandbox:
        sandbox.wait()

        # service_address comes from StartSandboxResponse
        # It may be None if the tower uses ClusterIP instead of LoadBalancer
        if sandbox.service_address is not None:
            # Address should look like "ip:port" or hostname format
            assert ":" in sandbox.service_address or "." in sandbox.service_address
        else:
            # If service_address is None, that's acceptable - infrastructure dependent
            # Just verify the sandbox is running and the property exists
            assert sandbox.status == "running"


def test_sandbox_public_exposed_ports(sandbox_defaults: SandboxDefaults) -> None:
    """Test sandbox with public ingress and ports returns exposed_ports.

    Creates a sandbox with NetworkOptions for public ingress and port mappings,
    then verifies exposed_ports is populated.
    """
    network = NetworkOptions(ingress_mode="public", exposed_ports=(8080,))

    with Sandbox.run(
        ports=[{"container_port": 8080, "name": "http"}],
        defaults=sandbox_defaults,
        network=network,
    ) as sandbox:
        sandbox.wait()

        # exposed_ports comes from StartSandboxResponse
        # It may be None depending on infrastructure configuration
        if sandbox.exposed_ports is not None:
            assert len(sandbox.exposed_ports) >= 1
            # Each entry should be (port, name) tuple
            port, name = sandbox.exposed_ports[0]
            assert isinstance(port, int)
            assert isinstance(name, str)
        else:
            # If exposed_ports is None, verify sandbox is running
            assert sandbox.status == "running"


def test_sandbox_public_service_connectivity(sandbox_defaults: SandboxDefaults) -> None:
    """Test that exposed service is actually reachable.

    Starts a simple HTTP server inside the sandbox and verifies we can
    connect to it from outside using the service_address.
    """
    network = NetworkOptions(ingress_mode="public", exposed_ports=(8080,))

    with Sandbox.run(
        "python",
        "-m",
        "http.server",
        "8080",
        ports=[{"container_port": 8080, "name": "http"}],
        defaults=sandbox_defaults,
        network=network,
    ) as sandbox:
        sandbox.wait()

        if sandbox.service_address is None:
            pytest.skip("Infrastructure does not provide service_address")

        # Give the HTTP server a moment to start
        time.sleep(2)

        response = httpx.get(
            f"http://{sandbox.service_address}/",
            timeout=120.0,
        )
        assert response.status_code == 200


# Stdin streaming tests


def test_sandbox_exec_stdin_basic(sandbox_defaults: SandboxDefaults) -> None:
    """Test basic stdin streaming with sh script execution.

    Uses sh to execute commands from stdin, which is a reliable pattern.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["sh"], stdin=True, timeout_seconds=30.0)
        assert process.stdin is not None

        # Send a simple script that echoes and exits
        script = 'echo "hello world"\nexit 0\n'
        process.stdin.write(script.encode()).result()
        process.stdin.close().result()

        result = process.result()
        assert "hello world" in result.stdout
        assert result.returncode == 0


def test_sandbox_exec_stdin_multiple_writes(sandbox_defaults: SandboxDefaults) -> None:
    """Test multiple stdin writes before closing.

    Uses sh with a script that echoes each received line.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["sh"], stdin=True, timeout_seconds=30.0)
        assert process.stdin is not None

        # Send a script that reads and counts lines
        script = """
echo "line1"
echo "line2"
echo "line3"
exit 0
"""
        # Send as multiple writes to test that pattern
        lines = script.split("\n")
        for line in lines:
            process.stdin.write((line + "\n").encode()).result()
        process.stdin.close().result()

        result = process.result()
        assert "line1" in result.stdout
        assert "line2" in result.stdout
        assert "line3" in result.stdout
        assert result.returncode == 0


def test_sandbox_exec_stdin_writeline(sandbox_defaults: SandboxDefaults) -> None:
    """Test writeline convenience method.

    Uses sh to execute commands sent via writeline.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["sh"], stdin=True, timeout_seconds=30.0)
        assert process.stdin is not None

        process.stdin.writeline('echo "first"').result()
        process.stdin.writeline('echo "second"').result()
        process.stdin.writeline("exit 0").result()
        process.stdin.close().result()

        result = process.result()
        assert "first" in result.stdout
        assert "second" in result.stdout
        assert result.returncode == 0


def test_sandbox_exec_stdin_large_input(sandbox_defaults: SandboxDefaults) -> None:
    """Test large stdin input that triggers chunking (>64KB).

    The implementation uses 64KB chunks for stdin writes. This test verifies
    that data larger than a single chunk is correctly transmitted.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        # Create 200KB of data to exceed the 64KB chunk size
        large_data = b"x" * (200 * 1024)

        process = sandbox.exec(["wc", "-c"], stdin=True, timeout_seconds=60.0)
        assert process.stdin is not None

        process.stdin.write(large_data).result()
        process.stdin.close().result()

        result = process.result()
        # wc -c outputs byte count
        byte_count = int(result.stdout.strip())
        assert byte_count == len(large_data)
        assert result.returncode == 0


def test_sandbox_exec_stdin_python_interactive(sandbox_defaults: SandboxDefaults) -> None:
    """Test Python script execution via stdin.

    Uses sh to pipe a Python script, which is a reliable pattern.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["sh"], stdin=True, timeout_seconds=30.0)
        assert process.stdin is not None

        # Send a shell script that runs Python
        script = """
python -u -c "result = 2 + 3; print(f'answer={result}')"
exit 0
"""
        process.stdin.write(script.encode()).result()
        process.stdin.close().result()

        result = process.result()
        assert "answer=5" in result.stdout
        assert result.returncode == 0


def test_sandbox_exec_stdin_shell_interaction(sandbox_defaults: SandboxDefaults) -> None:
    """Test basic shell interaction pattern via stdin.

    Uses a single multi-command echo to avoid timing issues with separate writes.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        # Use sh -c with a script to avoid timing issues
        process = sandbox.exec(["sh"], stdin=True, timeout_seconds=30.0)
        assert process.stdin is not None

        # Send all commands as a single script to avoid race conditions
        script = """
echo "hello"
echo "world"
exit 0
"""
        process.stdin.write(script.encode()).result()
        process.stdin.close().result()

        result = process.result()
        assert "hello" in result.stdout
        assert "world" in result.stdout
        assert result.returncode == 0


def test_sandbox_exec_stdin_close_idempotent(sandbox_defaults: SandboxDefaults) -> None:
    """Test that close() is idempotent.

    Verifies calling close() multiple times doesn't raise errors and the
    stream reports closed status correctly.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["sh"], stdin=True, timeout_seconds=30.0)
        assert process.stdin is not None

        assert not process.stdin.closed

        # Write script and close
        process.stdin.write(b'echo "test"\nexit 0\n').result()
        process.stdin.close().result()
        assert process.stdin.closed

        # Second close should succeed without error
        process.stdin.close().result()
        assert process.stdin.closed

        # Third close should also succeed
        process.stdin.close().result()
        assert process.stdin.closed

        # Process should complete
        result = process.result()
        assert result.returncode == 0
        assert "test" in result.stdout


def test_sandbox_exec_stdin_write_after_close_raises(
    sandbox_defaults: SandboxDefaults,
) -> None:
    """Test that writing after close raises SandboxExecutionError."""
    from cwsandbox.exceptions import SandboxExecutionError

    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["sh"], stdin=True, timeout_seconds=30.0)
        assert process.stdin is not None

        process.stdin.write(b'echo "test"\nexit 0\n').result()
        process.stdin.close().result()

        with pytest.raises(SandboxExecutionError) as exc_info:
            process.stdin.write(b"should fail\n").result()

        assert "closed" in str(exc_info.value).lower()


def test_sandbox_exec_stdin_disabled_by_default(
    sandbox_defaults: SandboxDefaults,
) -> None:
    """Test stdin is None when stdin=False (default)."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["echo", "hello"])
        assert process.stdin is None

        result = process.result()
        assert result.stdout.strip() == "hello"
        assert result.returncode == 0


def test_sandbox_exec_stdin_false_explicit(sandbox_defaults: SandboxDefaults) -> None:
    """Test explicit stdin=False works correctly."""
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["echo", "hello"], stdin=False)
        assert process.stdin is None

        result = process.result()
        assert result.stdout.strip() == "hello"
        assert result.returncode == 0


def test_sandbox_exec_stdin_eof_command(sandbox_defaults: SandboxDefaults) -> None:
    """Test that EOF (close) properly terminates stdin-reading commands.

    Uses sort which blocks until EOF is received, verifying that close()
    properly signals end-of-input.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["sort"], stdin=True, timeout_seconds=60.0)
        assert process.stdin is not None

        # Send data as single block for reliability
        data = "\n".join([f"line {i}" for i in range(10)]) + "\n"
        process.stdin.write(data.encode()).result()
        process.stdin.close().result()

        result = process.result()
        lines = result.stdout.strip().split("\n")
        # sort should output all 10 lines in sorted order
        assert len(lines) == 10
        # Lines should be sorted alphabetically (line 0, line 1, line 2...)
        assert lines[0] == "line 0"
        assert lines[9] == "line 9"
        assert result.returncode == 0


def test_sandbox_exec_stdin_binary_data(sandbox_defaults: SandboxDefaults) -> None:
    """Test binary data transmission via stdin.

    Uses Python to read and report byte count, avoiding timing issues by
    having Python read all stdin before outputting.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        # Create binary data with various byte values including high-ASCII
        # Avoid null bytes which can cause issues with some programs
        binary_data = bytes([i % 255 + 1 for i in range(1000)])  # 1000 bytes

        # Use Python to read all stdin and report byte count
        process = sandbox.exec(
            ["python", "-c", "import sys; data = sys.stdin.buffer.read(); print(len(data))"],
            stdin=True,
            timeout_seconds=60.0,
        )
        assert process.stdin is not None

        process.stdin.write(binary_data).result()
        process.stdin.close().result()

        result = process.result()
        assert result.returncode == 0

        byte_count = int(result.stdout.strip())
        assert byte_count == len(binary_data)


def test_sandbox_exec_stdin_with_streaming_output(
    sandbox_defaults: SandboxDefaults,
) -> None:
    """Test stdin and stdout streaming together.

    Sends input and consumes output in streaming fashion using sort command
    which waits for EOF before producing output.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        # Use sort which waits for all input before producing output
        process = sandbox.exec(["sort"], stdin=True, timeout_seconds=60.0)
        assert process.stdin is not None

        process.stdin.write(b"banana\napple\ncherry\n").result()
        process.stdin.close().result()

        # Consume stdout via streaming
        lines = list(process.stdout)
        result = process.result()

        # sort produces output with lines in alphabetical order
        combined = "".join(lines)
        sorted_lines = combined.strip().split("\n")
        assert sorted_lines == ["apple", "banana", "cherry"]
        assert result.returncode == 0


def test_sandbox_exec_stdin_cat_roundtrip(sandbox_defaults: SandboxDefaults) -> None:
    """Test stdin roundtrip with cat command as shown in the bead example.

    This is the canonical test case: write input to cat, close stdin, verify
    output matches input exactly.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["cat"], stdin=True, timeout_seconds=60.0)
        assert process.stdin is not None

        process.stdin.write(b"hello world").result()
        process.stdin.close().result()

        result = process.result()
        assert result.stdout.strip() == "hello world"
        assert result.returncode == 0


def test_sandbox_exec_stdin_cat_multiple_lines(sandbox_defaults: SandboxDefaults) -> None:
    """Test cat with multiple lines of input.

    Verifies that newlines and multiple writes are handled correctly.
    """
    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["cat"], stdin=True, timeout_seconds=60.0)
        assert process.stdin is not None

        process.stdin.write(b"line one\n").result()
        process.stdin.write(b"line two\n").result()
        process.stdin.write(b"line three\n").result()
        process.stdin.close().result()

        result = process.result()
        lines = result.stdout.strip().split("\n")
        assert lines == ["line one", "line two", "line three"]
        assert result.returncode == 0


def test_sandbox_exec_stdin_write_to_exited_process(
    sandbox_defaults: SandboxDefaults,
) -> None:
    """Test behavior when writing to stdin after the process has exited.

    The process exits immediately (exit 0), then we attempt to write to stdin.
    This is different from write-after-close: here the process terminates
    before we close stdin.
    """
    from cwsandbox.exceptions import SandboxExecutionError

    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        # Process exits immediately without reading stdin
        process = sandbox.exec(["sh", "-c", "exit 0"], stdin=True, timeout_seconds=30.0)
        assert process.stdin is not None

        # Wait for process to complete
        result = process.result()
        assert result.returncode == 0

        # Now try to write - process is already gone
        # This should raise or handle gracefully
        with pytest.raises(SandboxExecutionError):
            process.stdin.write(b"too late\n").result()


def test_sandbox_exec_stdin_timeout(sandbox_defaults: SandboxDefaults) -> None:
    """Test timeout while stdin is still open.

    Process blocks waiting for stdin but the operation times out before
    we can close it. Verifies timeout handling works with stdin streaming.
    """
    from concurrent.futures import TimeoutError as FuturesTimeoutError

    with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        # cat blocks waiting for stdin - we don't close it
        process = sandbox.exec(["cat"], stdin=True, timeout_seconds=2.0)
        assert process.stdin is not None

        # Don't write anything or close stdin - let it timeout
        # The exec should timeout waiting for the process to complete
        with pytest.raises(FuturesTimeoutError):
            process.result(timeout=3.0)


async def test_sandbox_exec_stdin_async(sandbox_defaults: SandboxDefaults) -> None:
    """Test stdin streaming via the async (await) API.

    Verifies that write(), writeline(), and close() all work as awaitables,
    and that the Process result can be awaited directly.
    """
    async with Sandbox.run("sleep", "infinity", defaults=sandbox_defaults) as sandbox:
        process = sandbox.exec(["sh"], stdin=True, timeout_seconds=30.0)
        assert process.stdin is not None

        await process.stdin.writeline('echo "from-async"')
        await process.stdin.writeline("exit 0")
        await process.stdin.close()

        result = await process
        assert "from-async" in result.stdout
        assert result.returncode == 0


# List and Get endpoint tests


def test_sandbox_list_excludes_stopped_by_default(sandbox_defaults: SandboxDefaults) -> None:
    """Test that default list() excludes terminal (stopped) sandboxes.

    Creates a sandbox, stops it, then verifies it does not appear in the
    default list results. The backend now filters out terminal sandboxes
    from the default list response.
    """
    unique_tag = f"e2e-list-stopped-{uuid.uuid4().hex[:8]}"

    sandbox = Sandbox.run("echo", "hello", defaults=sandbox_defaults, tags=[unique_tag])
    sandbox_id = sandbox.sandbox_id
    assert sandbox_id is not None

    # Wait for sandbox to complete (echo exits immediately)
    sandbox.wait_until_complete(timeout=60.0).result()

    # Default list should exclude the stopped sandbox.
    # Poll until the status propagates (may take a few seconds).
    for _ in range(15):
        sandboxes = Sandbox.list(tags=[unique_tag]).result()
        ids = [sb.sandbox_id for sb in sandboxes]
        if sandbox_id not in ids:
            break
        time.sleep(2)
    else:
        pytest.fail(f"Stopped sandbox {sandbox_id} still appears in default list after 30s")


def test_sandbox_list_include_stopped(sandbox_defaults: SandboxDefaults) -> None:
    """Test that list(include_stopped=True) includes terminal sandboxes.

    Creates a sandbox, stops it, then verifies it appears when
    include_stopped=True is set.
    """
    unique_tag = f"e2e-include-stopped-{uuid.uuid4().hex[:8]}"

    sandbox = Sandbox.run("echo", "hello", defaults=sandbox_defaults, tags=[unique_tag])
    sandbox_id = sandbox.sandbox_id
    assert sandbox_id is not None

    # Wait for sandbox to reach terminal state
    sandbox.wait_until_complete(timeout=60.0).result()

    # With include_stopped=True, the sandbox should appear.
    # The status may not yet reflect the final terminal state, so we only
    # assert the sandbox is returned — not a specific status.
    found = False
    for _ in range(15):
        sandboxes = Sandbox.list(tags=[unique_tag], include_stopped=True).result()
        for sb in sandboxes:
            if sb.sandbox_id == sandbox_id:
                found = True
                break
        if found:
            break
        time.sleep(1)

    assert found, f"Stopped sandbox {sandbox_id} not found with include_stopped=True"


def test_sandbox_list_terminal_status_filter(sandbox_defaults: SandboxDefaults) -> None:
    """Test that listing with a terminal status filter returns stopped sandboxes.

    A terminal status filter automatically widens the search,
    even without include_stopped=True.
    """
    unique_tag = f"e2e-status-filter-{uuid.uuid4().hex[:8]}"

    sandbox = Sandbox.run("echo", "hello", defaults=sandbox_defaults, tags=[unique_tag])
    sandbox_id = sandbox.sandbox_id
    assert sandbox_id is not None

    # Wait for terminal state
    sandbox.wait_until_complete(timeout=60.0).result()

    # Filtering by a terminal status should return the sandbox
    found = False
    for _ in range(15):
        sandboxes = Sandbox.list(tags=[unique_tag], status="completed").result()
        for sb in sandboxes:
            if sb.sandbox_id == sandbox_id:
                assert sb.status == "completed"
                found = True
                break
        if found:
            break
        time.sleep(1)

    assert found, f"Sandbox {sandbox_id} not found with status='completed' filter"


def test_sandbox_from_id_returns_stopped(sandbox_defaults: SandboxDefaults) -> None:
    """Test that from_id() returns stopped sandboxes.

    Previously, Get on a stopped sandbox returned NOT_FOUND. Now the
    backend returns the historical record with terminal status.
    """
    sandbox = Sandbox.run("echo", "hello", defaults=sandbox_defaults)
    sandbox_id = sandbox.sandbox_id
    assert sandbox_id is not None

    # Wait for sandbox to reach terminal state
    sandbox.wait_until_complete(timeout=60.0).result()

    # Wait for the stopped status to propagate.
    time.sleep(5)

    # from_id should still return the sandbox after it has stopped.
    # The backend may return UNSPECIFIED for the stopped sandbox's status.
    recovered = Sandbox.from_id(sandbox_id).result()
    assert recovered.sandbox_id == sandbox_id
    assert recovered.status in ("completed", "terminated", "failed", "unspecified")
