"""Self-healing test engine.

This module provides the healing engine that applies fixes to detected
flaky test patterns. It supports:
- Template-based fixes for common patterns
- LLM-powered fixes for complex cases
- Validation of fixes through test execution
"""

from __future__ import annotations

import difflib
import logging
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass, field
from enum import Enum, auto
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from autodebug.healing.detector import DetectionResult, FlakyDetector
from autodebug.healing.languages import get_adapter
from autodebug.healing.patterns import (
    FixStrategy,
    PatternMatch,
)

logger = logging.getLogger(__name__)


class HealingStrategy(Enum):
    """Strategy for applying fixes."""

    TEMPLATE_ONLY = auto()     # Only use template-based fixes
    LLM_ONLY = auto()          # Only use LLM-powered fixes
    TEMPLATE_THEN_LLM = auto() # Try template first, fall back to LLM
    INTERACTIVE = auto()       # Ask for user confirmation


@dataclass
class HealingResult:
    """Result of a healing operation."""

    match: PatternMatch
    success: bool
    strategy_used: FixStrategy
    original_code: str
    fixed_code: str
    diff: str
    explanation: str
    confidence: float
    validated: bool = False
    validation_passed: bool = False
    validation_error: Optional[str] = None
    validation_runs_passed: int = 0
    validation_runs_total: int = 0
    llm_cost: float = 0.0

    def get_unified_diff(self) -> str:
        """Get unified diff between original and fixed code."""
        original_lines = self.original_code.splitlines(keepends=True)
        fixed_lines = self.fixed_code.splitlines(keepends=True)

        diff = difflib.unified_diff(
            original_lines,
            fixed_lines,
            fromfile=f"a/{self.match.file_path}",
            tofile=f"b/{self.match.file_path}",
            lineterm="",
        )
        return "".join(diff)


@dataclass
class HealingSession:
    """A healing session that tracks all fixes applied."""

    results: List[HealingResult] = field(default_factory=list)
    total_matches: int = 0
    successful_fixes: int = 0
    failed_fixes: int = 0
    validated_fixes: int = 0
    total_llm_cost: float = 0.0

    def add_result(self, result: HealingResult) -> None:
        """Add a healing result to the session."""
        self.results.append(result)
        self.total_matches += 1
        if result.success:
            self.successful_fixes += 1
        else:
            self.failed_fixes += 1
        if result.validated and result.validation_passed:
            self.validated_fixes += 1
        self.total_llm_cost += result.llm_cost

    def get_summary(self) -> Dict[str, Any]:
        """Get session summary."""
        return {
            "total_matches": self.total_matches,
            "successful_fixes": self.successful_fixes,
            "failed_fixes": self.failed_fixes,
            "validated_fixes": self.validated_fixes,
            "success_rate": (
                self.successful_fixes / self.total_matches * 100
                if self.total_matches > 0 else 0
            ),
            "validation_rate": (
                self.validated_fixes / self.successful_fixes * 100
                if self.successful_fixes > 0 else 0
            ),
            "total_llm_cost": round(self.total_llm_cost, 4),
        }


class TestHealer:
    """Engine for healing flaky tests."""

    def __init__(
        self,
        strategy: HealingStrategy = HealingStrategy.TEMPLATE_THEN_LLM,
        validate_fixes: bool = True,
        validation_runs: int = 5,
        llm_model: str = "claude-sonnet-4-20250514",
        llm_provider: str = "anthropic",
        dry_run: bool = False,
    ):
        """Initialize the healer.

        Args:
            strategy: Strategy for applying fixes
            validate_fixes: Whether to validate fixes by running tests
            validation_runs: Number of validation runs (must all pass)
            llm_model: Model to use for LLM-powered fixes
            llm_provider: LLM provider (anthropic, openai)
            dry_run: If True, don't actually modify files
        """
        self.strategy = strategy
        self.validate_fixes = validate_fixes
        self.validation_runs = validation_runs
        self.llm_model = llm_model
        self.llm_provider = llm_provider
        self.dry_run = dry_run

        # LLM clients (lazy loaded)
        self._anthropic_client = None
        self._openai_client = None

    def heal_match(
        self,
        match: PatternMatch,
        file_content: Optional[str] = None,
        context: Optional[Dict[str, Any]] = None,
    ) -> HealingResult:
        """Apply a fix for a single pattern match.

        Args:
            match: The pattern match to fix
            file_content: Full file content (read from disk if not provided)
            context: Additional context for fix templates

        Returns:
            Healing result with fixed code and diff
        """
        # Read file if content not provided
        if file_content is None:
            file_path = Path(match.file_path)
            try:
                file_content = file_path.read_text()
            except Exception as e:
            logging.error(f'Command execution error: {e}')
                return HealingResult(
                    match=match,
                    success=False,
                    strategy_used=FixStrategy.TEMPLATE,
                    original_code=match.matched_code,
                    fixed_code=match.matched_code,
                    diff="",
                    explanation=f"Failed to read file: {e}",
                    confidence=0,
                )

        # Get language adapter
        adapter = get_adapter(match.language)
        if not adapter:
            return HealingResult(
                match=match,
                success=False,
                strategy_used=FixStrategy.TEMPLATE,
                original_code=match.matched_code,
                fixed_code=match.matched_code,
                diff="",
                explanation=f"No adapter for language: {match.language}",
                confidence=0,
            )

        # Try to apply fix based on strategy
        result = None

        if self.strategy in (HealingStrategy.TEMPLATE_ONLY, HealingStrategy.TEMPLATE_THEN_LLM):
            result = self._apply_template_fix(match, file_content, adapter, context)

        if (result is None or not result.success) and self.strategy in (
            HealingStrategy.LLM_ONLY,
            HealingStrategy.TEMPLATE_THEN_LLM,
        ):
            result = self._apply_llm_fix(match, file_content, adapter, context)

        if result is None:
            result = HealingResult(
                match=match,
                success=False,
                strategy_used=FixStrategy.TEMPLATE,
                original_code=match.matched_code,
                fixed_code=match.matched_code,
                diff="",
                explanation="No fix strategy available",
                confidence=0,
            )

        # Validate fix if requested
        if result.success and self.validate_fixes:
            result = self._validate_fix(result, file_content)

        # Apply fix to file if not dry run
        if result.success and not self.dry_run:
            self._apply_fix_to_file(match.file_path, file_content, result.fixed_code)

        return result

    def _apply_template_fix(
        self,
        match: PatternMatch,
        file_content: str,
        adapter: Any,
        context: Optional[Dict[str, Any]] = None,
    ) -> Optional[HealingResult]:
        """Apply a template-based fix."""
        pattern = match.pattern

        if not pattern.fix_templates:
            return None

        # Find best template for this language
        best_template = None
        for template in pattern.fix_templates:
            if adapter.name in template.templates:
                best_template = template
                break

        if not best_template:
            return None

        # Build context for template
        fix_context = context or {}

        # Extract context from match
        fix_context.update(match.additional_info)

        # Try to extract common context values
        if "duration" not in fix_context:
            # Try to extract duration from sleep calls
            duration_match = re.search(
                r"sleep\s*\(\s*(\d+(?:\.\d+)?)\s*\)",
                match.matched_code,
                re.IGNORECASE,
            )
            if duration_match:
                fix_context["duration"] = duration_match.group(1)

        if "expression" not in fix_context:
            fix_context["expression"] = match.matched_code

        # Try to extract timeout values
        if "timeout" not in fix_context:
            timeout_match = re.search(
                r"timeout\s*=\s*(\d+(?:\.\d+)?)",
                match.matched_code,
            )
            if timeout_match:
                fix_context["timeout"] = timeout_match.group(1)

        # Try to extract env var name from os.environ patterns
        if "var_name" not in fix_context:
            env_match = re.search(
                r"os\.environ\s*\[\s*['\"](\w+)['\"]\s*\]",
                match.matched_code,
            )
            if env_match:
                fix_context["var_name"] = env_match.group(1).lower()

        # Check if we have all required context
        missing_context = []
        for needed in best_template.context_needed:
            if needed not in fix_context:
                missing_context.append(needed)

        if missing_context:
            # Can't apply template without full context
            logger.debug(f"Missing context for template: {missing_context}")
            return None

        # Validate extracted context values are sensible
        if "timeout" in fix_context:
            try:
                if float(fix_context["timeout"]) <= 0:
                    logger.debug("Invalid timeout value <= 0, skipping template fix")
                    return None
            except (ValueError, TypeError):
                pass

        if "duration" in fix_context:
            try:
                if float(fix_context["duration"]) <= 0:
                    logger.debug("Invalid duration value <= 0, skipping template fix")
                    return None
            except (ValueError, TypeError):
                pass

        # Apply the fix template
        try:
            template_code = best_template.templates[adapter.name]
            fixed_code = adapter.apply_fix(
                file_content,
                match,
                template_code,
                fix_context,
            )

            # Validate that the fixed code is syntactically valid
            if match.language == "python":
                try:
                    import ast as _ast
                    _ast.parse(fixed_code)
                except SyntaxError as syn_err:
                    logger.warning(
                        f"Template fix produced invalid Python: {syn_err} "
                        f"(pattern={match.pattern.id}, file={match.file_path}:{match.line_number})"
                    )
                    # Return a tracked failure instead of None so it doesn't silently
                    # fall through to LLM (which may also be broken)
                    return HealingResult(
                        match=match,
                        success=False,
                        strategy_used=FixStrategy.TEMPLATE,
                        original_code=match.matched_code,
                        fixed_code=match.matched_code,
                        diff="",
                        explanation=f"Template fix produced invalid Python: {syn_err}",
                        confidence=0,
                    )

            # Generate diff
            diff = self._generate_diff(file_content, fixed_code, match.file_path)

            return HealingResult(
                match=match,
                success=True,
                strategy_used=best_template.strategy,
                original_code=self._get_match_lines(file_content, match),
                fixed_code=self._get_fixed_lines(fixed_code, match),
                diff=diff,
                explanation=best_template.description,
                confidence=best_template.confidence * match.confidence,
            )

        except Exception as e:
            logger.warning(f"Failed to apply template fix: {e}")
            return HealingResult(
                match=match,
                success=False,
                strategy_used=FixStrategy.TEMPLATE,
                original_code=match.matched_code,
                fixed_code=match.matched_code,
                diff="",
                explanation=f"Template fix failed: {e}",
                confidence=0,
            )

    def _apply_llm_fix(
        self,
        match: PatternMatch,
        file_content: str,
        adapter: Any,
        context: Optional[Dict[str, Any]] = None,
    ) -> Optional[HealingResult]:
        """Apply an LLM-powered fix."""
        # Build prompt for LLM
        prompt = self._build_llm_prompt(match, file_content)

        # Call LLM
        try:
            response, cost = self._call_llm(prompt)
        except Exception as e:
            logger.error(f"LLM call failed: {e}")
            return HealingResult(
                match=match,
                success=False,
                strategy_used=FixStrategy.LLM,
                original_code=match.matched_code,
                fixed_code=match.matched_code,
                diff="",
                explanation=f"LLM call failed: {e}",
                confidence=0,
                llm_cost=0,
            )

        # Parse LLM response
        fixed_code, explanation = self._parse_llm_response(response)

        if not fixed_code:
            return HealingResult(
                match=match,
                success=False,
                strategy_used=FixStrategy.LLM,
                original_code=match.matched_code,
                fixed_code=match.matched_code,
                diff="",
                explanation="Failed to parse LLM response",
                confidence=0,
                llm_cost=cost,
            )

        # Apply the fix to the full file content
        try:
            new_content = self._apply_llm_fix_to_content(
                file_content, match, fixed_code
            )

            # Validate that the LLM fix is syntactically valid
            if match.language == "python":
                try:
                    import ast as _ast
                    _ast.parse(new_content)
                except SyntaxError as syn_err:
                    logger.warning(
                        f"LLM fix produced invalid Python: {syn_err} "
                        f"(pattern={match.pattern.id}, file={match.file_path}:{match.line_number})"
                    )
                    return HealingResult(
                        match=match,
                        success=False,
                        strategy_used=FixStrategy.LLM,
                        original_code=match.matched_code,
                        fixed_code=match.matched_code,
                        diff="",
                        explanation=f"LLM fix failed syntax validation: {syn_err}",
                        confidence=0,
                        llm_cost=cost,
                    )

            diff = self._generate_diff(file_content, new_content, match.file_path)

            return HealingResult(
                match=match,
                success=True,
                strategy_used=FixStrategy.LLM,
                original_code=self._get_match_lines(file_content, match),
                fixed_code=fixed_code,
                diff=diff,
                explanation=explanation,
                confidence=0.7,  # LLM fixes have moderate confidence
                llm_cost=cost,
            )

        except Exception as e:
            logger.error(f"Failed to apply LLM fix: {e}")
            return HealingResult(
                match=match,
                success=False,
                strategy_used=FixStrategy.LLM,
                original_code=match.matched_code,
                fixed_code=match.matched_code,
                diff="",
                explanation=f"Failed to apply fix: {e}",
                confidence=0,
                llm_cost=cost,
            )

    def _build_llm_prompt(self, match: PatternMatch, file_content: str) -> str:
        """Build prompt for LLM fix generation."""
        context_before = "\n".join(match.context_before)
        context_after = "\n".join(match.context_after)

        # Extract import statements from the file for LLM context
        import_lines = []
        for line in file_content.splitlines()[:50]:
            stripped = line.strip()
            if stripped.startswith(("import ", "from ")):
                import_lines.append(line)
        import_context = "\n".join(import_lines[:20]) if import_lines else "No imports visible"

        return f"""You are an expert at fixing flaky tests. A flaky test is one that sometimes passes and sometimes fails without any code changes.

**Pattern Detected**: {match.pattern.name}
**Category**: {match.pattern.category.name}
**Description**: {match.pattern.description}
**Language**: {match.language}
**File**: {match.file_path}
**Line**: {match.line_number}

**Why this causes flakiness**:
{match.pattern.fix_explanation}

**File imports** (for reference):
```{match.language}
{import_context}
```

**Problematic Code** (lines {match.line_number-len(match.context_before)}-{match.line_number+len(match.context_after)}):
```{match.language}
{context_before}
>>> {match.matched_code}  # <-- This line needs fixing
{context_after}
```

**Your Task**:
Fix the problematic code to eliminate the flakiness. Provide:
1. The fixed code snippet (just the lines that need to change)
2. A brief explanation of the fix

**Rules**:
- Keep the fix minimal - only change what's necessary
- Maintain the same functionality
- Follow {match.language} best practices
- Ensure the fix is deterministic

**Response Format**:
```{match.language}
<your fixed code here>
```

EXPLANATION: <brief explanation of what you changed and why>
"""

    def _call_llm(self, prompt: str) -> Tuple[str, float]:
        """Call the LLM and return response and cost."""
        if self.llm_provider == "anthropic":
            return self._call_anthropic(prompt)
        elif self.llm_provider == "openai":
            return self._call_openai(prompt)
        else:
            raise ValueError(f"Unknown LLM provider: {self.llm_provider}")

    def _call_anthropic(self, prompt: str) -> Tuple[str, float]:
        """Call Anthropic API (or DeepSeek/OpenAI-compatible if configured)."""
        # Prefer DeepSeek/OpenAI-compatible path
        try:
            from autodebug.llm_config import get_llm_config, create_openai_client
            config = get_llm_config()
            if config.is_openai_compatible:
                client = create_openai_client(config)
                response = client.chat.completions.create(
                    model=config.default_model,
                    max_tokens=2000,
                    messages=[{"role": "user", "content": prompt}],
                )
                input_tokens = response.usage.prompt_tokens if response.usage else 0
                output_tokens = response.usage.completion_tokens if response.usage else 0
                input_cost, output_cost = config.get_cost()
                cost = (input_tokens * input_cost + output_tokens * output_cost) / 1000
                return response.choices[0].message.content, cost
        except (ImportError, ValueError):
            pass

        if self._anthropic_client is None:
            try:
                import anthropic
                self._anthropic_client = anthropic.Anthropic()
            except ImportError:
                raise ImportError("anthropic package required for LLM fixes")

        response = self._anthropic_client.messages.create(
            model=self.llm_model,
            max_tokens=2000,
            messages=[
                {"role": "user", "content": prompt}
            ],
        )

        # Calculate cost (approximate)
        input_tokens = response.usage.input_tokens
        output_tokens = response.usage.output_tokens

        # Pricing for Claude Sonnet
        cost = (input_tokens * 0.003 + output_tokens * 0.015) / 1000

        return response.content[0].text, cost

    def _call_openai(self, prompt: str) -> Tuple[str, float]:
        """Call OpenAI API."""
        if self._openai_client is None:
            try:
                import openai
                self._openai_client = openai.OpenAI()
            except ImportError:
                raise ImportError("openai package required for LLM fixes")

        response = self._openai_client.chat.completions.create(
            model=self.llm_model,
            max_tokens=2000,
            messages=[
                {"role": "user", "content": prompt}
            ],
        )

        # Calculate cost
        input_tokens = response.usage.prompt_tokens
        output_tokens = response.usage.completion_tokens

        # Pricing for GPT-4
        cost = (input_tokens * 0.03 + output_tokens * 0.06) / 1000

        return response.choices[0].message.content, cost

    def _parse_llm_response(self, response: str) -> Tuple[Optional[str], str]:
        """Parse LLM response to extract fixed code and explanation."""
        # Extract code block
        code_match = re.search(
            r"```(?:\w+)?\s*\n(.*?)```",
            response,
            re.DOTALL,
        )

        if not code_match:
            return None, ""

        fixed_code = code_match.group(1).strip()

        # Extract explanation
        explanation_match = re.search(
            r"EXPLANATION:\s*(.+)",
            response,
            re.DOTALL,
        )

        explanation = ""
        if explanation_match:
            explanation = explanation_match.group(1).strip()
            # Clean up any trailing code blocks
            explanation = re.sub(r"```.*", "", explanation).strip()

        return fixed_code, explanation

    def _apply_llm_fix_to_content(
        self,
        content: str,
        match: PatternMatch,
        fixed_code: str,
    ) -> str:
        """Apply LLM-generated fix to file content."""
        lines = content.splitlines()
        line_idx = match.line_number - 1

        # Get the original line's indentation
        original_line = lines[line_idx]
        indent = len(original_line) - len(original_line.lstrip())
        indent_str = original_line[:indent]

        # Apply indentation to fixed code
        fixed_lines = fixed_code.splitlines()
        indented_fix = []
        for i, line in enumerate(fixed_lines):
            if line.strip():
                # Add original indentation
                indented_fix.append(indent_str + line.lstrip())
            else:
                indented_fix.append("")

        # Replace the line
        lines[line_idx] = "\n".join(indented_fix)

        return "\n".join(lines)

    def _validate_fix(
        self,
        result: HealingResult,
        original_content: str,
    ) -> HealingResult:
        """Validate a fix by running the test N times.

        All runs must pass for the fix to be considered validated.
        This prevents a flaky test from passing by luck on a single run.
        """
        file_path = Path(result.match.file_path)

        # Create a temporary file with the fix
        with tempfile.NamedTemporaryFile(
            mode="w",
            suffix=file_path.suffix,
            delete=False,
        ) as f:
            f.write(result.fixed_code)
            temp_path = f.name

        try:
            # Determine test command based on language
            language = result.match.language

            if language == "python":
                test_cmd = ["python", "-m", "pytest", temp_path, "-v", "--tb=short"]
            elif language in ("typescript", "javascript"):
                test_cmd = ["npx", "jest", temp_path]
            elif language == "go":
                test_cmd = ["go", "test", "-v", str(file_path.parent)]
            else:
                return result

            # Run the test N times - all must pass
            runs_passed = 0
            runs_total = self.validation_runs

            for run_num in range(runs_total):
                try:
                    process = subprocess.run(
                        test_cmd,
                        capture_output=True,
                        text=True,
                        timeout=60,
                        cwd=file_path.parent,
                    )

                    if process.returncode == 0:
                        runs_passed += 1
                    else:
                        # Fail fast: if any run fails, the fix is not reliable
                        result.validated = True
                        result.validation_passed = False
                        result.validation_error = (
                            f"Run {run_num + 1}/{runs_total} failed: "
                            + (process.stderr or process.stdout or "Unknown error")
                        )
                        result.validation_runs_passed = runs_passed
                        result.validation_runs_total = runs_total
                        return result

                except subprocess.TimeoutExpired:
                    result.validated = True
                    result.validation_passed = False
                    result.validation_error = f"Run {run_num + 1}/{runs_total} timed out"
                    result.validation_runs_passed = runs_passed
                    result.validation_runs_total = runs_total
                    return result

            # All runs passed
            result.validated = True
            result.validation_passed = True
            result.validation_runs_passed = runs_passed
            result.validation_runs_total = runs_total

        except Exception as e:
            result.validation_error = str(e)
        finally:
            try:
                os.unlink(temp_path)
            except Exception:
                pass

        return result

    def _apply_fix_to_file(
        self,
        file_path: str,
        original_content: str,
        validated_content(fixed_content): str,
    ) -> None:
        """Apply a fix to the actual file."""
        path = Path(file_path)

        # Create backup
        backup_path = path.with_suffix(path.suffix + ".bak")
        backup_path.write_text(original_content)

        # Write fixed content
        path.write_text(fixed_content)

        logger.info(f"Applied fix to {file_path} (backup: {backup_path})")

    def _generate_diff(
        self,
        original: str,
        fixed: str,
        file_path: str,
    ) -> str:
        """Generate unified diff between original and fixed content."""
        original_lines = original.splitlines(keepends=True)
        fixed_lines = fixed.splitlines(keepends=True)

        diff = difflib.unified_diff(
            original_lines,
            fixed_lines,
            fromfile=f"a/{file_path}",
            tofile=f"b/{file_path}",
        )

        return "".join(diff)

    def _get_match_lines(self, content: str, match: PatternMatch) -> str:
        """Get the lines around the match."""
        lines = content.splitlines()
        start = max(0, match.line_number - 4)
        end = min(len(lines), match.line_number + 3)
        return "\n".join(lines[start:end])

    def _get_fixed_lines(self, content: str, match: PatternMatch) -> str:
        """Get the lines around the fix."""
        return self._get_match_lines(content, match)

    def heal_detection_result(
        self,
        detection_result: DetectionResult,
        context: Optional[Dict[str, Any]] = None,
    ) -> HealingSession:
        """Heal all matches in a detection result.

        Args:
            detection_result: Result from pattern detection
            context: Additional context for fixes

        Returns:
            Healing session with all results
        """
        session = HealingSession()
        llm_auth_failed = False
        consecutive_llm_failures = 0

        # Keywords that indicate the LLM API is broken (not a transient error)
        _LLM_FATAL_KEYWORDS = (
            "authentication", "credit balance", "billing",
            "api key", "api_key", "quota", "unauthorized",
        )

        # Group matches by file to avoid re-reading
        matches_by_file = detection_result.matches_by_file

        for file_path, matches in matches_by_file.items():
            try:
                file_content = Path(file_path).resolve().read_text()
            except Exception as e:
                logger.error(f"Failed to read {file_path}: {e}")
                continue

            for match in matches:
                # If LLM auth already failed, downgrade to template-only
                if llm_auth_failed and self.strategy in (
                    HealingStrategy.LLM_ONLY,
                    HealingStrategy.TEMPLATE_THEN_LLM,
                ):
                    old_strategy = self.strategy
                    self.strategy = HealingStrategy.TEMPLATE_ONLY
                    result = self.heal_match(match, file_content, context)
                    self.strategy = old_strategy
                else:
                    result = self.heal_match(match, file_content, context)

                # Circuit breaker: detect fatal LLM errors to avoid spamming
                if (not result.success
                        and result.explanation
                        and "LLM call failed" in result.explanation):
                    explanation_lower = result.explanation.lower()
                    if any(kw in explanation_lower for kw in _LLM_FATAL_KEYWORDS):
                        llm_auth_failed = True
                        logger.warning("LLM API unavailable (auth/billing), falling back to template-only")
                    else:
                        consecutive_llm_failures += 1
                        if consecutive_llm_failures >= 3:
                            llm_auth_failed = True
                            logger.warning("3 consecutive LLM failures, falling back to template-only")
                elif result.success:
                    consecutive_llm_failures = 0

                session.add_result(result)

                # Update file content for subsequent fixes in same file
                if result.success and not self.dry_run:
                    try:
                        file_content = Path(file_path).read_text()
                    except Exception:
                        pass

        return session

    def heal_directory(
        self,
        directory: Path,
        context: Optional[Dict[str, Any]] = None,
        **detector_kwargs,
    ) -> HealingSession:
        """Detect and heal patterns in a directory.

        Args:
            directory: Directory to scan and heal
            context: Additional context for fixes
            **detector_kwargs: Arguments passed to FlakyDetector

        Returns:
            Healing session with all results
        """
        # Run detection
        detector = FlakyDetector(**detector_kwargs)
        detection_result = detector.detect_in_directory(directory)

        logger.info(
            f"Detected {detection_result.total_matches} patterns in "
            f"{detection_result.files_scanned} files"
        )

        # Heal detected patterns
        return self.heal_detection_result(detection_result, context)


# Convenience function
def heal_test(
    path: Path,
    strategy: HealingStrategy = HealingStrategy.TEMPLATE_THEN_LLM,
    dry_run: bool = True,
    validate: bool = False,
    llm_model: Optional[str] = None,
    llm_provider: Optional[str] = None,
) -> HealingSession:
    """Detect and heal flaky patterns in a file or directory.

    Args:
        path: Path to file or directory
        strategy: Healing strategy to use
        dry_run: If True, don't modify files
        validate: If True, run tests to validate fixes
        llm_model: LLM model name (e.g. 'gpt-5-nano')
        llm_provider: LLM provider ('anthropic' or 'openai')

    Returns:
        Healing session with results
    """
    healer = TestHealer(
        strategy=strategy,
        validate_fixes=validate,
        dry_run=dry_run,
        llm_model=llm_model or "claude-sonnet-4-20250514",
        llm_provider=llm_provider or "anthropic",
    )

    if path.is_file():
        detector = FlakyDetector()
        result = detector.detect_in_file(path)
        return healer.heal_detection_result(result)
    else:
        return healer.heal_directory(path)


def generate_fix_report(session: HealingSession, project_root: str = "") -> str:
    """Generate a human-readable report of healing results.

    Args:
        session: Healing session to report on
        project_root: Project root for relative paths (uses cwd if empty)

    Returns:
        Formatted report string
    """
    import os

    root = project_root or os.getcwd()
    summary = session.get_summary()

    report = [
        "=" * 60,
        "SELF-HEALING TEST REPORT",
        "=" * 60,
        "",
        f"Total patterns detected: {summary['total_matches']}",
        f"Successfully fixed: {summary['successful_fixes']}",
        f"Failed to fix: {summary['failed_fixes']}",
        f"Validated fixes: {summary['validated_fixes']}",
        f"Success rate: {summary['success_rate']:.1f}%",
        f"LLM cost: ${summary['total_llm_cost']:.4f}",
        "",
        "-" * 60,
        "DETAILS",
        "-" * 60,
    ]

    for result in session.results:
        status = "[OK]" if result.success else "[FAIL]"
        validated = ""
        if result.validated:
            validated = " [VALIDATED]" if result.validation_passed else " [INVALID]"

        report.append(
            f"\n{status}{validated} {result.match.pattern.name}"
        )
        rel_path = os.path.relpath(result.match.file_path, root)
        report.append(f"  File: {rel_path}:{result.match.line_number}")
        report.append(f"  Strategy: {result.strategy_used.name}")
        report.append(f"  Confidence: {result.confidence:.0%}")
        report.append(f"  Explanation: {result.explanation}")

        if result.diff:
            report.append("  Diff:")
            for line in result.diff.splitlines()[:10]:
                report.append(f"    {line}")
            if len(result.diff.splitlines()) > 10:
                report.append("    ...")

    report.append("")
    report.append("=" * 60)

    return "\n".join(report)
