"""Inline tool executor for CleverAgents v3.

Provides safe execution of inline (anonymous) tool code within
skills.  Inline tools are defined directly in skill YAML as Python
code strings and are executed in a restricted thread with timeout
and output-size guards.

## Safety Guards

- **Timeout**: Execution is bounded by ``max_runtime_seconds``
  (default 30 s).  A background thread runs the code; if it does
  not complete in time the result reports a timeout error.
- **Output size**: Captured stdout/stderr is truncated at
  ``max_output_bytes`` (default 1 MiB).  When truncation occurs the
  ``InlineToolResult.truncated`` flag is set.
- **Write guard**: If the inline tool's capability declares
  ``writes=True``, the executor calls
  ``SkillContext.enforce_write_guard`` before running, which raises
  ``SkillExecutionError(PERMISSION_DENIED)`` in a read-only
  context.
- **Path restriction**: Any file-path values in *input_data* are
  validated to reside within the sandbox root returned by
  ``SkillContext.get_sandbox_path()``.
- **Language check**: Only ``source=custom`` (Python) is supported
  for MVP.  Other source types are rejected by ``validate_tool``.
- **Network access**: For MVP this is documented as a local-only
  restriction.  No runtime enforcement is applied; the code runs
  in the same process without network sandboxing.

Based on ``docs/specification.md`` and ``implementation_plan.md``
task C3.inline.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
import textwrap
import time
from pathlib import Path
from typing import Any

from pydantic import BaseModel, ConfigDict, Field

from cleveragents.domain.models.core.skill import SkillInlineTool
from cleveragents.domain.models.core.tool import ToolSource
from cleveragents.skills.context import SkillContext, SkillExecutionError

# ---------------------------------------------------------------------------
# InlineToolResult
# ---------------------------------------------------------------------------


class InlineToolResult(BaseModel):
    """Structured result of an inline tool execution.

    Captures success/failure, captured output, timing, and whether
    the output was truncated due to size limits.
    """

    success: bool = Field(..., description="Whether the execution succeeded")
    output: Any = Field(default=None, description="Captured stdout/stderr output")
    error_message: str | None = Field(
        default=None,
        description="Error description on failure",
    )
    duration_ms: float = Field(
        default=0.0,
        ge=0.0,
        description="Execution duration in milliseconds",
    )
    truncated: bool = Field(
        default=False,
        description="Whether output was truncated due to size limits",
    )

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


# ---------------------------------------------------------------------------
# Supported languages
# ---------------------------------------------------------------------------

_SUPPORTED_SOURCES: frozenset[ToolSource] = frozenset({ToolSource.CUSTOM})
"""Tool sources that the inline executor can run (Python-only for MVP)."""

# ---------------------------------------------------------------------------
# InlineToolExecutor
# ---------------------------------------------------------------------------


class InlineToolExecutor:
    """Execute inline tool code with safety constraints.

    Provides timeout enforcement, output-size truncation, write-guard
    checks via the ``SkillContext``, and path-restriction validation.

    Args:
        max_runtime_seconds: Maximum wall-clock seconds for code
            execution (default ``30.0``).
        max_output_bytes: Maximum bytes of captured stdout/stderr
            before truncation (default ``1_048_576`` = 1 MiB).
    """

    def __init__(
        self,
        max_runtime_seconds: float = 30.0,
        max_output_bytes: int = 1_048_576,
    ) -> None:
        if max_runtime_seconds <= 0:
            raise ValueError("max_runtime_seconds must be positive")
        if max_output_bytes <= 0:
            raise ValueError("max_output_bytes must be positive")

        self._max_runtime_seconds = max_runtime_seconds
        self._max_output_bytes = max_output_bytes

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

    @property
    def max_runtime_seconds(self) -> float:
        """Return the configured maximum runtime in seconds."""
        return self._max_runtime_seconds

    @property
    def max_output_bytes(self) -> int:
        """Return the configured maximum output size in bytes."""
        return self._max_output_bytes

    def execute(
        self,
        tool: SkillInlineTool,
        context: SkillContext,
        input_data: dict[str, Any],
    ) -> InlineToolResult:
        """Execute an inline tool's code in a guarded thread.

        Steps:
        1. Validate the tool.
        2. Enforce write guard if the tool declares ``writes``.
        3. Validate file paths in *input_data* against the sandbox.
        4. Run the code string in a background thread with timeout.
        5. Capture stdout/stderr, apply output-size truncation.
        6. Record the invocation in the context's change tracker.

        Args:
            tool: The inline tool definition.
            context: The skill execution context.
            input_data: Input data passed to the tool code as a
                local variable ``input_data``.

        Returns:
            An ``InlineToolResult`` with execution outcome.
        """
        if tool is None:
            raise ValueError("tool must not be None")
        if context is None:
            raise ValueError("context must not be None")
        if input_data is None:
            raise ValueError("input_data must not be None")

        # Validate tool suitability
        errors = self.validate_tool(tool)
        if errors:
            return InlineToolResult(
                success=False,
                error_message=f"Validation failed: {'; '.join(errors)}",
                duration_ms=0.0,
            )

        # Enforce write guard for tools that write
        if tool.capability is not None and tool.capability.writes:
            try:
                context.enforce_write_guard("inline_tool")
            except SkillExecutionError as exc:
                return InlineToolResult(
                    success=False,
                    error_message=str(exc),
                    duration_ms=0.0,
                )

        # Validate paths in input_data
        path_error = self._validate_paths(input_data, context.get_sandbox_path())
        if path_error is not None:
            return InlineToolResult(
                success=False,
                error_message=path_error,
                duration_ms=0.0,
            )

        # Execute with timeout
        result = self._run_with_timeout(tool, input_data)

        # Record invocation in change tracker
        context.register_tool_invocation(
            tool_name="inline_tool",
            input_data=input_data,
            output_data=result.output,
            duration_ms=result.duration_ms,
        )

        return result

    def validate_tool(self, tool: SkillInlineTool) -> list[str]:
        """Validate that an inline tool is suitable for execution.

        Checks:
        1. The tool has non-empty code.
        2. The tool's source is a supported language/source type
           (``custom`` / Python for MVP).

        Args:
            tool: The inline tool definition to validate.

        Returns:
            List of validation error messages.  An empty list means
            the tool is valid and ready for execution.
        """
        if tool is None:
            raise ValueError("tool must not be None")

        errors: list[str] = []

        if not tool.code:
            errors.append("Inline tool must have non-empty code")

        if tool.source not in _SUPPORTED_SOURCES:
            errors.append(
                f"Unsupported source '{tool.source.value}': "
                f"only 'custom' (Python) is supported for MVP"
            )

        return errors

    # -- Internal helpers --------------------------------------------------

    def _validate_paths(
        self,
        input_data: dict[str, Any],
        sandbox_path: Path,
    ) -> str | None:
        """Check that file-path values in *input_data* are within the sandbox.

        Args:
            input_data: The input data dict.
            sandbox_path: The sandbox root path.

        Returns:
            An error message string if a path escapes the sandbox,
            or ``None`` if all paths are valid.
        """
        for key, value in input_data.items():
            if not isinstance(value, str):
                continue
            # Heuristic: treat values that look like file paths
            if key.endswith("_path") or key == "path" or key.endswith("_file"):
                try:
                    resolved = Path(value).resolve()
                    sandbox_resolved = sandbox_path.resolve()
                    if not str(resolved).startswith(str(sandbox_resolved)):
                        return (
                            f"Path '{value}' for key '{key}' escapes sandbox "
                            f"root '{sandbox_path}'"
                        )
                except (OSError, ValueError):
                    return f"Invalid path '{value}' for key '{key}'"
        return None

    def _run_with_timeout(
        self,
        tool: SkillInlineTool,
        input_data: dict[str, Any],
    ) -> InlineToolResult:
        """Run tool code in a subprocess with timeout.

        Launches a child Python process that executes the tool's code
        string.  The ``input_data`` dict is serialised as JSON and
        injected into the child's namespace.  Stdout and stderr are
        captured and combined.

        Using a subprocess (rather than an in-process thread) ensures:
        - Clean timeout via ``subprocess.Popen.kill()``
        - No lingering daemon threads that confuse coverage tooling
        - Process-level isolation for the executed code

        Applies output-size truncation when the combined output exceeds
        ``max_output_bytes``.

        Args:
            tool: The inline tool whose ``code`` to execute.
            input_data: Passed into the execution namespace as
                ``input_data``.

        Returns:
            An ``InlineToolResult`` with captured output and timing.
        """
        # Build a small wrapper script that deserialises input_data and
        # execs the tool code, emitting a JSON result envelope on fd 3
        # (or just use stdout/stderr capture for simplicity).
        wrapper = textwrap.dedent("""\
            import json, sys
            input_data = json.loads(sys.argv[1])
            try:
                exec(sys.argv[2])
                print("__INLINE_OK__")
            except Exception as _exc:
                print(f"__INLINE_ERR__{_exc}", file=sys.stderr)
                sys.exit(1)
        """)

        # Build a clean environment for the subprocess.
        # Remove coverage-related env vars to prevent the child from
        # inheriting coverage instrumentation (which can cause hangs).
        child_env = {
            k: v for k, v in os.environ.items() if not k.startswith("COVERAGE")
        }

        start = time.monotonic()
        try:
            proc = subprocess.Popen(
                [
                    sys.executable,
                    "-I",
                    "-c",
                    wrapper,
                    json.dumps(input_data),
                    tool.code or "",
                ],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                env=child_env,
            )
            try:
                stdout_bytes, stderr_bytes = proc.communicate(
                    timeout=self._max_runtime_seconds,
                )
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait()
                elapsed_ms = (time.monotonic() - start) * 1000.0
                return InlineToolResult(
                    success=False,
                    error_message=(
                        f"Execution timed out after {self._max_runtime_seconds}s"
                    ),
                    duration_ms=elapsed_ms,
                )
        except OSError as exc:
            elapsed_ms = (time.monotonic() - start) * 1000.0
            return InlineToolResult(
                success=False,
                error_message=f"Failed to start subprocess: {exc}",
                duration_ms=elapsed_ms,
            )

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

        # Decode and combine output
        raw_stdout = stdout_bytes.decode("utf-8", errors="replace")
        raw_stderr = stderr_bytes.decode("utf-8", errors="replace")
        raw_output = raw_stdout + raw_stderr

        # Check for truncation
        truncated = False
        if len(raw_output.encode("utf-8", errors="replace")) > self._max_output_bytes:
            encoded = raw_output.encode("utf-8", errors="replace")
            raw_output = encoded[: self._max_output_bytes].decode(
                "utf-8", errors="replace"
            )
            truncated = True

        # Parse success/failure from the wrapper's sentinel
        if proc.returncode == 0 and "__INLINE_OK__" in raw_stdout:
            # Remove the sentinel from displayed output
            clean_output = raw_stdout.replace("__INLINE_OK__\n", "").replace(
                "__INLINE_OK__", ""
            )
            return InlineToolResult(
                success=True,
                output=clean_output + raw_stderr,
                duration_ms=elapsed_ms,
                truncated=truncated,
            )

        # Execution error — extract message from stderr sentinel
        error_msg = raw_stderr
        if "__INLINE_ERR__" in raw_stderr:
            error_msg = raw_stderr.split("__INLINE_ERR__", 1)[1].strip()

        return InlineToolResult(
            success=False,
            output=raw_stdout,
            error_message=error_msg or "Unknown execution error",
            duration_ms=elapsed_ms,
            truncated=truncated,
        )
