"""Sandbox executor -- Docker-based isolated test execution.

Every code change proposed by Builder is validated in a sandbox
BEFORE being proposed to the admin. This ensures we never submit
broken code for review.

Execution model:
1. Copy workspace to temp directory (or use git worktree)
2. Apply proposed changes
3. Run test suite in Docker container
4. Collect results
5. Clean up
"""

from __future__ import annotations

import json
import logging
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

logger = logging.getLogger("hive.sandbox")


@dataclass
class SandboxResult:
    """Result of sandbox validation."""

    passed: bool
    tests_total: int = 0
    tests_passed: int = 0
    tests_failed: int = 0
    tests_errors: int = 0
    lint_errors: int = 0
    lint_warnings: int = 0
    type_errors: int = 0
    execution_time_seconds: float = 0.0
    stdout: str = ""
    stderr: str = ""
    details: dict[str, Any] = field(default_factory=dict)


class SandboxExecutor:
    """Runs tests in an isolated environment.

    Strategy (ordered by preference):
    1. Git worktree isolation (fast, no Docker overhead for unit tests)
    2. Docker container isolation (full isolation, required for integration tests)
    3. Subprocess in temp dir (fallback if Docker unavailable)
    """

    def __init__(
        self,
        workspace_root: Path,
        docker_image: str = "python:3.12-slim",
        timeout: int = 300,
    ) -> None:
        self._root = workspace_root
        self._docker_image = docker_image
        self._timeout = timeout

    async def validate(
        self,
        changed_files: list[str] | None = None,
        run_lint: bool = True,
        run_tests: bool = True,
        run_typecheck: bool = False,
    ) -> SandboxResult:
        """Run full sandbox validation on current workspace state.

        This is the primary method called by Builder after making changes.
        Runs in the current workspace (assumes Builder is on a feature branch).
        """
        result = SandboxResult(passed=True)
        details: dict[str, Any] = {}

        # Step 1: Lint check
        if run_lint:
            lint_result = self._run_lint()
            result.lint_errors = lint_result.get("errors", 0)
            result.lint_warnings = lint_result.get("warnings", 0)
            details["lint"] = lint_result
            if result.lint_errors > 0:
                result.passed = False

        # Step 2: Test suite
        if run_tests:
            test_result = self._run_tests(changed_files)
            result.tests_total = test_result.get("total", 0)
            result.tests_passed = test_result.get("passed", 0)
            result.tests_failed = test_result.get("failed", 0)
            result.tests_errors = test_result.get("errors", 0)
            result.execution_time_seconds = test_result.get("duration", 0.0)
            details["tests"] = test_result
            if result.tests_failed > 0 or result.tests_errors > 0:
                result.passed = False

        # Step 3: Type check (optional)
        if run_typecheck:
            type_result = self._run_typecheck()
            result.type_errors = type_result.get("errors", 0)
            details["typecheck"] = type_result
            if result.type_errors > 0:
                result.passed = False

        result.details = details
        logger.info(
            "Sandbox result: %s (tests: %d/%d, lint errors: %d)",
            "PASS" if result.passed else "FAIL",
            result.tests_passed,
            result.tests_total,
            result.lint_errors,
        )
        return result

    async def validate_in_docker(
        self,
        changed_files: list[str] | None = None,
    ) -> SandboxResult:
        """Run full validation inside a Docker container for maximum isolation.

        Creates a temporary container, mounts the workspace, runs tests, collects results.
        """
        if not self._docker_available():
            logger.warning("Docker not available, falling back to local validation")
            return await self.validate(changed_files)

        container_name = f"hive-sandbox-{id(self) % 10000}"
        try:
            # Run tests in container
            cmd = [
                "docker",
                "run",
                "--name",
                container_name,
                "--rm",
                "-v",
                f"{self._root}:/workspace:ro",
                "-w",
                "/workspace",
                self._docker_image,
                "sh",
                "-c",
                "pip install -q -e '.[dev]' 2>/dev/null; "
                "python -m pytest --tb=short -q --json-report --json-report-file=/tmp/results.json 2>&1; "
                "cat /tmp/results.json 2>/dev/null || echo '{}'",
            ]

            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=self._timeout,
                check=False,
            )

            # Parse results
            try:
                # Try to extract JSON from output
                lines = result.stdout.strip().splitlines()
                json_line = lines[-1] if lines else "{}"
                test_data = json.loads(json_line)
                summary = test_data.get("summary", {})
                return SandboxResult(
                    passed=summary.get("failed", 0) == 0 and summary.get("error", 0) == 0,
                    tests_total=summary.get("total", 0),
                    tests_passed=summary.get("passed", 0),
                    tests_failed=summary.get("failed", 0),
                    tests_errors=summary.get("error", 0),
                    stdout=result.stdout,
                    stderr=result.stderr,
                )
            except (json.JSONDecodeError, IndexError):
                # Fallback: check exit code
                return SandboxResult(
                    passed=result.returncode == 0,
                    stdout=result.stdout,
                    stderr=result.stderr,
                )
        except subprocess.TimeoutExpired:
            logger.error("Docker sandbox timed out after %ds", self._timeout)
            # Clean up container
            subprocess.run(["docker", "rm", "-f", container_name], capture_output=True, check=False)
            return SandboxResult(passed=False, stderr="Sandbox execution timed out")
        except FileNotFoundError:
            logger.error("docker command not found")
            return SandboxResult(passed=False, stderr="docker not found")

    # ------------------------------------------------------------------
    # Individual check runners
    # ------------------------------------------------------------------

    def _run_lint(self) -> dict[str, Any]:
        """Run ruff linter on the workspace."""
        try:
            result = subprocess.run(
                ["ruff", "check", "--output-format=json", "."],
                cwd=self._root,
                capture_output=True,
                text=True,
                timeout=60,
                check=False,
            )
            if result.stdout:
                try:
                    issues = json.loads(result.stdout)
                    errors = sum(1 for i in issues if i.get("fix") is None)
                    warnings = len(issues) - errors
                    return {"errors": errors, "warnings": warnings, "issues": len(issues)}
                except json.JSONDecodeError:
                    pass
            # ruff exit code 0 = clean, 1 = issues found
            return {"errors": 0 if result.returncode == 0 else 1, "warnings": 0}
        except (FileNotFoundError, subprocess.TimeoutExpired):
            return {"errors": 0, "warnings": 0, "note": "ruff not available"}

    def _run_tests(self, changed_files: list[str] | None = None) -> dict[str, Any]:
        """Run pytest and collect results."""
        cmd = ["python", "-m", "pytest", "--tb=short", "-q"]

        # If specific files changed, run related tests first
        if changed_files:
            test_files = self._find_related_tests(changed_files)
            if test_files:
                cmd.extend(test_files)

        try:
            result = subprocess.run(
                cmd,
                cwd=self._root,
                capture_output=True,
                text=True,
                timeout=self._timeout,
                check=False,
            )

            # Parse pytest output for counts
            return self._parse_pytest_output(result.stdout, result.stderr, result.returncode)
        except FileNotFoundError:
            return {"total": 0, "passed": 0, "failed": 0, "errors": 0, "note": "pytest not available"}
        except subprocess.TimeoutExpired:
            return {"total": 0, "passed": 0, "failed": 0, "errors": 1, "note": "test execution timed out"}

    def _run_typecheck(self) -> dict[str, Any]:
        """Run mypy type checker."""
        try:
            result = subprocess.run(
                ["mypy", "--ignore-missing-imports", "."],
                cwd=self._root,
                capture_output=True,
                text=True,
                timeout=120,
                check=False,
            )
            error_count = result.stdout.count(": error:")
            return {"errors": error_count, "output": result.stdout[:2000]}
        except (FileNotFoundError, subprocess.TimeoutExpired):
            return {"errors": 0, "note": "mypy not available"}

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

    def _find_related_tests(self, changed_files: list[str]) -> list[str]:
        """Find test files related to changed source files."""
        test_files: list[str] = []
        tests_dir = self._root / "tests"
        if not tests_dir.exists():
            return test_files

        for f in changed_files:
            path = Path(f)
            # Look for test_<filename>.py in tests/
            test_name = f"test_{path.stem}.py"
            candidates = list(tests_dir.rglob(test_name))
            test_files.extend(str(c.relative_to(self._root)) for c in candidates)

        return test_files

    def _parse_pytest_output(self, stdout: str, stderr: str, returncode: int) -> dict[str, Any]:
        """Parse pytest summary output for test counts."""
        result: dict[str, Any] = {
            "total": 0,
            "passed": 0,
            "failed": 0,
            "errors": 0,
            "duration": 0.0,
        }

        # Look for summary line like "5 passed, 2 failed in 3.45s"
        for line in (stdout + "\n" + stderr).splitlines():
            line_lower = line.lower().strip()
            if "passed" in line_lower or "failed" in line_lower or "error" in line_lower:
                import re

                passed_match = re.search(r"(\d+)\s+passed", line_lower)
                failed_match = re.search(r"(\d+)\s+failed", line_lower)
                error_match = re.search(r"(\d+)\s+error", line_lower)
                duration_match = re.search(r"in\s+([\d.]+)s", line_lower)

                if passed_match:
                    result["passed"] = int(passed_match.group(1))
                if failed_match:
                    result["failed"] = int(failed_match.group(1))
                if error_match:
                    result["errors"] = int(error_match.group(1))
                if duration_match:
                    result["duration"] = float(duration_match.group(1))

                result["total"] = result["passed"] + result["failed"] + result["errors"]
                break

        # If no summary found, use exit code
        if result["total"] == 0 and returncode == 5:
            # pytest exit code 5 = no tests collected (not a failure)
            result["passed"] = 0
        elif result["total"] == 0 and returncode != 0:
            result["errors"] = 1
            result["total"] = 1

        return result

    def _docker_available(self) -> bool:
        """Check if Docker is available."""
        try:
            result = subprocess.run(
                ["docker", "info"],
                capture_output=True,
                timeout=5,
                check=False,
            )
            return result.returncode == 0
        except (FileNotFoundError, subprocess.TimeoutExpired):
            return False
