"""Base sandbox interface.

Defines the abstract :class:`Sandbox` class that all sandbox implementations
must extend. The class provides six abstract operations (command execution,
code execution, and file I/O) and convenience wrappers for common patterns.

Mirrors ``strands-ts/src/sandbox/base.ts``. The streaming methods
(:meth:`Sandbox.execute_streaming`, :meth:`Sandbox.execute_code_streaming`) are
the abstract primitives; the non-streaming convenience methods
(:meth:`Sandbox.execute`, :meth:`Sandbox.execute_code`) consume the stream and
return the final :class:`ExecutionResult`.

Idiomatic divergences from the TypeScript oracle (see PR description):

- TS's ``ExecuteOptions`` *options object* (``timeout``, ``cwd``, ``signal``,
  ``env``) becomes Python keyword arguments (``timeout``, ``cwd``, ``env``),
  matching how the rest of ``strands-py`` models call options.
- TS's ``AbortSignal`` cancellation maps to asyncio task cancellation — the
  Pythonic way to cancel an in-flight coroutine/generator — rather than an
  explicit signal parameter.
- TS's discriminated union (``type: 'streamChunk' | 'executionResult'``) is
  discriminated in Python with ``isinstance`` checks on the dataclasses.
"""

import logging
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from typing import Any

from .types import ExecutionResult, FileInfo, StreamChunk

logger = logging.getLogger(__name__)


class Sandbox(ABC):
    """Abstract execution environment.

    A Sandbox provides the runtime context where tools execute code, run
    commands, and interact with a filesystem. Multiple tools share the same
    Sandbox instance, giving them a common working directory and filesystem.

    Streaming methods (:meth:`execute_streaming`, :meth:`execute_code_streaming`)
    are the abstract primitives. Non-streaming convenience methods
    (:meth:`execute`, :meth:`execute_code`) consume the stream and return the
    final result.

    All abstract methods accept ``**kwargs`` for forward compatibility — new
    parameters with defaults can be added in future versions without breaking
    existing implementations.

    Example:
        Non-streaming (common case)::

            result = await sandbox.execute("echo hello")
            print(result.stdout)

        Streaming with stdout/stderr distinction::

            async for chunk in sandbox.execute_streaming("echo hello"):
                if isinstance(chunk, StreamChunk):
                    print(f"[{chunk.stream_type}] {chunk.data}", end="")
                elif isinstance(chunk, ExecutionResult):
                    print(f"Exit code: {chunk.exit_code}")
    """

    # ---- Streaming methods (abstract primitives) ----

    @abstractmethod
    async def execute_streaming(
        self,
        command: str,
        *,
        timeout: float | None = None,
        cwd: str | None = None,
        env: dict[str, str] | None = None,
        **kwargs: Any,
    ) -> AsyncGenerator[StreamChunk | ExecutionResult, None]:
        """Execute a shell command, streaming output.

        Yields :class:`StreamChunk` objects for stdout and stderr as output
        arrives. The final yield is an :class:`ExecutionResult` with the exit
        code and complete output.

        Args:
            command: The shell command to execute.
            timeout: Maximum execution time in seconds. ``None`` means no timeout.
            cwd: Working directory for execution. ``None`` means use the sandbox
                default.
            env: Environment variables to set for this command. Built-in
                sandboxes always apply these, though the mechanism differs;
                custom implementations must handle ``env`` explicitly or it has
                no effect.
            **kwargs: Additional keyword arguments for forward compatibility.

        Yields:
            :class:`StreamChunk` objects for output, then a final
            :class:`ExecutionResult`.
        """
        ...
        # Establishes this as an async generator function for type checkers.
        # Concrete subclasses must yield at least one ExecutionResult.
        yield  # type: ignore[misc]  # pragma: no cover

    @abstractmethod
    async def execute_code_streaming(
        self,
        code: str,
        language: str,
        *,
        timeout: float | None = None,
        cwd: str | None = None,
        env: dict[str, str] | None = None,
        **kwargs: Any,
    ) -> AsyncGenerator[StreamChunk | ExecutionResult, None]:
        """Execute source code via a language interpreter, streaming output.

        Args:
            code: The source code to execute.
            language: The interpreter to use (e.g., ``"python3"``, ``"node"``).
            timeout: Maximum execution time in seconds. ``None`` means no timeout.
            cwd: Working directory for execution. ``None`` means use the sandbox
                default.
            env: Environment variables to set for this execution.
            **kwargs: Additional keyword arguments for forward compatibility.

        Yields:
            :class:`StreamChunk` objects for output, then a final
            :class:`ExecutionResult`.
        """
        ...
        yield  # type: ignore[misc]  # pragma: no cover

    @abstractmethod
    async def read_file(self, path: str, **kwargs: Any) -> bytes:
        """Read a file from the sandbox filesystem as raw bytes.

        Returns ``bytes`` to support both text and binary files. Use
        :meth:`read_text` for a convenience wrapper that decodes to a string.

        Args:
            path: Path to the file to read.
            **kwargs: Additional keyword arguments for forward compatibility.

        Returns:
            The file contents as raw bytes.

        Raises:
            FileNotFoundError: If the file does not exist or cannot be read.
        """
        ...

    @abstractmethod
    async def write_file(self, path: str, content: bytes, **kwargs: Any) -> None:
        """Write raw bytes to a file in the sandbox filesystem.

        Implementations should create parent directories if they do not exist.
        Use :meth:`write_text` for a convenience wrapper that encodes a string.

        Args:
            path: Path to the file to write.
            content: The content to write.
            **kwargs: Additional keyword arguments for forward compatibility.

        Raises:
            OSError: If the file cannot be written.
        """
        ...

    @abstractmethod
    async def remove_file(self, path: str, **kwargs: Any) -> None:
        """Remove a file from the sandbox filesystem.

        Args:
            path: Path to the file to remove.
            **kwargs: Additional keyword arguments for forward compatibility.

        Raises:
            FileNotFoundError: If the file does not exist.
        """
        ...

    @abstractmethod
    async def list_files(self, path: str, **kwargs: Any) -> list[FileInfo]:
        """List files in a sandbox directory.

        Returns :class:`FileInfo` entries with name, ``is_dir``, and ``size``
        metadata. Fields ``is_dir`` and ``size`` may be ``None`` if the backend
        cannot determine them.

        Args:
            path: Path to the directory to list.
            **kwargs: Additional keyword arguments for forward compatibility.

        Returns:
            A list of :class:`FileInfo` entries for the directory contents.

        Raises:
            FileNotFoundError: If the directory does not exist.
        """
        ...

    # ---- Non-streaming convenience methods ----

    async def execute(
        self,
        command: str,
        *,
        timeout: float | None = None,
        cwd: str | None = None,
        env: dict[str, str] | None = None,
        **kwargs: Any,
    ) -> ExecutionResult:
        """Execute a shell command and return the result.

        Consumes :meth:`execute_streaming` and returns the final
        :class:`ExecutionResult`. Use :meth:`execute_streaming` when you need to
        process output as it arrives.

        Args:
            command: The shell command to execute.
            timeout: Maximum execution time in seconds. ``None`` means no timeout.
            cwd: Working directory for execution. ``None`` means use the sandbox
                default.
            env: Environment variables to set for this command.
            **kwargs: Additional keyword arguments for forward compatibility.

        Returns:
            The execution result with exit code and output.

        Raises:
            RuntimeError: If ``execute_streaming`` did not yield an
                :class:`ExecutionResult`.
        """
        async for chunk in self.execute_streaming(command, timeout=timeout, cwd=cwd, env=env, **kwargs):
            if isinstance(chunk, ExecutionResult):
                return chunk
        raise RuntimeError("execute_streaming() did not yield an ExecutionResult")

    async def execute_code(
        self,
        code: str,
        language: str,
        *,
        timeout: float | None = None,
        cwd: str | None = None,
        env: dict[str, str] | None = None,
        **kwargs: Any,
    ) -> ExecutionResult:
        """Execute source code and return the result.

        Consumes :meth:`execute_code_streaming` and returns the final
        :class:`ExecutionResult`. Use :meth:`execute_code_streaming` when you
        need to process output as it arrives.

        Args:
            code: The source code to execute.
            language: The interpreter to use.
            timeout: Maximum execution time in seconds. ``None`` means no timeout.
            cwd: Working directory for execution. ``None`` means use the sandbox
                default.
            env: Environment variables to set for this execution.
            **kwargs: Additional keyword arguments for forward compatibility.

        Returns:
            The execution result with exit code and output.

        Raises:
            RuntimeError: If ``execute_code_streaming`` did not yield an
                :class:`ExecutionResult`.
        """
        async for chunk in self.execute_code_streaming(code, language, timeout=timeout, cwd=cwd, env=env, **kwargs):
            if isinstance(chunk, ExecutionResult):
                return chunk
        raise RuntimeError("execute_code_streaming() did not yield an ExecutionResult")

    # ---- Text convenience methods ----

    async def read_text(self, path: str, encoding: str = "utf-8", **kwargs: Any) -> str:
        """Read a text file from the sandbox filesystem.

        Convenience wrapper over :meth:`read_file` that decodes bytes as UTF-8
        (by default). For other encodings, call :meth:`read_file` and decode
        manually.

        Args:
            path: Path to the file to read.
            encoding: Text encoding to use. Defaults to UTF-8.
            **kwargs: Additional keyword arguments passed to :meth:`read_file`.

        Returns:
            The file contents decoded as a string.
        """
        return (await self.read_file(path, **kwargs)).decode(encoding)

    async def write_text(self, path: str, content: str, encoding: str = "utf-8", **kwargs: Any) -> None:
        """Write a text file to the sandbox filesystem.

        Convenience wrapper over :meth:`write_file` that encodes a string as
        UTF-8 (by default). For other encodings, encode manually and call
        :meth:`write_file`.

        Args:
            path: Path to the file to write.
            content: The text content to write.
            encoding: Text encoding to use. Defaults to UTF-8.
            **kwargs: Additional keyword arguments passed to :meth:`write_file`.
        """
        await self.write_file(path, content.encode(encoding), **kwargs)
