"""Shell sandbox with default implementations for file and code operations.

Subclasses only need to implement :meth:`PosixShellSandbox.execute_streaming` —
all other operations are implemented by running shell commands through it. Use
this for remote environments where only shell access is available (Docker
containers, SSH connections, cloud runtimes).

Mirrors ``strands-ts/src/sandbox/posix-shell.ts``.
"""

import base64
import logging
import shlex
import uuid
from abc import ABC
from collections.abc import AsyncGenerator
from typing import Any

from .base import Sandbox
from .constants import ENV_KEY_PATTERN, LANGUAGE_PATTERN
from .types import ExecutionResult, FileInfo, StreamChunk

logger = logging.getLogger(__name__)


def validate_env_keys(env: dict[str, str]) -> None:
    """Validate environment variable names against :data:`ENV_KEY_PATTERN`.

    Args:
        env: Mapping of environment variable names to values.

    Raises:
        ValueError: If any key is not a valid POSIX environment variable name.
    """
    for key in env:
        if not ENV_KEY_PATTERN.fullmatch(key):
            raise ValueError(f"Invalid environment variable name: {key}")


def build_shell_env_prefix(env: dict[str, str] | None = None) -> str:
    """Build a shell ``export KEY=VALUE && ...`` prefix, or ``""`` when empty.

    Keys are validated; values are escaped with :func:`shlex.quote`. Used by
    shell-string backends (e.g. SSH); backends that set env via native flags
    (e.g. Docker's ``-e``) call :func:`validate_env_keys` directly.

    Uses ``export`` rather than an ``env KEY=VALUE`` command wrapper so the
    variables are set in the shell itself and inherited by every stage of a
    pipeline. ``execute_code`` runs ``base64 ... | <lang>``, and an ``env``
    wrapper would only bind the left side of the pipe, never reaching the
    interpreter. The trailing ``&&`` keeps the surrounding
    ``cd ... && <prefix><command>`` chain fail-fast.

    Args:
        env: Mapping of environment variable names to values.

    Returns:
        The shell ``export ... && `` prefix, or an empty string when ``env`` is
        ``None`` or empty.

    Raises:
        ValueError: If any key is not a valid POSIX environment variable name.
    """
    if not env:
        return ""
    validate_env_keys(env)
    assignments = " ".join(f"{key}={shlex.quote(value)}" for key, value in env.items())
    return f"export {assignments} && "


def _eof_marker() -> str:
    """Generate a unique heredoc EOF marker, mirroring the TS ``STRANDS_EOF_`` token."""
    return f"STRANDS_EOF_{uuid.uuid4().hex[:16]}"


class PosixShellSandbox(Sandbox, ABC):
    """Abstract sandbox that provides shell-based defaults for file and code operations.

    Assumes a POSIX-compatible shell (sh/bash) on the target.

    Subclasses only need to implement :meth:`execute_streaming`. The remaining
    operations — ``execute_code_streaming``, ``read_file``, ``write_file``,
    ``remove_file``, and ``list_files`` — are implemented via shell commands
    piped through :meth:`execute_streaming`.

    Subclasses may override any method with a native implementation for better
    performance or to handle edge cases (e.g., binary-safe file transfer via
    Docker stdin pipes, or native API calls for cloud backends).

    Subclasses are responsible for honoring the execution options in
    :meth:`execute_streaming`, or they have no effect:

    - ``env`` — backends that build a shell-command string prepend
      :func:`build_shell_env_prefix`; backends that set env via process flags
      (e.g. Docker's ``-e``) call :func:`validate_env_keys` and pass the values
      directly. An implementation that ignores ``env`` will silently drop the
      caller's variables.
    - ``timeout`` — the base class does not enforce a timeout; a subclass that
      does not wire ``timeout`` into its process supervision will silently run
      without any time limit.
    - ``cwd`` — similarly must be applied by the subclass.
    """

    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 code by piping it to a language interpreter over the shell.

        The code is base64-encoded and decoded inside a quoted heredoc on the
        target, then piped to the interpreter (``base64 -d << 'EOF' | <lang>``).
        This transports arbitrary source — including shell metacharacters,
        quotes, and newlines — without injection risk. The ``language`` is
        validated against :data:`LANGUAGE_PATTERN` first.

        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.
            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`.

        Raises:
            ValueError: If ``language`` contains invalid characters.
        """
        if not LANGUAGE_PATTERN.fullmatch(language):
            raise ValueError(f"language parameter contains invalid characters: {language}")
        encoded = base64.b64encode(code.encode()).decode("ascii")
        eof = _eof_marker()
        command = f"base64 -d << '{eof}' | {language}\n{encoded}\n{eof}"
        async for chunk in self.execute_streaming(command, timeout=timeout, cwd=cwd, env=env, **kwargs):
            yield chunk

    async def read_file(self, path: str, **kwargs: Any) -> bytes:
        """Read a file as raw bytes via base64 over the shell.

        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.
            OSError: If the command succeeds but its output is not valid base64
                (e.g. a shell profile or locale warning prepended text to stdout).
        """
        result = await self.execute(f"base64 < {shlex.quote(path)}")
        if result.exit_code != 0:
            raise FileNotFoundError(result.stderr or f"Failed to read file: {path}")
        # base64 output is ASCII-safe text; strip whitespace (line wrapping) and decode.
        try:
            # binascii.Error (raised by b64decode on malformed input) subclasses ValueError.
            return base64.b64decode("".join(result.stdout.split()))
        except ValueError as e:
            raise OSError(f"Failed to decode base64 contents of file: {path}") from e

    async def write_file(self, path: str, content: bytes, **kwargs: Any) -> None:
        """Write raw bytes to a file via base64 over the shell.

        Parent directories are created via ``mkdir -p``. The base64-encoded
        content is decoded inside a quoted heredoc on the target, preserving
        arbitrary binary content.

        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.
        """
        encoded = base64.b64encode(content).decode("ascii")
        quoted = shlex.quote(path)
        eof = _eof_marker()
        cmd = f"mkdir -p \"$(dirname {quoted})\" && base64 -d << '{eof}' > {quoted}\n{encoded}\n{eof}"
        result = await self.execute(cmd)
        if result.exit_code != 0:
            raise OSError(result.stderr or f"Failed to write file: {path}")

    async def remove_file(self, path: str, **kwargs: Any) -> None:
        """Remove a file via ``rm`` over the shell.

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

        Raises:
            FileNotFoundError: If the file does not exist.
        """
        result = await self.execute(f"rm {shlex.quote(path)}")
        if result.exit_code != 0:
            raise FileNotFoundError(result.stderr or f"Failed to remove file: {path}")

    async def list_files(self, path: str, **kwargs: Any) -> list[FileInfo]:
        """List directory contents via ``ls -1ap`` parsing.

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

        Returns:
            A list of :class:`FileInfo` entries (``size`` is always ``None`` for
            this shell-based listing).

        Raises:
            FileNotFoundError: If the directory does not exist (or ``path`` is
                not a directory).
        """
        quoted = shlex.quote(path)
        result = await self.execute(f"test -d {quoted} || exit 1; env QUOTING_STYLE=literal ls -1ap {quoted}")
        if result.exit_code != 0:
            raise FileNotFoundError(result.stderr or f"Failed to list directory: {path}")

        entries: list[FileInfo] = []
        for raw in result.stdout.split("\n"):
            line = raw.rstrip("\r")
            if not line or line in ("./", "../"):
                continue
            is_dir = line.endswith("/")
            name = line[:-1] if is_dir else line
            if name:
                entries.append(FileInfo(name=name, is_dir=is_dir))
        return entries
