"""Test fix execution for the repair pipeline.

Applies fixes and runs tests to verify they actually fix the issue
without introducing regressions.
"""

from __future__ import annotations

import asyncio
import os
import resource
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional

if TYPE_CHECKING:
    from autodebug.pipeline.repair_pipeline import GeneratedFix


def _set_sandbox_limits() -> None:
    """Set resource limits for sandboxed test execution.

    Applied via preexec_fn to subprocess calls to prevent
    runaway processes from consuming excessive resources.
    Only applies on Unix-like systems.
    """
    if sys.platform == "win32":
        return
    try:
        resource.setrlimit(resource.RLIMIT_CPU, (60, 60))  # 60s CPU time
        resource.setrlimit(
            resource.RLIMIT_AS,
            (512 * 1024 * 1024, 512 * 1024 * 1024),  # 512MB virtual memory
        )
        resource.setrlimit(
            resource.RLIMIT_FSIZE,
            (10 * 1024 * 1024, 10 * 1024 * 1024),  # 10MB max file writes
        )
    except (ValueError, OSError):
        pass  # Skip if limits not supported on this platform


@dataclass
class TestResult:
    """Result from running a test on a fix."""

    __test__ = False
    test_passed: bool
    exit_code: int
    output: str
    duration_ms: float
    error_message: Optional[str] = None
    regression_results: Optional[Dict[str, bool]] = None

    def __str__(self) -> str:
        status = "PASSED" if self.test_passed else "FAILED"
        return f"TestResult({status}, exit_code={self.exit_code}, {self.duration_ms:.1f}ms)"


class FixTester:
    """Tests fixes by applying them and running tests.

    Applies fix diffs to source code in an isolated workspace,
    runs tests, and reports whether the fix resolves the issue.
    """

    def __init__(
        self,
        workspace_dir: Optional[str] = None,
        use_patch_command: bool = True,
    ):
        """Initialize the fix tester.

        Args:
            workspace_dir: Directory for test workspaces
            use_patch_command: Whether to use patch command for applying diffs
        """
        self.workspace_dir = workspace_dir
        self.use_patch_command = use_patch_command

    async def test_fix(
        self,
        fix: GeneratedFix,
        test_command: str,
        file_path: str,
        original_code: str,
        timeout: int = 30,
    ) -> TestResult:
        """Apply fix and run test.

        Args:
            fix: The fix to test
            test_command: Command to run the test
            file_path: Path to the file being fixed
            original_code: Original source code
            timeout: Test timeout in seconds

        Returns:
            TestResult with pass/fail and output
        """
        start = time.perf_counter()

        # Create isolated workspace
        with tempfile.TemporaryDirectory(dir=self.workspace_dir) as tmpdir:
            # Write original code to temp file
            temp_file = Path(tmpdir) / Path(file_path).name
            temp_file.write_text(original_code, encoding="utf-8")

            # Apply fix
            try:
                self._apply_fix(temp_file, fix)
            except Exception as e:
                return TestResult(
                    test_passed=False,
                    exit_code=-1,
                    output="",
                    duration_ms=(time.perf_counter() - start) * 1000,
                    error_message=f"Failed to apply fix: {e}",
                )

            # Run test
            try:
                result = await self._run_test(
                    test_command=test_command,
                    cwd=tmpdir,
                    timeout=timeout,
                )

                return TestResult(
                    test_passed=result["passed"],
                    exit_code=result["exit_code"],
                    output=result["output"],
                    duration_ms=(time.perf_counter() - start) * 1000,
                )

            except asyncio.TimeoutError:
                return TestResult(
                    test_passed=False,
                    exit_code=-1,
                    output="Test timed out",
                    duration_ms=timeout * 1000,
                    error_message="Test exceeded timeout",
                )
            except Exception as e:
                return TestResult(
                    test_passed=False,
                    exit_code=-1,
                    output=str(e),
                    duration_ms=(time.perf_counter() - start) * 1000,
                    error_message=f"Test execution error: {e}",
                )

    def _apply_fix(self, file_path: Path, fix: GeneratedFix) -> None:
        """Apply fix diff to file.

        Args:
            file_path: Path to file to modify
            fix: Fix containing unified diff

        Raises:
            Exception: If fix cannot be applied
        """
        if self.use_patch_command:
            self._apply_with_patch(file_path, fix.unified_diff)
        else:
            self._apply_with_python(file_path, fix.unified_diff)

    def _apply_with_patch(self, file_path: Path, diff: str) -> None:
        """Apply diff using patch command.

        Args:
            file_path: Path to file
            diff: Unified diff string

        Raises:
            Exception: If patch fails
        """
        # Write diff to temp file
        with tempfile.NamedTemporaryFile(
            mode="w",
            suffix=".patch",
            delete=False,
            encoding="utf-8",
        ) as f:
            f.write(diff)
            patch_file = f.name

        try:
            result = subprocess.run(
                ["patch", str(file_path), patch_file],
                capture_output=True,
                text=True,
                timeout=10,
            )

            if result.returncode != 0:
                raise Exception(f"patch failed: {result.stderr}")

        finally:
            os.unlink(patch_file)

    def _apply_with_python(self, file_path: Path, diff: str) -> None:
        """Apply diff using pure Python.

        Parses unified diff and applies changes.

        Args:
            file_path: Path to file
            diff: Unified diff string

        Raises:
            Exception: If diff cannot be applied
        """
        # Read original file
        original_lines = file_path.read_text(encoding="utf-8").split("\n")

        # Parse diff
        changes = self._parse_unified_diff(diff)

        # Apply changes
        new_lines = self._apply_changes(original_lines, changes)

        # Write result
        file_path.write_text("\n".join(new_lines), encoding="utf-8")

    def _parse_unified_diff(self, diff: str) -> List[Dict[str, Any]]:
        """Parse unified diff into change operations.

        Args:
            diff: Unified diff string

        Returns:
            List of change operations
        """
        changes = []
        current_hunk = None

        for line in diff.split("\n"):
            if line.startswith("@@"):
                # Parse hunk header: @@ -start,count +start,count @@
                import re

                match = re.search(r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@", line)
                if match:
                    current_hunk = {
                        "old_start": int(match.group(1)),
                        "new_start": int(match.group(3)),
                        "lines": [],
                    }
                    changes.append(current_hunk)
            elif current_hunk is not None:
                if line.startswith("-") and not line.startswith("---"):
                    current_hunk["lines"].append(("remove", line[1:]))
                elif line.startswith("+") and not line.startswith("+++"):
                    current_hunk["lines"].append(("add", line[1:]))
                elif line.startswith(" "):
                    current_hunk["lines"].append(("context", line[1:]))

        return changes

    def _apply_changes(
        self,
        original_lines: List[str],
        changes: List[Dict[str, Any]],
    ) -> List[str]:
        """Apply parsed changes to lines.

        Args:
            original_lines: Original file lines
            changes: Parsed change operations

        Returns:
            Modified lines
        """
        result = list(original_lines)

        # Apply hunks in reverse order to preserve line numbers
        for hunk in reversed(changes):
            start = hunk["old_start"] - 1  # Convert to 0-indexed
            new_lines = []

            for op, content in hunk["lines"]:
                if op == "add":
                    new_lines.append(content)
                elif op == "context":
                    new_lines.append(content)
                # "remove" lines are skipped

            # Calculate how many lines to remove
            remove_count = sum(1 for op, _ in hunk["lines"] if op in ("remove", "context"))

            # Replace lines
            result[start : start + remove_count] = new_lines

        return result

    async def _run_test(
        self,
        test_command: str,
        cwd: str,
        timeout: int,
    ) -> Dict[str, Any]:
        """Run test command asynchronously.

        Args:
            test_command: Command to run
            cwd: Working directory
            timeout: Timeout in seconds

        Returns:
            Dict with passed, exit_code, output
        """
        # Run in subprocess with sandbox resource limits
        process = await asyncio.create_subprocess_shell(
            test_command,
            cwd=cwd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.STDOUT,
            preexec_fn=_set_sandbox_limits,
        )

        try:
            stdout, _ = await asyncio.wait_for(
                process.communicate(),
                timeout=timeout,
            )

            output = stdout.decode("utf-8", errors="replace") if stdout else ""

            return {
                "passed": process.returncode == 0,
                "exit_code": process.returncode or 0,
                "output": output,
            }

        except asyncio.TimeoutError:
            process.kill()
            raise

    async def run_regression_tests(
        self,
        fix: GeneratedFix,
        test_commands: List[str],
        file_path: str,
        original_code: str,
        timeout: int = 60,
    ) -> Dict[str, TestResult]:
        """Run multiple tests to check for regressions.

        Args:
            fix: Fix to test
            test_commands: List of test commands to run
            file_path: Path to file being fixed
            original_code: Original source code
            timeout: Per-test timeout

        Returns:
            Dict mapping test command to result
        """
        results = {}

        for test_cmd in test_commands:
            result = await self.test_fix(
                fix=fix,
                test_command=test_cmd,
                file_path=file_path,
                original_code=original_code,
                timeout=timeout,
            )
            results[test_cmd] = result

        return results

    async def _run_regression_check(
        self,
        fix: GeneratedFix,
        test_file: str,
        file_path: str,
        original_code: str,
        timeout: int = 60,
    ) -> TestResult:
        """Run the entire test module/directory as a regression check.

        After the originally-failing test passes, run the broader module
        to detect regressions introduced by the fix.

        Args:
            fix: The fix to test
            test_file: Path to the specific test file that was fixed
            file_path: Path to the source file being fixed
            original_code: Original source code
            timeout: Timeout in seconds

        Returns:
            TestResult for the regression run
        """
        # Determine the test module directory from the test file path
        test_path = Path(test_file)
        if test_path.is_file():
            # Run the entire test file (module-level regression)
            regression_cmd = f"pytest {test_file} -x --tb=short -q"
        elif test_path.is_dir():
            regression_cmd = f"pytest {test_file} -x --tb=short -q"
        else:
            # Fall back to parent directory of the test file
            regression_cmd = f"pytest {test_path.parent} -x --tb=short -q"

        return await self.test_fix(
            fix=fix,
            test_command=regression_cmd,
            file_path=file_path,
            original_code=original_code,
            timeout=timeout,
        )

    def quick_syntax_test(
        self,
        fix: GeneratedFix,
        original_code: str,
    ) -> bool:
        """Quick syntax check without running tests.

        Args:
            fix: Fix to check
            original_code: Original source code

        Returns:
            True if resulting code has valid syntax
        """
        try:
            # Apply fix to get new code
            original_lines = original_code.split("\n")
            changes = self._parse_unified_diff(fix.unified_diff)
            new_lines = self._apply_changes(original_lines, changes)
            new_code = "\n".join(new_lines)

            # Try to compile
            compile(new_code, "<fix>", "exec")
            return True

        except SyntaxError:
            return False
        except Exception:
            return False
