"""Controlled local tools for grounded sandbox tasks."""

from __future__ import annotations

import os
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path

from .trace import ToolCallTrace


class SandboxViolation(ValueError):
    """Raised when a tool request tries to escape the sandbox."""


@dataclass(slots=True)
class SandboxToolResult:
    trace: ToolCallTrace
    content: str | None = None
    files: list[str] | None = None

    @property
    def success(self) -> bool:
        return self.trace.success


class SandboxToolRunner:
    def __init__(self, root: str | Path, timeout_seconds: float = 120.0) -> None:
        self.root = Path(root).resolve()
        self.root.mkdir(parents=True, exist_ok=True)
        self.timeout_seconds = timeout_seconds
        self.calls: list[ToolCallTrace] = []

    def list_files(self, path: str | Path = ".") -> SandboxToolResult:
        directory = self._resolve_existing(path)
        if not directory.is_dir():
            return self._record("list_files", False, "path is not a directory", {"path": str(path)})
        files = sorted(
            str(item.relative_to(self.root))
            for item in directory.rglob("*")
            if item.is_file() and not item.is_symlink()
        )
        return self._record("list_files", True, f"listed {len(files)} file(s)", {"path": str(path)}, files=files)

    def read_file(self, path: str | Path) -> SandboxToolResult:
        target = self._resolve_existing(path)
        if not target.is_file():
            return self._record("read_file", False, "path is not a file", {"path": str(path)})
        content = target.read_text(encoding="utf-8")
        return self._record(
            "read_file",
            True,
            f"read {len(content)} byte(s)",
            {"path": str(path), "bytes": len(content)},
            content=content,
        )

    def write_file(self, path: str | Path, content: str) -> SandboxToolResult:
        target = self._resolve_for_write(path)
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(content, encoding="utf-8")
        return self._record(
            "write_file",
            True,
            f"wrote {len(content)} byte(s)",
            {"path": str(path), "bytes": len(content)},
        )

    def patch_file(self, path: str | Path, old: str, new: str) -> SandboxToolResult:
        target = self._resolve_existing(path)
        if not target.is_file():
            return self._record("patch_file", False, "path is not a file", {"path": str(path)})
        content = target.read_text(encoding="utf-8")
        if old not in content:
            return self._record(
                "patch_file",
                False,
                "old text not found",
                {"path": str(path), "old_text": old, "new_text": new},
            )
        target.write_text(content.replace(old, new), encoding="utf-8")
        return self._record(
            "patch_file",
            True,
            "patched file",
            {"path": str(path), "old_text": old, "new_text": new},
        )

    def run_compileall(self) -> SandboxToolResult:
        return self.run_command(["python", "-m", "compileall", "src", "tests"])

    def run_pytest(self) -> SandboxToolResult:
        return self.run_command(["python", "-m", "pytest", "-q"])

    def run_command(self, command: list[str]) -> SandboxToolResult:
        allowed = {
            ("python", "-m", "compileall", "src", "tests"),
            ("python", "-m", "pytest", "-q"),
        }
        if tuple(command) not in allowed:
            return self._record(
                "run_command",
                False,
                "command not allowlisted",
                {"command": command},
            )

        started = time.perf_counter()
        env = {
            **os.environ,
            "PYTHONPATH": str(self.root / "src"),
            "PYTHONDONTWRITEBYTECODE": "1",
            "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1",
            "PYTEST_ADDOPTS": "",
            "PYTHONHASHSEED": "0",
        }
        env.pop("PYTEST_CURRENT_TEST", None)
        executable_command = [sys.executable, *command[1:]] if command and command[0] == "python" else list(command)
        try:
            completed = subprocess.run(
                executable_command,
                cwd=self.root,
                timeout=self.timeout_seconds,
                text=True,
                capture_output=True,
                check=False,
                shell=False,
                env=env,
            )
            duration_ms = (time.perf_counter() - started) * 1000
            return self._record(
                "run_command",
                completed.returncode == 0,
                "command passed" if completed.returncode == 0 else "command failed",
                {"command": command},
                stdout_tail=completed.stdout[-1000:],
                stderr_tail=completed.stderr[-1000:],
                returncode=completed.returncode,
                duration_ms=duration_ms,
            )
        except subprocess.TimeoutExpired as exc:
            duration_ms = (time.perf_counter() - started) * 1000
            return self._record(
                "run_command",
                False,
                "command timed out",
                {"command": command, "timeout_seconds": self.timeout_seconds},
                stdout_tail=(exc.stdout or "")[-1000:] if isinstance(exc.stdout, str) else "",
                stderr_tail=(exc.stderr or "")[-1000:] if isinstance(exc.stderr, str) else "",
                returncode=None,
                duration_ms=duration_ms,
            )

    def _record(
        self,
        operation: str,
        success: bool,
        summary: str,
        args: dict[str, object],
        stdout_tail: str = "",
        stderr_tail: str = "",
        returncode: int | None = None,
        duration_ms: float = 0.0,
        content: str | None = None,
        files: list[str] | None = None,
    ) -> SandboxToolResult:
        trace = ToolCallTrace(
            operation=operation,
            success=success,
            summary=summary,
            args=args,
            stdout_tail=stdout_tail,
            stderr_tail=stderr_tail,
            returncode=returncode,
            duration_ms=duration_ms,
        )
        self.calls.append(trace)
        return SandboxToolResult(trace=trace, content=content, files=files)

    def _resolve_existing(self, path: str | Path) -> Path:
        requested = self._raw_relative(path)
        target = (self.root / requested).resolve(strict=True)
        self._ensure_inside(target)
        return target

    def _resolve_for_write(self, path: str | Path) -> Path:
        requested = self._raw_relative(path)
        parent = (self.root / requested).parent.resolve(strict=True)
        self._ensure_inside(parent)
        target = parent / requested.name
        if target.exists():
            self._ensure_inside(target.resolve(strict=True))
        return target

    def _raw_relative(self, path: str | Path) -> Path:
        requested = Path(path)
        if requested.is_absolute():
            raise SandboxViolation(f"absolute paths are not allowed: {path}")
        if ".." in requested.parts:
            raise SandboxViolation(f"parent traversal is not allowed: {path}")
        return requested

    def _ensure_inside(self, target: Path) -> None:
        if not target.is_relative_to(self.root):
            raise SandboxViolation(f"path escapes sandbox: {target}")
