"""Test execution tools for the autonomous debugging agent.

This module provides tools for running tests and verifying fixes:
- Running individual tests in sandboxed environments
- Running multiple verification runs to check determinism
"""

from __future__ import annotations

import asyncio
import logging
import os
import re
import subprocess
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional

from autodebug.llm_first.autonomous import BaseTool

logger = logging.getLogger(__name__)


@dataclass
class TestRunResult:
    """Result of a single test run."""

    passed: bool
    exit_code: int
    duration_seconds: float
    output: str
    error_output: str
    exception_type: Optional[str] = None
    exception_message: Optional[str] = None


class RunTestTool(BaseTool):
    """Run a single test in a sandbox.

    This tool runs a specific test to verify behavior or test a fix.
    Tests are run in isolation with proper environment setup.
    """

    name = "run_test"
    description = """
    Run a specific test to verify a fix or check behavior.

    Parameters:
    - test_path: Path to the test file
    - test_name: Name of the test function/method
    - working_dir: Optional working directory
    - timeout: Timeout in seconds (default 60)
    - env_vars: Optional dict of environment variables
    - apply_diff: Optional diff to apply before running

    Returns:
    - passed: Whether the test passed
    - exit_code: Process exit code
    - duration_seconds: How long the test took
    - output: Test stdout
    - error_output: Test stderr
    - exception_type: Type of exception if failed
    - exception_message: Exception message if failed

    Use this tool to:
    - Verify that a generated fix works
    - Check current test behavior
    - Validate fix candidates
    """

    def __init__(
        self,
        base_path: Optional[str] = None,
        default_timeout: int = 60,
        use_docker: bool = False,
        python_path: Optional[str] = None,
    ):
        """Initialize RunTestTool.

        Args:
            base_path: Base path for resolving test paths.
            default_timeout: Default timeout in seconds.
            use_docker: Whether to run tests in Docker.
            python_path: Path to Python interpreter.
        """
        self.base_path = Path(base_path) if base_path else Path.cwd()
        self.default_timeout = default_timeout
        self.use_docker = use_docker
        self.python_path = python_path or "python"

    async def execute(
        self,
        test_path: str,
        test_name: str,
        working_dir: Optional[str] = None,
        timeout: Optional[int] = None,
        env_vars: Optional[Dict[str, str]] = None,
        apply_diff: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Run a single test.

        Args:
            test_path: Path to test file.
            test_name: Test function/method name.
            working_dir: Working directory.
            timeout: Timeout in seconds.
            env_vars: Environment variables.
            apply_diff: Optional diff to apply first.

        Returns:
            Test result dictionary.
        """
        try:
            # Resolve paths
            test_file = self._resolve_path(test_path)
            work_dir = Path(working_dir) if working_dir else self.base_path

            # Validate test exists
            if not test_file.exists():
                error_message = f"Test file not found: {test_path}"
                return {
                    "passed": False,
                    "exit_code": -1,
                    "duration_seconds": 0,
                    "output": "",
                    "error_output": error_message,
                    "error": error_message,
                }

            # Apply diff if provided
            original_content = None
            if apply_diff:
                original_content = test_file.read_text()
                try:
                    patched_content = self._apply_diff(original_content, apply_diff)
                    test_file.write_text(patched_content)
                except Exception as e:
                    error_message = f"Failed to apply diff: {e}"
                    return {
                        "passed": False,
                        "exit_code": -1,
                        "duration_seconds": 0,
                        "output": "",
                        "error_output": error_message,
                        "error": error_message,
                    }

            try:
                # Run the test
                result = await self._run_pytest(
                    test_file=test_file,
                    test_name=test_name,
                    work_dir=work_dir,
                    timeout=timeout or self.default_timeout,
                    env_vars=env_vars,
                )

                return {
                    "passed": result.passed,
                    "exit_code": result.exit_code,
                    "duration_seconds": round(result.duration_seconds, 3),
                    "output": (result.output or "")[:5000],
                    "error_output": (result.error_output or "")[:5000],
                    "error": (result.error_output or "")[:5000] if not result.passed else None,
                    "exception_type": result.exception_type,
                    "exception_message": result.exception_message,
                }

            finally:
                # Restore original file if we modified it
                if original_content is not None:
                    test_file.write_text(original_content)

        except Exception as e:
            logger.error(f"Test execution error: {e}")
            error_message = f"Execution error: {e}"
            return {
                "passed": False,
                "exit_code": -1,
                "duration_seconds": 0,
                "output": "",
                "error_output": error_message,
                "error": error_message,
            }

    def _resolve_path(self, path: str) -> Path:
        """Resolve path to absolute path."""
        p = Path(path)
        if p.is_absolute():
            return p
        return (self.base_path / p).resolve()

    async def _run_pytest(
        self,
        test_file: Path,
        test_name: str,
        work_dir: Path,
        timeout: int,
        env_vars: Optional[Dict[str, str]],
    ) -> TestRunResult:
        """Run test using pytest.

        Args:
            test_file: Path to test file.
            test_name: Test name.
            work_dir: Working directory.
            timeout: Timeout in seconds.
            env_vars: Environment variables.

        Returns:
            TestRunResult object.
        """
        # Build pytest command
        test_spec = f"{test_file}::{test_name}"
        cmd = [
            self.python_path, "-m", "pytest",
            test_spec,
            "-v",
            "--tb=short",
            "-x",  # Stop on first failure
            "--timeout", str(timeout),
        ]

        # Build environment
        env = os.environ.copy()
        if env_vars:
            env.update(env_vars)

        # Run in thread pool
        start_time = time.time()

        loop = asyncio.get_event_loop()
        result = await loop.run_in_executor(
            None,
            lambda: subprocess.run(
                cmd,
                cwd=str(work_dir),
                env=env,
                capture_output=True,
                text=True,
                timeout=timeout + 5,
            ),
        )

        duration = time.time() - start_time

        # Parse output for exception info
        exception_type, exception_message = self._parse_exception(
            result.stdout + result.stderr
        )

        return TestRunResult(
            passed=result.returncode == 0,
            exit_code=result.returncode,
            duration_seconds=duration,
            output=result.stdout or "",
            error_output=result.stderr or "",
            exception_type=exception_type,
            exception_message=exception_message,
        )

    def _parse_exception(self, output: str) -> tuple[Optional[str], Optional[str]]:
        """Parse exception info from pytest output.

        Args:
            output: Combined stdout/stderr.

        Returns:
            Tuple of (exception_type, exception_message).
        """
        # Look for common exception patterns
        patterns = [
            r"(\w+Error): (.+)",
            r"(\w+Exception): (.+)",
            r"E\s+(\w+): (.+)",
        ]

        for pattern in patterns:
            match = re.search(pattern, output)
            if match:
                return match.group(1), match.group(2)[:500]

        return None, None

    def _apply_diff(self, original: str, diff: str) -> str:
        """Apply a unified diff to content.

        Args:
            original: Original content.
            diff: Unified diff.

        Returns:
            Patched content.
        """
        # Use the validator's apply function
        from autodebug.autonomous.validator import apply_unified_diff
        return apply_unified_diff(original, diff)


class RunVerificationTool(BaseTool):
    """Run verification (N runs) to test determinism.

    This tool runs a test multiple times to verify that a fix
    makes the test deterministic (passes consistently).
    """

    name = "run_verification"
    description = """
    Run the test multiple times to verify a fix is deterministic.

    Parameters:
    - test_path: Path to the test file
    - test_name: Name of the test function/method
    - runs: Number of runs (default 10)
    - parallel: Number of parallel runs (default 2)
    - timeout_per_run: Timeout per run in seconds (default 60)
    - apply_diff: Optional diff to apply before running

    Returns:
    - passed: Number of passed runs
    - failed: Number of failed runs
    - total: Total runs
    - pass_rate: Pass rate (0.0-1.0)
    - is_deterministic: True if all runs had same result
    - unique_failures: List of unique failure types
    - run_details: Details of each run

    Use this tool to:
    - Verify that a fix eliminates flakiness
    - Measure test stability
    - Confirm deterministic behavior
    """

    def __init__(
        self,
        base_path: Optional[str] = None,
        default_runs: int = 10,
        default_parallel: int = 2,
        default_timeout: int = 60,
    ):
        """Initialize RunVerificationTool.

        Args:
            base_path: Base path for resolving test paths.
            default_runs: Default number of runs.
            default_parallel: Default parallel runs.
            default_timeout: Default timeout per run.
        """
        self.base_path = Path(base_path) if base_path else Path.cwd()
        self.default_runs = default_runs
        self.default_parallel = default_parallel
        self.default_timeout = default_timeout

    async def execute(
        self,
        test_path: str,
        test_name: str,
        runs: Optional[int] = None,
        parallel: Optional[int] = None,
        timeout_per_run: Optional[int] = None,
        apply_diff: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Run verification runs.

        Args:
            test_path: Path to test file.
            test_name: Test function name.
            runs: Number of runs.
            parallel: Parallel runs.
            timeout_per_run: Timeout per run.
            apply_diff: Optional diff to apply.

        Returns:
            Verification result dictionary.
        """
        try:
            num_runs = runs or self.default_runs
            num_parallel = parallel or self.default_parallel
            timeout = timeout_per_run or self.default_timeout

            # Create a temporary directory for this verification
            with tempfile.TemporaryDirectory() as tmpdir:
                tmp_path = Path(tmpdir)

                # Copy test file to temp location if applying diff
                test_file = self._resolve_path(test_path)

                if not test_file.exists():
                    return {
                        "passed": 0,
                        "failed": 0,
                        "total": 0,
                        "pass_rate": 0.0,
                        "is_deterministic": False,
                        "error": f"Test file not found: {test_path}",
                    }

                # Apply diff if provided
                if apply_diff:
                    original_content = test_file.read_text()
                    try:
                        from autodebug.autonomous.validator import apply_unified_diff
                        patched = apply_unified_diff(original_content, apply_diff)

                        # Write to temp file
                        temp_test = tmp_path / test_file.name
                        temp_test.write_text(patched)
                        test_file = temp_test
                    except Exception as e:
                        return {
                            "passed": 0,
                            "failed": 0,
                            "total": 0,
                            "pass_rate": 0.0,
                            "is_deterministic": False,
                            "error": f"Failed to apply diff: {e}",
                        }

                # Run tests in batches
                run_tool = RunTestTool(
                    base_path=str(self.base_path),
                    default_timeout=timeout,
                )

                results = []
                for batch_start in range(0, num_runs, num_parallel):
                    batch_end = min(batch_start + num_parallel, num_runs)
                    batch_size = batch_end - batch_start

                    # Run batch in parallel
                    tasks = [
                        run_tool.execute(
                            test_path=str(test_file),
                            test_name=test_name,
                            timeout=timeout,
                        )
                        for _ in range(batch_size)
                    ]

                    batch_results = await asyncio.gather(*tasks)
                    results.extend(batch_results)

                # Analyze results
                return self._analyze_results(results)

        except Exception as e:
            import traceback
            logger.error(f"Verification error: {e}\n{traceback.format_exc()}")
            return {
                "passed": 0,
                "failed": 0,
                "total": 0,
                "pass_rate": 0.0,
                "is_deterministic": False,
                "error": f"Verification failed: {e}",
            }

    def _resolve_path(self, path: str) -> Path:
        """Resolve path to absolute path."""
        p = Path(path)
        if p.is_absolute():
            return p
        return (self.base_path / p).resolve()

    def _analyze_results(
        self,
        results: List[Dict[str, Any]],
    ) -> Dict[str, Any]:
        """Analyze verification run results.

        Args:
            results: List of run results.

        Returns:
            Analysis dictionary.
        """
        # Filter out None results (from failed async tasks)
        valid_results = [r for r in results if r is not None]
        passed = sum(1 for r in valid_results if r.get("passed", False))
        failed = len(valid_results) - passed
        total = len(valid_results)

        pass_rate = passed / total if total > 0 else 0.0

        # Check determinism
        all_passed = passed == total
        all_failed = failed == total
        is_deterministic = all_passed or all_failed

        # Collect unique failure types
        failure_signatures = {}
        for r in valid_results:
            if not r.get("passed", False):
                exc_type = r.get("exception_type") or "Unknown"
                exc_msg = (r.get("exception_message") or "")[:100]
                sig = f"{exc_type}: {exc_msg}"

                if sig not in failure_signatures:
                    failure_signatures[sig] = 0
                failure_signatures[sig] += 1

        unique_failures = [
            {"signature": sig, "count": count}
            for sig, count in sorted(
                failure_signatures.items(),
                key=lambda x: -x[1]
            )
        ]

        # Build run details (summarized)
        run_details = [
            {
                "run": i + 1,
                "passed": r.get("passed", False),
                "duration_seconds": r.get("duration_seconds", 0),
                "exception_type": r.get("exception_type"),
            }
            for i, r in enumerate(valid_results)
        ]

        # Calculate timing stats
        durations = [r.get("duration_seconds", 0) for r in valid_results]
        avg_duration = sum(durations) / len(durations) if durations else 0
        min_duration = min(durations) if durations else 0
        max_duration = max(durations) if durations else 0

        return {
            "passed": passed,
            "failed": failed,
            "total": total,
            "pass_rate": round(pass_rate, 3),
            "is_deterministic": is_deterministic,
            "unique_failures": unique_failures,
            "flakiness_score": self._calculate_flakiness(valid_results),
            "timing": {
                "avg_seconds": round(avg_duration, 3),
                "min_seconds": round(min_duration, 3),
                "max_seconds": round(max_duration, 3),
            },
            "run_details": run_details,
            "verdict": self._get_verdict(pass_rate, is_deterministic),
        }

    def _calculate_flakiness(self, results: List[Dict[str, Any]]) -> float:
        """Calculate flakiness score from results.

        A flakiness score measures how often results alternate.
        0 = completely stable (all same)
        1 = maximally flaky (alternates every run)

        Args:
            results: List of run results.

        Returns:
            Flakiness score (0.0-1.0).
        """
        if len(results) < 2:
            return 0.0

        transitions = 0
        for i in range(1, len(results)):
            if results[i].get("passed") != results[i - 1].get("passed"):
                transitions += 1

        return transitions / (len(results) - 1)

    def _get_verdict(self, pass_rate: float, is_deterministic: bool) -> str:
        """Get human-readable verdict.

        Args:
            pass_rate: Pass rate (0-1).
            is_deterministic: Whether results are deterministic.

        Returns:
            Verdict string.
        """
        if is_deterministic and pass_rate == 1.0:
            return "PROVEN_FIX"
        elif is_deterministic and pass_rate == 0.0:
            return "NO_EFFECT"
        elif pass_rate >= 0.9:
            return "MOSTLY_FIXED"
        elif pass_rate >= 0.5:
            return "PARTIALLY_FIXED"
        else:
            return "STILL_FLAKY"
