"""Core validation pipeline for generated fixes.

Validates that all generated fixes compile, type-check,
and can be applied cleanly before being suggested to users.
"""

from __future__ import annotations

import ast
import importlib.util
import os
import re
import sys
import tempfile
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List, Optional, Tuple

if TYPE_CHECKING:
    from autodebug.autonomous.hypothesis import Hypothesis, Patch


@dataclass
class ValidationError:
    """An error found during validation."""

    stage: str  # "syntax", "type", "patch", "import", "lint"
    message: str
    line: Optional[int] = None
    column: Optional[int] = None
    suggestion: Optional[str] = None

    def __str__(self) -> str:
        location = ""
        if self.line:
            location = f" at line {self.line}"
            if self.column:
                location += f":{self.column}"
        return f"[{self.stage}]{location}: {self.message}"


@dataclass
class ValidationWarning:
    """A warning found during validation (non-blocking)."""

    stage: str
    message: str
    line: Optional[int] = None
    column: Optional[int] = None


@dataclass
class ValidationResult:
    """Complete result of validation pipeline."""

    is_valid: bool
    syntax_ok: bool
    types_ok: bool
    applies_cleanly: bool
    imports_ok: bool
    lint_ok: bool = True  # No new static analysis issues (Plan 55)
    errors: List[ValidationError] = field(default_factory=list)
    warnings: List[ValidationWarning] = field(default_factory=list)
    duration_ms: float = 0.0
    patched_code: Optional[str] = None

    @property
    def error_count(self) -> int:
        """Number of errors."""
        return len(self.errors)

    @property
    def warning_count(self) -> int:
        """Number of warnings."""
        return len(self.warnings)

    def summary(self) -> str:
        """Get a one-line summary of validation result."""
        status = "VALID" if self.is_valid else "INVALID"
        stages = []
        if self.syntax_ok:
            stages.append("syntax:OK")
        else:
            stages.append("syntax:FAIL")
        if self.types_ok:
            stages.append("types:OK")
        else:
            stages.append("types:FAIL")
        if self.applies_cleanly:
            stages.append("patch:OK")
        else:
            stages.append("patch:FAIL")
        if self.imports_ok:
            stages.append("imports:OK")
        else:
            stages.append("imports:FAIL")
        if not self.lint_ok:
            stages.append("lint:FAIL")

        return f"{status} ({', '.join(stages)}) in {self.duration_ms:.1f}ms"


class FixValidator:
    """Validates that generated fixes compile and type-check.

    Runs a multi-stage validation pipeline:
    1. Apply patch and check syntax
    2. Type checking (Ty or mypy)
    3. Import validation
    4. Static analysis (optional, using Trunk.io)
    """

    def __init__(
        self,
        use_ty: bool = True,
        strict_types: bool = False,
        validate_imports: bool = True,
        static_analysis: bool = False,
    ):
        """Initialize the fix validator.

        Args:
            use_ty: Use Ty type checker (faster) instead of mypy
            strict_types: Enable strict type checking mode
            validate_imports: Validate that new imports can be resolved
            static_analysis: Run static analysis to detect regressions (Plan 55)
        """
        self.use_ty = use_ty
        self.strict_types = strict_types
        self.validate_imports = validate_imports
        self.static_analysis = static_analysis

        # Lazy load type checker and static analyzer
        self._type_checker = None
        self._static_analyzer = None

    @property
    def type_checker(self):
        """Get or create the type checker."""
        if self._type_checker is None:
            from autodebug.validation.ty_checker import MypyChecker, TyChecker

            if self.use_ty:
                self._type_checker = TyChecker(strict=self.strict_types)
            else:
                self._type_checker = MypyChecker(strict=self.strict_types)
        return self._type_checker

    @property
    def trunk_analyzer(self):
        """Get or create the static analyzer (Plan 55)."""
        if self._static_analyzer is None:
            try:
                from autodebug.analysis.trunk_analyzer import TrunkAnalyzer

                self._static_analyzer = TrunkAnalyzer()
            except ImportError:
                self._static_analyzer = None
        return self._static_analyzer

    def validate(
        self,
        hypothesis: Hypothesis,
        source_code: str,
        file_path: Optional[str] = None,
    ) -> ValidationResult:
        """Run full validation pipeline on a fix hypothesis.

        Args:
            hypothesis: The hypothesis containing the patch
            source_code: Original source code to patch
            file_path: Optional path for context

        Returns:
            ValidationResult with all checks
        """
        start_ns = time.perf_counter_ns()
        errors: List[ValidationError] = []
        warnings: List[ValidationWarning] = []

        # Stage 1: Apply patch and check syntax
        patched_code, patch_ok, patch_errors = self._apply_and_check_syntax(
            hypothesis.patch, source_code
        )
        errors.extend(patch_errors)

        if not patch_ok:
            return ValidationResult(
                is_valid=False,
                syntax_ok=False,
                types_ok=False,
                applies_cleanly=False,
                imports_ok=False,
                errors=errors,
                warnings=warnings,
                duration_ms=(time.perf_counter_ns() - start_ns) / 1_000_000,
                patched_code=patched_code,
            )

        # Stage 2: Type checking
        types_ok, type_errors = self.type_checker.check(patched_code)
        errors.extend(type_errors)

        # Stage 3: Import validation
        imports_ok = True
        if self.validate_imports:
            imports_ok, import_errors = self._validate_imports(
                hypothesis.patch.raw_diff
            )
            errors.extend(import_errors)

        # Stage 4: Static analysis (Plan 55)
        lint_ok = True
        if self.static_analysis and self.trunk_analyzer:
            lint_ok, lint_errors, lint_warnings = self._run_static_analysis(
                source_code, patched_code
            )
            errors.extend(lint_errors)
            warnings.extend(lint_warnings)

        # Stage 5: Security scan (always runs for auto-generated fixes)
        security_ok = True
        if patched_code:
            security_ok, security_errors = self.check_security(patched_code)
            errors.extend(security_errors)

        is_valid = patch_ok and types_ok and imports_ok and lint_ok and security_ok

        # TODO(H-139): Add PATCH-SIM behavior similarity check here.
        # PATCH-SIM compares the runtime behavior (execution traces, outputs)
        # of the original and patched code on a corpus of inputs to verify
        # semantic equivalence beyond syntactic validation. This would provide
        # an additional confidence signal that the fix preserves intended
        # behavior while correcting the bug.

        return ValidationResult(
            is_valid=is_valid,
            syntax_ok=True,
            types_ok=types_ok,
            applies_cleanly=patch_ok,
            imports_ok=imports_ok,
            lint_ok=lint_ok,
            errors=errors,
            warnings=warnings,
            duration_ms=(time.perf_counter_ns() - start_ns) / 1_000_000,
            patched_code=patched_code,
        )

    def validate_code(self, code: str) -> ValidationResult:
        """Validate standalone code without a patch.

        Useful for validating auto-fixed code.

        Args:
            code: Python source code to validate

        Returns:
            ValidationResult
        """
        start_ns = time.perf_counter_ns()
        errors: List[ValidationError] = []
        warnings: List[ValidationWarning] = []

        # Check syntax
        syntax_ok, syntax_errors = self._check_syntax(code)
        errors.extend(syntax_errors)

        if not syntax_ok:
            return ValidationResult(
                is_valid=False,
                syntax_ok=False,
                types_ok=False,
                applies_cleanly=True,  # N/A for standalone code
                imports_ok=False,
                errors=errors,
                warnings=warnings,
                duration_ms=(time.perf_counter_ns() - start_ns) / 1_000_000,
                patched_code=code,
            )

        # Type checking
        types_ok, type_errors = self.type_checker.check(code)
        errors.extend(type_errors)

        # Import validation
        imports_ok, import_errors = self._validate_imports_from_code(code)
        errors.extend(import_errors)

        is_valid = syntax_ok and types_ok and imports_ok

        return ValidationResult(
            is_valid=is_valid,
            syntax_ok=syntax_ok,
            types_ok=types_ok,
            applies_cleanly=True,
            imports_ok=imports_ok,
            errors=errors,
            warnings=warnings,
            duration_ms=(time.perf_counter_ns() - start_ns) / 1_000_000,
            patched_code=code,
        )

    def _apply_and_check_syntax(
        self, patch: Patch, source: str
    ) -> Tuple[Optional[str], bool, List[ValidationError]]:
        """Apply patch and validate syntax.

        Args:
            patch: The patch to apply
            source: Original source code

        Returns:
            Tuple of (patched_code, ok, errors)
        """
        errors: List[ValidationError] = []

        # Apply patch
        patched = self._apply_patch(patch.raw_diff, source)
        if patched is None:
            errors.append(
                ValidationError(
                    stage="patch",
                    message="Patch does not apply cleanly to source code",
                )
            )
            return None, False, errors

        # Check syntax
        syntax_ok, syntax_errors = self._check_syntax(patched)
        errors.extend(syntax_errors)

        return patched, syntax_ok, errors

    def _check_syntax(self, code: str) -> Tuple[bool, List[ValidationError]]:
        """Check Python syntax using LibCST or ast.

        Args:
            code: Python source code

        Returns:
            Tuple of (ok, errors)
        """
        errors: List[ValidationError] = []

        # Try LibCST first (preserves formatting info)
        try:
            import libcst as cst

            try:
                cst.parse_module(code)
                return True, []
            except cst.ParserSyntaxError as e:
                # Extract line number from exception
                line = None
                if hasattr(e, "raw_line"):
                    line = e.raw_line
                errors.append(
                    ValidationError(
                        stage="syntax",
                        message=str(e),
                        line=line,
                    )
                )
                return False, errors

        except ImportError:
            pass

        # Fall back to ast
        try:
            ast.parse(code)
            return True, []
        except SyntaxError as e:
            errors.append(
                ValidationError(
                    stage="syntax",
                    message=e.msg or str(e),
                    line=e.lineno,
                    column=e.offset,
                )
            )
            return False, errors

    def _apply_patch(self, diff: str, source: str) -> Optional[str]:
        """Apply unified diff to source code.

        Uses patch-ng if available, falls back to manual application.

        Args:
            diff: Unified diff text
            source: Original source code

        Returns:
            Patched code or None if patch fails
        """
        # Try patch-ng first
        try:
            import patch_ng

            with tempfile.TemporaryDirectory() as tmpdir:
                # Write source to temp file
                src_path = os.path.join(tmpdir, "source.py")
                with open(src_path, "w", encoding="utf-8") as f:
                    f.write(source)

                # Apply patch
                pset = patch_ng.fromstring(diff.encode())
                if pset and pset.apply(strip=0, root=tmpdir):
                    with open(src_path, encoding="utf-8") as f:
                        return f.read()

                # Try with strip=1 (common for git diffs)
                pset = patch_ng.fromstring(diff.encode())
                if pset and pset.apply(strip=1, root=tmpdir):
                    with open(src_path, encoding="utf-8") as f:
                        return f.read()

        except ImportError:
            pass
        except Exception:
            pass

        # Fall back to manual application
        return self._apply_patch_manual(diff, source)

    def _apply_patch_manual(self, diff: str, source: str) -> Optional[str]:
        """Manually apply a unified diff.

        Simple implementation for common cases.

        Args:
            diff: Unified diff text
            source: Original source code

        Returns:
            Patched code or None if patch fails
        """
        try:
            lines = source.split("\n")
            diff_lines = diff.split("\n")

            # Parse hunks
            i = 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
                if line.startswith("@@"):
                    # Format: @@ -start,count +start,count @@
                    import re

                    match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
                    if not match:
                        i += 1
                        continue

                    old_start = int(match.group(1)) - 1  # 0-indexed
                    new_start = int(match.group(2)) - 1

                    # Apply hunk
                    j = i + 1
                    offset = 0
                    while j < len(diff_lines):
                        hunk_line = diff_lines[j]
                        if hunk_line.startswith("@@") or hunk_line.startswith("---"):
                            break

                        if hunk_line.startswith("-"):
                            # Delete line
                            if old_start + offset < len(lines):
                                lines.pop(old_start + offset)
                        elif hunk_line.startswith("+"):
                            # Add line
                            lines.insert(old_start + offset, hunk_line[1:])
                            offset += 1
                        elif hunk_line.startswith(" "):
                            # Context line
                            offset += 1
                        j += 1

                    i = j
                else:
                    i += 1

            return "\n".join(lines)

        except Exception:
            return None

    def _validate_imports(self, diff: str) -> Tuple[bool, List[ValidationError]]:
        """Validate new imports added by the patch.

        Args:
            diff: Unified diff text

        Returns:
            Tuple of (ok, errors)
        """
        errors: List[ValidationError] = []

        # Extract new imports from diff
        new_imports = []
        for line in diff.split("\n"):
            if line.startswith("+") and not line.startswith("+++"):
                line_content = line[1:].strip()
                if line_content.startswith("import ") or line_content.startswith("from "):
                    new_imports.append(line_content)

        for imp in new_imports:
            module = self._extract_module_name(imp)
            if module and not self._can_resolve(module):
                errors.append(
                    ValidationError(
                        stage="import",
                        message=f"Cannot resolve import: {module}",
                        suggestion=f"pip install {module.split('.')[0]}",
                    )
                )

        return len(errors) == 0, errors

    def _validate_imports_from_code(
        self, code: str
    ) -> Tuple[bool, List[ValidationError]]:
        """Validate all imports in code.

        Args:
            code: Python source code

        Returns:
            Tuple of (ok, errors)
        """
        errors: List[ValidationError] = []

        try:
            tree = ast.parse(code)
            for node in ast.walk(tree):
                if isinstance(node, ast.Import):
                    for alias in node.names:
                        if not self._can_resolve(alias.name):
                            errors.append(
                                ValidationError(
                                    stage="import",
                                    message=f"Cannot resolve import: {alias.name}",
                                    line=node.lineno,
                                    suggestion=f"pip install {alias.name.split('.')[0]}",
                                )
                            )
                elif isinstance(node, ast.ImportFrom):
                    if node.module and not self._can_resolve(node.module):
                        errors.append(
                            ValidationError(
                                stage="import",
                                message=f"Cannot resolve import: {node.module}",
                                line=node.lineno,
                                suggestion=f"pip install {node.module.split('.')[0]}",
                            )
                        )
        except SyntaxError:
            pass  # Syntax errors handled elsewhere

        return len(errors) == 0, errors

    def _extract_module_name(self, import_stmt: str) -> str:
        """Extract module name from import statement.

        Args:
            import_stmt: Import statement string

        Returns:
            Module name
        """
        if import_stmt.startswith("from "):
            # from module import name
            parts = import_stmt.split()
            if len(parts) >= 2:
                return parts[1]
        elif import_stmt.startswith("import "):
            # import module
            parts = import_stmt.split()
            if len(parts) >= 2:
                return parts[1].split(",")[0].strip()
        return ""

    def _can_resolve(self, module: str) -> bool:
        """Check if a module can be imported.

        Args:
            module: Module name

        Returns:
            True if module can be resolved
        """
        if not module:
            return True

        root_module = module.split(".")[0]

        # Check stdlib
        if hasattr(sys, "stdlib_module_names"):
            if root_module in sys.stdlib_module_names:
                return True

        # Common stdlib modules not in stdlib_module_names (Python < 3.10)
        common_stdlib = {
            "os", "sys", "re", "json", "typing", "dataclasses", "collections",
            "itertools", "functools", "pathlib", "datetime", "time", "math",
            "random", "hashlib", "tempfile", "subprocess", "threading",
            "multiprocessing", "logging", "unittest", "io", "ast", "inspect",
            "contextlib", "copy", "enum", "abc", "argparse", "configparser",
        }
        if root_module in common_stdlib:
            return True

        # Check if importable
        try:
            spec = importlib.util.find_spec(root_module)
            return spec is not None
        except (ModuleNotFoundError, ValueError):
            return False

    # Security patterns that should never appear in auto-generated fixes.
    # These are checked unconditionally regardless of the static_analysis flag.
    _SECURITY_PATTERNS = [
        (re.compile(r"\beval\s*\("), "eval() can execute arbitrary code"),
        (re.compile(r"\bexec\s*\("), "exec() can execute arbitrary code"),
        (re.compile(r"\bos\.system\s*\("), "os.system() can execute arbitrary shell commands"),
        (re.compile(r"\bsubprocess\.call\s*\([^)]*shell\s*=\s*True"), "subprocess with shell=True is a command injection risk"),
        (re.compile(r"\bsubprocess\.Popen\s*\([^)]*shell\s*=\s*True"), "subprocess.Popen with shell=True is a command injection risk"),
        (re.compile(r"\bpickle\.loads?\s*\("), "pickle.load/loads can execute arbitrary code during deserialization"),
        (re.compile(r"\byaml\.load\s*\([^)]*\)\s*$", re.MULTILINE), "yaml.load without Loader= can execute arbitrary code"),
        (re.compile(r"""(?:password|secret|api_key|token)\s*=\s*['"][^'"]{8,}['"]""", re.IGNORECASE), "Possible hardcoded credential"),
    ]

    def check_security(
        self,
        code: str,
    ) -> Tuple[bool, List[ValidationError]]:
        """Check for security anti-patterns in generated code.

        This gate runs unconditionally for auto-generated fixes to prevent
        introducing vulnerabilities like eval(), os.system(), pickle.loads(),
        or hardcoded credentials.

        Args:
            code: The code to check (typically the added lines from a diff)

        Returns:
            Tuple of (ok, errors)
        """
        errors: List[ValidationError] = []

        for pattern, message in self._SECURITY_PATTERNS:
            for match in pattern.finditer(code):
                # Determine approximate line number
                line_num = code[:match.start()].count("\n") + 1
                errors.append(
                    ValidationError(
                        stage="security",
                        message=f"Security risk: {message}",
                        line=line_num,
                        suggestion="Remove or replace with a safe alternative",
                    )
                )

        return len(errors) == 0, errors

    def check_invariant_preservation(
        self,
        original_code: str,
        patched_code: str,
    ) -> Tuple[bool, List[ValidationWarning]]:
        """Check that the patch preserves structural invariants.

        A lightweight invariant inference approach inspired by Invalidator
        (IEEE TSE 2023): compare assertion counts and guard conditions
        between original and patched code.  If the patch removes assertions
        or guard clauses without replacing them, it may be overfitting.

        Args:
            original_code: Original source code
            patched_code: Patched source code

        Returns:
            Tuple of (ok, warnings).  Returns ok=True with warnings when
            invariants decrease -- callers decide whether to block.
        """
        warnings: List[ValidationWarning] = []

        try:
            original_tree = ast.parse(original_code)
            patched_tree = ast.parse(patched_code)
        except SyntaxError:
            # Syntax errors are handled elsewhere; skip invariant check
            return True, warnings

        original_asserts = sum(
            1 for node in ast.walk(original_tree) if isinstance(node, ast.Assert)
        )
        patched_asserts = sum(
            1 for node in ast.walk(patched_tree) if isinstance(node, ast.Assert)
        )

        original_raises = sum(
            1 for node in ast.walk(original_tree) if isinstance(node, ast.Raise)
        )
        patched_raises = sum(
            1 for node in ast.walk(patched_tree) if isinstance(node, ast.Raise)
        )

        if patched_asserts < original_asserts:
            warnings.append(
                ValidationWarning(
                    stage="invariant",
                    message=(
                        f"Patch removes {original_asserts - patched_asserts} assertion(s) "
                        f"({original_asserts} -> {patched_asserts}). "
                        "This may indicate overfitting or symptom suppression."
                    ),
                )
            )

        if patched_raises < original_raises:
            warnings.append(
                ValidationWarning(
                    stage="invariant",
                    message=(
                        f"Patch removes {original_raises - patched_raises} raise statement(s) "
                        f"({original_raises} -> {patched_raises}). "
                        "Error reporting paths may be silently suppressed."
                    ),
                )
            )

        # A decrease in either metric is a signal but not a hard failure
        return True, warnings

    def _run_static_analysis(
        self,
        original: str,
        patched: str,
    ) -> Tuple[bool, List[ValidationError], List[ValidationWarning]]:
        """Run static analysis to detect regressions (Plan 55).

        Uses Trunk.io to analyze code before and after the fix,
        detecting if the fix introduces new issues.

        Args:
            original: Original source code
            patched: Patched source code

        Returns:
            Tuple of (ok, errors, warnings)
        """
        errors: List[ValidationError] = []
        warnings: List[ValidationWarning] = []

        if not self.trunk_analyzer:
            return True, errors, warnings

        try:
            diff_analysis = self.trunk_analyzer.analyze_diff(original, patched)

            # New issues from the fix are errors
            for issue in diff_analysis.new_issues:
                errors.append(
                    ValidationError(
                        stage="lint",
                        message=f"Fix introduces: {issue.message}",
                        line=issue.line,
                        column=issue.column,
                        suggestion=issue.suggestion,
                    )
                )

            # Remaining issues are warnings (pre-existing)
            for issue in diff_analysis.remaining_issues:
                warnings.append(
                    ValidationWarning(
                        stage="lint",
                        message=f"Pre-existing issue: {issue.message}",
                        line=issue.line,
                        column=issue.column,
                    )
                )

            # Fix is OK if no new issues were introduced
            is_ok = not diff_analysis.is_regression
            return is_ok, errors, warnings

        except Exception:
            # Don't block validation if static analysis fails
            return True, errors, warnings


def validate_hypothesis(
    hypothesis: Hypothesis,
    source_code: str,
    use_ty: bool = True,
) -> ValidationResult:
    """Convenience function to validate a hypothesis.

    Args:
        hypothesis: The hypothesis to validate
        source_code: Original source code
        use_ty: Use Ty type checker

    Returns:
        ValidationResult
    """
    validator = FixValidator(use_ty=use_ty)
    return validator.validate(hypothesis, source_code)
