"""Container-aware tool execution via ``devcontainer exec``.

Implements :class:`ContainerToolExecutor` which wraps tool execution
inside a provisioned devcontainer.  Arguments are escaped for shell
safety, results are captured as JSON, and structured errors are
produced on failure or timeout.

This module fulfils issue #515 — container-aware tool execution and
I/O forwarding.
"""

from __future__ import annotations

import json
import os
import posixpath
import shlex
import shutil
import subprocess
import time
from pathlib import Path
from typing import Any

import structlog
from pydantic import BaseModel, ConfigDict, Field, field_validator

from cleveragents.tool.path_mapper import PathMapper
from cleveragents.tool.runtime import ToolResult

logger = structlog.get_logger(__name__)

# ---------------------------------------------------------------------------
# Domain models
# ---------------------------------------------------------------------------

_DEFAULT_TIMEOUT_SECONDS = 120
_MAX_RECURSION_DEPTH = 20
_MAX_OUTPUT_BYTES = 50 * 1024 * 1024  # 50 MiB guard for captured output
_SAFE_SUBPROCESS_ENV_KEYS = ("PATH", "LANG", "TERM")


class ContainerMetadata(BaseModel):
    """Metadata about a container execution.

    Stored on :class:`ToolInvocation` for audit trail purposes.
    """

    model_config = ConfigDict(frozen=True)

    container_id: str = ""
    image: str = ""
    workspace_folder: str = ""
    exec_time_ms: float = 0.0
    exit_code: int | None = None
    timed_out: bool = False


class ContainerConfig(BaseModel):
    """Configuration for the container execution environment.

    Attributes:
        workspace_folder: Absolute path to the workspace inside the container.
        container_id: Docker/Podman container ID or name.
        image: Container image reference.
        timeout_seconds: Maximum execution time in seconds (must be > 0).
        host_sandbox_path: Host-side sandbox root for file sync.
    """

    model_config = ConfigDict(frozen=True, str_strip_whitespace=True)

    workspace_folder: str = Field(default="/workspace", min_length=1)
    container_id: str = ""
    image: str = ""
    timeout_seconds: int = Field(default=_DEFAULT_TIMEOUT_SECONDS, gt=0)
    host_sandbox_path: str = ""

    @field_validator("workspace_folder")
    @classmethod
    def _validate_workspace_folder(cls, value: str) -> str:
        if not value.startswith("/"):
            raise ValueError(
                "workspace_folder must be an absolute path (starting with '/')"
            )
        # Normalise to collapse redundant separators and resolve ".." so
        # the same path is used both by PathMapper and the devcontainer
        # CLI (P1-11).
        normalised = posixpath.normpath(value)
        if normalised == "/":
            raise ValueError("workspace_folder must not be the filesystem root '/'")
        # Reject traversal components that survive normalisation (e.g.
        # "/../etc" normalises to "/etc" — the intent is suspicious).
        if "/.." in value or value.endswith("/.."):
            raise ValueError("workspace_folder must not contain '..' path components")
        return normalised


# ---------------------------------------------------------------------------
# Execution errors
# ---------------------------------------------------------------------------


class ContainerExecutionError(Exception):
    """Raised when container execution fails."""

    def __init__(
        self,
        message: str,
        exit_code: int | None = None,
        stderr: str = "",
        timed_out: bool = False,
    ) -> None:
        super().__init__(message)
        self.exit_code = exit_code
        self.stderr = stderr
        self.timed_out = timed_out


class ContainerTimeoutError(ContainerExecutionError):
    """Raised when container execution exceeds the timeout."""

    def __init__(self, timeout_seconds: int, stderr: str = "") -> None:
        super().__init__(
            f"Container execution timed out after {timeout_seconds}s",
            exit_code=None,
            stderr=stderr,
            timed_out=True,
        )
        self.timeout_seconds = timeout_seconds


# ---------------------------------------------------------------------------
# Executor
# ---------------------------------------------------------------------------


class _ExecResult(BaseModel):
    """Internal result from a ``devcontainer exec`` call."""

    stdout: str = ""
    stderr: str = ""
    raw_stdout: bytes = b""
    exit_code: int = 0
    duration_ms: float = 0.0
    timed_out: bool = False


class ContainerToolExecutor:
    """Execute tools inside a devcontainer with I/O forwarding.

    The executor translates host-side tool inputs (paths, arguments) into
    container-side equivalents using :class:`PathMapper`, executes the
    tool via ``devcontainer exec``, and retrieves results.

    Parameters
    ----------
    config:
        Container configuration describing the target environment.
    path_mapper:
        Optional path mapper override. If ``None`` a default mapper is
        created from the config.

    Usage::

        config = ContainerConfig(
            workspace_folder="/workspace",
            container_id="abc123",
            image="ghcr.io/org/devimage:latest",
            host_sandbox_path="/tmp/sandbox",
        )
        executor = ContainerToolExecutor(config)
        result = executor.execute_tool("file_read", {"path": "/tmp/sandbox/foo.txt"})
    """

    def __init__(
        self,
        config: ContainerConfig,
        path_mapper: PathMapper | None = None,
    ) -> None:
        self._config = config

        host_root = config.host_sandbox_path or "/tmp/sandbox"
        fallback_path = Path(host_root)
        if not config.host_sandbox_path and fallback_path.is_symlink():
            raise ContainerExecutionError(
                f"Default sandbox path {host_root!r} is a symlink — "
                "set host_sandbox_path explicitly on ContainerConfig",
            )

        self._path_mapper = path_mapper or PathMapper(
            host_root=host_root,
            container_root=config.workspace_folder,
        )

        # Resolve devcontainer binary at init to prevent PATH hijacking.
        # Store ``None`` when the binary is absent so that callers
        # receive a clear error at execution time rather than silently
        # falling back to a bare name that could be hijacked via PATH.
        resolved_bin = shutil.which("devcontainer")
        if resolved_bin is None:
            logger.warning(
                "devcontainer_binary_not_found",
                msg=(
                    "devcontainer binary not found on PATH; "
                    "execute_tool and sync_results_to_host will fail"
                ),
            )
        self._devcontainer_bin: str | None = resolved_bin

    @property
    def config(self) -> ContainerConfig:
        """Return the container configuration."""
        return self._config

    @property
    def path_mapper(self) -> PathMapper:
        """Return the path mapper."""
        return self._path_mapper

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def execute_tool(
        self,
        tool_name: str,
        inputs: dict[str, Any],
        *,
        timeout_seconds: int | None = None,
    ) -> ToolResult:
        """Execute a tool inside the container.

        Maps file paths in *inputs*, runs the tool via ``devcontainer
        exec``, and returns a :class:`ToolResult`.

        Args:
            tool_name: Namespaced tool name.
            inputs: Tool input arguments.
            timeout_seconds: Override for execution timeout.

        Returns:
            :class:`ToolResult` with execution output or error.

        Raises:
            ValueError: If *tool_name* is empty or *timeout_seconds* <= 0.
            TypeError: If *inputs* is not a dict.
        """
        if not tool_name:
            raise ValueError("tool_name must be a non-empty string")
        if not isinstance(inputs, dict):
            raise TypeError("inputs must be a dict")
        if timeout_seconds is not None and timeout_seconds <= 0:
            raise ValueError("timeout_seconds must be > 0")

        timeout = timeout_seconds or self._config.timeout_seconds

        # Map host paths to container paths in inputs
        mapped_inputs = self._map_input_paths(inputs)

        # Build the command — validate JSON-serialisability
        try:
            cmd, stdin_data = self._build_exec_command(
                tool_name, mapped_inputs, timeout
            )
        except (TypeError, ValueError) as exc:
            return ToolResult(
                success=False,
                output={},
                error=f"Tool inputs are not JSON-serialisable: {exc}",
                duration_ms=0.0,
            )

        logger.info(
            "container_tool_exec_start",
            tool_name=tool_name,
            container_id=self._config.container_id,
            timeout_seconds=timeout,
        )

        exec_result = self._run_command(cmd, timeout=timeout, stdin_data=stdin_data)

        metadata = ContainerMetadata(
            container_id=self._config.container_id,
            image=self._config.image,
            workspace_folder=self._config.workspace_folder,
            exec_time_ms=exec_result.duration_ms,
            exit_code=exec_result.exit_code,
            timed_out=exec_result.timed_out,
        )
        metadata_dict = metadata.model_dump()

        if exec_result.timed_out:
            logger.warning(
                "container_tool_exec_timeout",
                tool_name=tool_name,
                timeout_seconds=timeout,
            )
            return ToolResult(
                success=False,
                output={},
                error=f"Container execution timed out after {timeout}s",
                duration_ms=exec_result.duration_ms,
                metadata={"container": metadata_dict},
            )

        if exec_result.exit_code != 0:
            logger.warning(
                "container_tool_exec_failed",
                tool_name=tool_name,
                exit_code=exec_result.exit_code,
            )
            # Attempt best-effort parsing of stdout even on failure
            partial_output = self._parse_output(exec_result.stdout)
            return ToolResult(
                success=False,
                output=partial_output,
                error=(
                    f"Container execution failed (exit code "
                    f"{exec_result.exit_code}): "
                    f"{exec_result.stderr[:500]}"
                ),
                duration_ms=exec_result.duration_ms,
                metadata={"container": metadata_dict},
            )

        # Parse output and map container paths back to host paths.
        # Metadata is placed in ToolResult.metadata (not output) so the
        # tool's semantic output is not polluted.
        output = self._parse_output(exec_result.stdout)
        output = self._map_output_paths(output)

        logger.info(
            "container_tool_exec_success",
            tool_name=tool_name,
            duration_ms=exec_result.duration_ms,
        )

        return ToolResult(
            success=True,
            output=output,
            duration_ms=exec_result.duration_ms,
            metadata={"container": metadata_dict},
        )

    def sync_results_to_host(self, container_path: str) -> str:
        """Sync a container file back to the host sandbox.

        Uses ``devcontainer exec`` with ``cat`` to read the file
        content from the container and writes it to the mapped host
        path.

        Args:
            container_path: Absolute path inside the container.

        Returns:
            Host-side path where the content was written.

        Raises:
            ContainerExecutionError: If the sync fails.
            ContainerTimeoutError: If the sync times out.
            ValueError: If *container_path* is not absolute or
                contains null bytes.
        """
        if "\x00" in container_path:
            raise ValueError("container_path must not contain null bytes")
        if not container_path.startswith("/"):
            raise ValueError(f"container_path must be absolute, got {container_path!r}")

        host_path = self._path_mapper.container_to_host(container_path)

        # Validate host path falls within the sandbox root.
        sandbox_root = Path(self._path_mapper.host_root).resolve()
        resolved_host = Path(host_path).resolve()
        if not resolved_host.is_relative_to(sandbox_root):
            raise ContainerExecutionError(
                f"Mapped host path {host_path!r} resolves outside "
                f"sandbox root {sandbox_root}",
                exit_code=-1,
            )

        cmd = self._build_sync_command(container_path)
        result = self._run_command(cmd, timeout=self._config.timeout_seconds)
        if result.timed_out:
            raise ContainerTimeoutError(
                timeout_seconds=self._config.timeout_seconds,
                stderr=result.stderr,
            )
        if result.exit_code != 0:
            raise ContainerExecutionError(
                f"Failed to sync {container_path} to host: {result.stderr}",
                exit_code=result.exit_code,
                stderr=result.stderr,
            )

        # Write captured content to the *resolved* host path to avoid a
        # TOCTOU race between validation and actual file write.
        # Use raw bytes from _run_command to avoid binary-file
        # corruption from text-mode decode/re-encode (P1-10).
        dest = resolved_host
        try:
            dest.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
            fd = os.open(
                str(dest),
                os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW,
                0o600,
            )
            try:
                os.write(fd, result.raw_stdout)
            finally:
                os.close(fd)
        except OSError as exc:
            raise ContainerExecutionError(
                f"Failed to write synced file to host path {dest}: {exc}",
                exit_code=-1,
                stderr=str(exc),
            ) from exc

        return str(resolved_host)

    # ------------------------------------------------------------------
    # Command building
    # ------------------------------------------------------------------

    def _build_exec_command(
        self,
        tool_name: str,
        inputs: dict[str, Any],
        timeout: int,
    ) -> tuple[list[str], str]:
        """Build the ``devcontainer exec`` command for tool execution.

        Returns a (command, stdin_data) tuple.  The JSON inputs are
        piped via stdin instead of embedded as a shell argument to
        avoid hitting the OS ``ARG_MAX`` limit.
        """
        inputs_json = json.dumps(inputs, allow_nan=False)

        devcontainer = self._require_devcontainer_bin()
        target_args = self._devcontainer_target_args()

        # Enforce int type to prevent shell injection via malicious
        # objects with __str__ methods (P2-16).
        timeout = int(timeout)

        # Use a slightly shorter container-side timeout so the
        # container process self-terminates before the host-side
        # deadline, avoiding orphan processes when the host kills
        # its Popen first.
        container_timeout = max(timeout - 5, 1)
        cmd = [
            devcontainer,
            "exec",
            *target_args,
            "--",
            "sh",
            "-c",
            f"timeout {int(container_timeout)} cleveragents-tool-exec "
            + shlex.quote(tool_name),
        ]
        return cmd, inputs_json

    def _build_sync_command(self, container_path: str) -> list[str]:
        """Build a command to sync a file from container to host."""
        devcontainer = self._require_devcontainer_bin()
        target_args = self._devcontainer_target_args()
        return [
            devcontainer,
            "exec",
            *target_args,
            "--",
            "sh",
            "-c",
            f"cat {shlex.quote(container_path)}",
        ]

    def _require_devcontainer_bin(self) -> str:
        """Return the resolved devcontainer binary path or raise."""
        if self._devcontainer_bin is None:
            raise ContainerExecutionError(
                "devcontainer binary was not found on PATH at init time. "
                "Install devcontainer CLI or set host_sandbox_path explicitly.",
                exit_code=-1,
            )
        return self._devcontainer_bin

    def _devcontainer_target_args(self) -> list[str]:
        """Return CLI args to target the correct container."""
        if self._config.container_id:
            return ["--container-id", self._config.container_id]
        return ["--workspace-folder", self._config.workspace_folder]

    # ------------------------------------------------------------------
    # Command execution
    # ------------------------------------------------------------------

    def _run_command(
        self,
        cmd: list[str],
        timeout: int,
        *,
        stdin_data: str | None = None,
    ) -> _ExecResult:
        """Execute a command and capture output with bounded memory.

        Uses :class:`subprocess.Popen` with incremental reads so that
        at most ``_MAX_OUTPUT_BYTES`` are buffered per stream at any
        time, preventing an unbounded memory allocation if the
        container produces excessive output.

        Returns an :class:`_ExecResult` with stdout, stderr, exit code,
        duration, and timeout status.
        """
        safe_env = {k: os.environ.get(k, "") for k in _SAFE_SUBPROCESS_ENV_KEYS}

        start = time.monotonic()
        try:
            proc = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                stdin=subprocess.PIPE if stdin_data else subprocess.DEVNULL,
                env=safe_env,
            )
            try:
                # Feed stdin and close it so the child can proceed.
                if stdin_data:
                    assert proc.stdin is not None  # mypy guard
                    proc.stdin.write(stdin_data.encode("utf-8"))
                    proc.stdin.close()

                # Read stdout/stderr with a cap to avoid unbounded memory.
                raw_stdout = _read_bounded(proc.stdout, _MAX_OUTPUT_BYTES)
                raw_stderr = _read_bounded(proc.stderr, _MAX_OUTPUT_BYTES)

                remaining = timeout - (time.monotonic() - start)
                proc.wait(timeout=max(remaining, 0))
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait()
                elapsed = (time.monotonic() - start) * 1000.0
                return _ExecResult(
                    stdout="",
                    stderr=f"Command timed out after {timeout}s",
                    exit_code=-1,
                    duration_ms=elapsed,
                    timed_out=True,
                )
            finally:
                # Ensure all handles are closed even on unexpected errors.
                for stream in (proc.stdout, proc.stderr, proc.stdin):
                    if stream and not stream.closed:
                        stream.close()

            elapsed = (time.monotonic() - start) * 1000.0

            if len(raw_stdout) > _MAX_OUTPUT_BYTES:
                logger.warning(
                    "container_output_truncated",
                    stream="stdout",
                    original_bytes=len(raw_stdout),
                    max_bytes=_MAX_OUTPUT_BYTES,
                )
                raw_stdout = raw_stdout[:_MAX_OUTPUT_BYTES]
            if len(raw_stderr) > _MAX_OUTPUT_BYTES:
                logger.warning(
                    "container_output_truncated",
                    stream="stderr",
                    original_bytes=len(raw_stderr),
                    max_bytes=_MAX_OUTPUT_BYTES,
                )
                raw_stderr = raw_stderr[:_MAX_OUTPUT_BYTES]

            stdout_text = raw_stdout.decode("utf-8", errors="replace")
            stderr_text = raw_stderr.decode("utf-8", errors="replace")
            return _ExecResult(
                stdout=stdout_text,
                stderr=stderr_text,
                raw_stdout=raw_stdout,
                exit_code=proc.returncode,
                duration_ms=elapsed,
                timed_out=False,
            )
        except OSError as exc:
            elapsed = (time.monotonic() - start) * 1000.0
            return _ExecResult(
                stdout="",
                stderr=str(exc),
                exit_code=-1,
                duration_ms=elapsed,
                timed_out=False,
            )

    # ------------------------------------------------------------------
    # Path mapping
    # ------------------------------------------------------------------

    def _map_input_paths(
        self, inputs: dict[str, Any], *, _depth: int = 0
    ) -> dict[str, Any]:
        """Map host file paths in tool inputs to container paths."""
        if _depth > _MAX_RECURSION_DEPTH:
            logger.warning(
                "path_mapping_depth_exceeded",
                direction="host_to_container",
                max_depth=_MAX_RECURSION_DEPTH,
            )
            return inputs
        mapped: dict[str, Any] = {}
        for key, value in inputs.items():
            mapped[key] = self._map_value_host_to_container(value, _depth=_depth)
        return mapped

    def _map_value_host_to_container(self, value: Any, *, _depth: int = 0) -> Any:
        """Recursively map a single value from host to container paths."""
        if _depth > _MAX_RECURSION_DEPTH:
            logger.warning(
                "path_mapping_depth_exceeded",
                direction="host_to_container",
                max_depth=_MAX_RECURSION_DEPTH,
            )
            return value
        if isinstance(value, str) and _looks_like_path(value):
            if self._path_mapper.is_host_path(value):
                return self._path_mapper.host_to_container(value)
            return value
        if isinstance(value, dict):
            return {
                k: self._map_value_host_to_container(v, _depth=_depth + 1)
                for k, v in value.items()
            }
        if isinstance(value, list):
            return [
                self._map_value_host_to_container(v, _depth=_depth + 1) for v in value
            ]
        return value

    def _map_output_paths(
        self, output: dict[str, Any], *, _depth: int = 0
    ) -> dict[str, Any]:
        """Map container file paths in tool output to host paths."""
        if _depth > _MAX_RECURSION_DEPTH:
            logger.warning(
                "path_mapping_depth_exceeded",
                direction="container_to_host",
                max_depth=_MAX_RECURSION_DEPTH,
            )
            return output
        mapped: dict[str, Any] = {}
        for key, value in output.items():
            # Skip path mapping for raw_output to prevent nonsensical
            # remapping of multi-line stdout that happens to start with
            # the container root.
            if key == "raw_output":
                mapped[key] = value
                continue
            mapped[key] = self._map_value_container_to_host(value, _depth=_depth)
        return mapped

    def _map_value_container_to_host(self, value: Any, *, _depth: int = 0) -> Any:
        """Recursively map a single value from container to host paths."""
        if _depth > _MAX_RECURSION_DEPTH:
            logger.warning(
                "path_mapping_depth_exceeded",
                direction="container_to_host",
                max_depth=_MAX_RECURSION_DEPTH,
            )
            return value
        if isinstance(value, str) and _looks_like_path(value):
            if self._path_mapper.is_container_path(value):
                return self._path_mapper.container_to_host(value)
            return value
        if isinstance(value, dict):
            return {
                k: self._map_value_container_to_host(v, _depth=_depth + 1)
                for k, v in value.items()
            }
        if isinstance(value, list):
            return [
                self._map_value_container_to_host(v, _depth=_depth + 1) for v in value
            ]
        return value

    # ------------------------------------------------------------------
    # Output parsing
    # ------------------------------------------------------------------

    @staticmethod
    def extract_container_metadata(result: ToolResult) -> dict[str, Any] | None:
        """Extract container metadata from a :class:`ToolResult`.

        Returns the ``container`` dict from ``result.metadata`` if
        present, or ``None`` otherwise.  This is the bridge between the
        executor's ``ToolResult`` output and the
        :class:`ToolInvocation.container_metadata` field for audit trail
        persistence (P1-9).
        """
        return result.metadata.get("container")

    @staticmethod
    def _parse_output(stdout: str) -> dict[str, Any]:
        """Parse JSON output from the container tool execution.

        Falls back to wrapping raw output in a dict if JSON parsing
        fails.
        """
        if not stdout.strip():
            return {}
        try:
            parsed = json.loads(stdout)
            if isinstance(parsed, dict):
                return parsed
            return {"result": parsed}
        except (json.JSONDecodeError, TypeError, RecursionError, MemoryError):
            return {"raw_output": stdout.strip()}


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_READ_CHUNK_SIZE = 65_536  # 64 KiB chunks for incremental stream reads


def _read_bounded(stream: Any, max_bytes: int) -> bytes:
    """Read up to *max_bytes* from *stream*, then discard the rest.

    Reads in fixed-size chunks to avoid allocating the entire output
    in a single ``read()`` call.
    """
    chunks: list[bytes] = []
    total = 0
    while True:
        chunk = stream.read(_READ_CHUNK_SIZE)
        if not chunk:
            break
        remaining = max_bytes - total
        if remaining <= 0:
            # Already at the cap — drain the stream without keeping data
            continue
        if len(chunk) > remaining:
            chunks.append(chunk[:remaining])
            total += remaining
        else:
            chunks.append(chunk)
            total += len(chunk)
    return b"".join(chunks)


def _looks_like_path(value: str) -> bool:
    """Return ``True`` if *value* looks like a filesystem path.

    Rejects strings containing newlines, tabs, or null bytes which are
    almost certainly not bare paths.  Also rejects URL-like strings and
    strings with query/fragment markers to avoid false positives on API
    routes and URIs (P1-7).  Spaces are allowed because they are valid
    in filesystem paths (e.g. ``/home/user/My Documents``).
    """
    if not value.startswith("/"):
        return False
    if "\n" in value or "\r" in value or "\t" in value:
        return False
    if "\x00" in value:
        return False
    # Reject URL-like patterns: "//host/...", protocol-relative URIs,
    # and paths containing query strings or fragment identifiers.
    if value.startswith("//"):
        return False
    if "?" in value or "#" in value:
        return False
    # Reject strings that look like URL paths with scheme separators.
    return "://" not in value
