"""Fix validation for autonomous debugging.

This module validates hypotheses by applying patches to harnesses
and running them to verify fixes work.
"""

from __future__ import annotations

import json
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional

from autodebug.autonomous.hypothesis import Hypothesis, Patch


@dataclass
class RunResult:
    """Result of a single validation run."""

    exit_code: int
    passed: bool
    exception: Optional[Dict[str, Any]] = None
    output: Optional[str] = None
    error: Optional[str] = None


@dataclass
class ValidationResult:
    """Result of validating a hypothesis."""

    hypothesis: Hypothesis
    runs: List[RunResult]
    fixes_failure: bool
    same_failure: bool
    introduces_new_failure: bool
    original_exception: Optional[Dict[str, Any]] = None

    @property
    def all_pass(self) -> bool:
        """Check if all runs passed."""
        return all(r.passed for r in self.runs)

    @property
    def pass_rate(self) -> float:
        """Calculate pass rate."""
        if not self.runs:
            return 0.0
        return sum(1 for r in self.runs if r.passed) / len(self.runs)

    @property
    def verdict(self) -> str:
        """Get validation verdict."""
        if self.fixes_failure:
            return "PROVEN_FIX"
        elif self.same_failure:
            return "NO_EFFECT"
        elif self.introduces_new_failure:
            return "NEW_FAILURE"
        else:
            return "INCONCLUSIVE"


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

    Args:
        original: Original file content
        diff: Unified diff to apply

    Returns:
        Patched content
    """
    original_lines = original.splitlines(keepends=True)
    result_lines = []

    # Parse diff hunks
    hunk_pattern = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")

    diff_lines = diff.splitlines(keepends=True)
    i = 0
    original_pos = 0

    while i < len(diff_lines):
        line = diff_lines[i]

        # Skip header lines
        if line.startswith("---") or line.startswith("+++"):
            i += 1
            continue

        # Parse hunk header
        match = hunk_pattern.match(line)
        if match:
            old_start = int(match.group(1)) - 1  # 0-indexed
            old_count = int(match.group(2) or 1)

            # Copy unchanged lines before this hunk
            while original_pos < old_start and original_pos < len(original_lines):
                result_lines.append(original_lines[original_pos])
                original_pos += 1

            i += 1

            # Process hunk content
            while i < len(diff_lines):
                if diff_lines[i].startswith("@@"):
                    break
                elif diff_lines[i].startswith("---") or diff_lines[i].startswith("+++"):
                    break

                line = diff_lines[i]
                if line.startswith("-"):
                    # Remove line from original
                    original_pos += 1
                elif line.startswith("+"):
                    # Add line to result
                    result_lines.append(line[1:])  # Remove leading +
                elif line.startswith(" ") or line == "\n":
                    # Context line
                    if original_pos < len(original_lines):
                        result_lines.append(original_lines[original_pos])
                        original_pos += 1
                    else:
                        result_lines.append(line[1:] if line.startswith(" ") else line)

                i += 1
        else:
            i += 1

    # Copy remaining original lines
    while original_pos < len(original_lines):
        result_lines.append(original_lines[original_pos])
        original_pos += 1

    return "".join(result_lines)


def generate_patched_harness(
    original_harness: Path,
    patch: Patch,
    output_dir: Optional[Path] = None,
) -> Path:
    """Create a new harness with the patch applied.

    Args:
        original_harness: Path to original harness directory or tarball
        patch: Patch to apply
        output_dir: Optional output directory (uses temp if not provided)

    Returns:
        Path to patched harness directory
    """
    # Create output directory
    if output_dir is None:
        output_dir = Path(tempfile.mkdtemp(prefix="patched_harness_"))

    patched_dir = output_dir / f"patched_{patch.hash[:8]}"
    patched_dir.mkdir(parents=True, exist_ok=True)

    # Extract or copy original harness
    if original_harness.suffix in (".gz", ".tar", ".tgz"):
        shutil.unpack_archive(str(original_harness), patched_dir)
    elif original_harness.is_dir():
        shutil.copytree(original_harness, patched_dir, dirs_exist_ok=True)
    else:
        raise ValueError(f"Unknown harness format: {original_harness}")

    # Apply each file patch
    for file_patch in patch.files:
        target_path = patched_dir / file_patch.path

        if target_path.exists():
            original_content = target_path.read_text()
            patched_content = apply_unified_diff(original_content, file_patch.diff)
            target_path.write_text(patched_content)
        else:
            # New file - extract content from diff
            lines = []
            for line in file_patch.diff.splitlines():
                if line.startswith("+") and not line.startswith("+++"):
                    lines.append(line[1:])
            target_path.parent.mkdir(parents=True, exist_ok=True)
            target_path.write_text("\n".join(lines))

    return patched_dir


def run_harness(
    harness_dir: Path,
    timeout: int = 60,
) -> RunResult:
    """Run a harness and capture results.

    Args:
        harness_dir: Path to harness directory
        timeout: Timeout in seconds

    Returns:
        RunResult with exit code and output
    """
    # Build Docker image
    tag = f"autodebug-validate-{harness_dir.name}"

    try:
        # Build
        build_result = subprocess.run(
            ["docker", "build", "-t", tag, str(harness_dir)],
            capture_output=True,
            text=True,
            timeout=timeout * 2,
        )

        if build_result.returncode != 0:
            return RunResult(
                exit_code=-1,
                passed=False,
                error=f"Build failed: {build_result.stderr[:500]}",
            )

        # Run with output capture
        output_dir = harness_dir / "output"
        output_dir.mkdir(exist_ok=True)

        # H-126: Add --read-only filesystem with tmpfs for /tmp
        run_result = subprocess.run(
            [
                "docker", "run", "--rm",
                "--network=none",
                "--memory=512m",
                "--cpus=1",
                "--pids-limit=256",
                "--security-opt=no-new-privileges",
                "--read-only",
                "--tmpfs", "/tmp:rw,noexec,nosuid,size=64m",
                "-v", f"{output_dir}:/output",
                tag,
            ],
            capture_output=True,
            text=True,
            timeout=timeout,
        )

        # Parse result
        result_path = output_dir / "result.json"
        exception = None
        if result_path.exists():
            try:
                data = json.loads(result_path.read_text())
                exception = data.get("exception")
            except (json.JSONDecodeError, KeyError):
                pass

        # L-01: Parse test framework output, not just exit code
        passed = run_result.returncode == 0
        stdout = run_result.stdout[:1000]
        stderr = run_result.stderr[:1000] if run_result.returncode != 0 else None

        # Cross-check against pytest markers in output for robustness:
        # even if exit code is 0, detect suppressed failures
        if passed and run_result.stdout:
            output_lower = run_result.stdout.lower()
            if " failed" in output_lower or " error" in output_lower:
                # Pytest reports failures but exit code was 0 (unusual)
                passed = False
            # Detect bare except suppression patterns in patched code
            if "except:" in run_result.stdout and "pass" in run_result.stdout:
                passed = False
        elif not passed and run_result.stdout:
            # If exit code != 0 but all tests show PASSED, treat as build issue
            # rather than test failure (e.g., import warnings causing non-zero exit)
            pass

        return RunResult(
            exit_code=run_result.returncode,
            passed=passed,
            exception=exception,
            output=stdout,
            error=stderr,
        )

    except subprocess.TimeoutExpired:
        return RunResult(
            exit_code=-1,
            passed=False,
            error="Timeout",
        )
    except Exception as e:
        return RunResult(
            exit_code=-1,
            passed=False,
            error=str(e),
        )
    finally:
        # Cleanup Docker image
        try:
            subprocess.run(
                ["docker", "rmi", "-f", tag],
                capture_output=True,
                timeout=30,
            )
        except Exception:
            pass


def _same_exception(exc1: Optional[Dict], exc2: Optional[Dict]) -> bool:
    """Check if two exceptions are the same type."""
    if exc1 is None and exc2 is None:
        return True
    if exc1 is None or exc2 is None:
        return False
    return (
        exc1.get("exc_type") == exc2.get("exc_type")
        and exc1.get("message", "")[:100] == exc2.get("message", "")[:100]
    )


def validate_hypothesis(
    hypothesis: Hypothesis,
    original_harness: Path,
    original_exception: Optional[Dict[str, Any]] = None,
    num_runs: int = 3,
    timeout: int = 60,
    regression_test_dir: Optional[Path] = None,
) -> ValidationResult:
    """Validate a hypothesis by applying patch and running harness.

    Implements a generate-test-rerank pipeline: after checking
    that the patched harness passes, optionally runs the project's
    existing test suite to detect regressions.

    Args:
        hypothesis: Hypothesis to validate
        original_harness: Path to original harness
        original_exception: Original exception to compare against
        num_runs: Number of validation runs
        timeout: Timeout per run
        regression_test_dir: Optional path to project test suite
            for regression checking

    Returns:
        ValidationResult with verdict
    """
    with tempfile.TemporaryDirectory() as tmpdir:
        output_dir = Path(tmpdir)

        try:
            # Generate patched harness
            patched_harness = generate_patched_harness(
                original_harness,
                hypothesis.patch,
                output_dir,
            )
        except Exception as e:
            # Patch application failed
            return ValidationResult(
                hypothesis=hypothesis,
                runs=[RunResult(exit_code=-1, passed=False, error=f"Patch failed: {e}")],
                fixes_failure=False,
                same_failure=False,
                introduces_new_failure=False,
                original_exception=original_exception,
            )

        # Run validation
        runs = []
        for _ in range(num_runs):
            result = run_harness(patched_harness, timeout)
            runs.append(result)

        # Analyze results
        all_pass = all(r.passed for r in runs)
        any_exception = any(r.exception for r in runs)

        # Check if it's the same failure
        same_failure = False
        introduces_new = False

        if not all_pass and any_exception:
            # Compare exceptions
            run_exceptions = [r.exception for r in runs if r.exception]
            if run_exceptions:
                same_failure = all(
                    _same_exception(e, original_exception)
                    for e in run_exceptions
                )
                if not same_failure:
                    introduces_new = True

        # M-01: Regression test step -- if the harness passes,
        # run the project's existing test suite against the patched code
        # to catch regressions. Only upgrade to PROVEN_FIX if no regressions.
        regression_passed = True
        if all_pass and regression_test_dir and regression_test_dir.is_dir():
            try:
                regression_result = subprocess.run(
                    [
                        "python", "-m", "pytest",
                        str(regression_test_dir),
                        "--tb=no", "-q", "--timeout=30",
                    ],
                    capture_output=True,
                    text=True,
                    timeout=timeout * 3,
                    cwd=str(patched_harness),
                )
                if regression_result.returncode != 0:
                    regression_passed = False
                    introduces_new = True
            except (subprocess.TimeoutExpired, Exception):
                # Regression test timeout or error is not a blocker,
                # but prevents PROVEN_FIX upgrade
                regression_passed = False

        return ValidationResult(
            hypothesis=hypothesis,
            runs=runs,
            fixes_failure=all_pass and regression_passed,
            same_failure=same_failure and not all_pass,
            introduces_new_failure=introduces_new,
            original_exception=original_exception,
        )


def validate_hypotheses(
    hypotheses: List[Hypothesis],
    original_harness: Path,
    original_exception: Optional[Dict[str, Any]] = None,
    num_runs: int = 3,
    timeout: int = 60,
    progress_callback: Optional[callable] = None,
) -> List[ValidationResult]:
    """Validate multiple hypotheses.

    Args:
        hypotheses: List of hypotheses to validate
        original_harness: Path to original harness
        original_exception: Original exception to compare against
        num_runs: Number of validation runs per hypothesis
        timeout: Timeout per run
        progress_callback: Optional callback(completed, total)

    Returns:
        List of ValidationResults
    """
    results = []

    for i, hypothesis in enumerate(hypotheses):
        result = validate_hypothesis(
            hypothesis,
            original_harness,
            original_exception,
            num_runs,
            timeout,
        )
        results.append(result)

        if progress_callback:
            progress_callback(i + 1, len(hypotheses))

    return results


def format_validation_report(results: List[ValidationResult]) -> str:
    """Format validation results as human-readable report.

    Args:
        results: List of validation results

    Returns:
        Formatted report string
    """
    lines = [
        "",
        "Validation Results",
        "=" * 50,
        "",
    ]

    proven = [r for r in results if r.fixes_failure]
    no_effect = [r for r in results if r.same_failure]
    new_failure = [r for r in results if r.introduces_new_failure]

    lines.append(f"Total hypotheses: {len(results)}")
    lines.append(f"Proven fixes: {len(proven)}")
    lines.append(f"No effect: {len(no_effect)}")
    lines.append(f"Introduced new failures: {len(new_failure)}")
    lines.append("")

    if proven:
        lines.append("### Proven Fixes")
        lines.append("")
        for r in proven:
            lines.append(f"- Hypothesis {r.hypothesis.id} ({r.hypothesis.confidence:.0%} confidence)")
            lines.append(f"  Strategy: {r.hypothesis.strategy}")
            lines.append(f"  Pass rate: {r.pass_rate:.0%}")
            lines.append(f"  Root cause: {r.hypothesis.root_cause[:100]}")
            lines.append("")

    if no_effect:
        lines.append("### No Effect")
        lines.append("")
        for r in no_effect:
            lines.append(f"- Hypothesis {r.hypothesis.id}: Same failure persists")

    if new_failure:
        lines.append("")
        lines.append("### Introduced New Failures")
        lines.append("")
        for r in new_failure:
            lines.append(f"- Hypothesis {r.hypothesis.id}: Different failure occurred")
            if r.runs and r.runs[0].exception:
                exc = r.runs[0].exception
                lines.append(f"  New exception: {exc.get('exc_type', '?')}")

    return "\n".join(lines)
