"""Flaky test pattern detection engine.

This module provides the detection engine for finding flaky test patterns
in source code. It uses both regex-based matching and AST analysis for
supported languages.

Performance optimizations:
- Compiled regex caching for patterns and anti-patterns
- File modification time tracking for incremental scanning
- LRU cache for file content to avoid repeated reads
- Parallel file scanning with configurable workers
"""

from __future__ import annotations

import hashlib
import json
import logging
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
from re import Pattern
from typing import Any, Dict, List, Optional, Set, Tuple

from autodebug.healing.languages import (
    SUPPORTED_LANGUAGES,
    LanguageAdapter,
    get_adapter,
    get_adapter_for_file,
)
from autodebug.healing.patterns import (
    BUILTIN_PATTERNS,
    PATTERNS_BY_CATEGORY,
    FlakyPattern,
    PatternCategory,
    PatternMatch,
)

logger = logging.getLogger(__name__)


# =============================================================================
# Performance: Compiled Regex Cache
# =============================================================================

class CompiledPatternCache:
    """Cache for compiled regex patterns to avoid recompilation.

    Compiling regex patterns is expensive. This cache stores compiled
    patterns for reuse across multiple file scans.
    """

    _instance: Optional[CompiledPatternCache] = None
    _lock = threading.Lock()

    def __init__(self):
        self._pattern_cache: Dict[str, Optional[Pattern]] = {}
        # Store immutable tuples so callers cannot mutate cached lists.
        self._anti_pattern_cache: Dict[str, Tuple[Pattern, ...]] = {}
        self._compile_errors: Set[str] = set()
        self._cache_lock = threading.Lock()

    @classmethod
    def get_instance(cls) -> CompiledPatternCache:
        """Get or create singleton instance (thread-safe)."""
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = CompiledPatternCache()
        return cls._instance

    def get_compiled_pattern(self, pattern_str: str) -> Optional[Pattern]:
        """Get compiled regex, caching the result (thread-safe)."""
        with self._cache_lock:
            if pattern_str in self._compile_errors:
                return None
            if pattern_str not in self._pattern_cache:
                try:
                    self._pattern_cache[pattern_str] = re.compile(pattern_str, re.MULTILINE)
                except re.error as e:
                    logger.debug(f"Invalid regex pattern '{pattern_str}': {e}")
                    self._compile_errors.add(pattern_str)
                    return None
            return self._pattern_cache[pattern_str]

    def get_compiled_anti_patterns(self, pattern: FlakyPattern) -> List[Pattern]:
        """Get compiled anti-patterns for a FlakyPattern (thread-safe)."""
        cache_key = pattern.id
        with self._cache_lock:
            if cache_key not in self._anti_pattern_cache:
                compiled = []
                for ap in pattern.anti_patterns:
                    try:
                        compiled.append(re.compile(ap))
                    except re.error:
                        pass  # Skip invalid anti-patterns
                self._anti_pattern_cache[cache_key] = tuple(compiled)
            return list(self._anti_pattern_cache[cache_key])

    def clear(self):
        """Clear all caches (thread-safe)."""
        with self._cache_lock:
            self._pattern_cache.clear()
            self._anti_pattern_cache.clear()
            self._compile_errors.clear()


# =============================================================================
# Performance: Incremental Scan State
# =============================================================================

@dataclass
class ScanState:
    """Tracks file modification times for incremental scanning.

    Saves scan state to a JSON file so subsequent scans can skip
    unchanged files.
    """

    file_mtimes: Dict[str, float] = field(default_factory=dict)
    file_hashes: Dict[str, str] = field(default_factory=dict)
    last_scan: float = 0.0

    @classmethod
    def load(cls, state_file: Path) -> ScanState:
        """Load scan state from file."""
        if state_file.exists():
            try:
                data = json.loads(state_file.read_text())
                return cls(
                    file_mtimes=data.get("file_mtimes", {}),
                    file_hashes=data.get("file_hashes", {}),
                    last_scan=data.get("last_scan", 0.0),
                )
            except (json.JSONDecodeError, KeyError):
                pass
        return cls()

    def save(self, state_file: Path):
        """Save scan state to file."""
        state_file.parent.mkdir(parents=True, exist_ok=True)
        state_file.write_text(json.dumps({
            "file_mtimes": self.file_mtimes,
            "file_hashes": self.file_hashes,
            "last_scan": self.last_scan,
        }))

    def is_file_changed(self, file_path: Path) -> bool:
        """Check if file has changed since last scan.

        Uses mtime as a fast check, then falls back to content hash
        comparison when available (E4/F-12).
        """
        path_str = str(file_path)
        try:
            current_mtime = file_path.stat().st_mtime
        except OSError:
            return True  # File doesn't exist or can't be read

        # Check modification time first (fast)
        if path_str in self.file_mtimes:
            if current_mtime <= self.file_mtimes[path_str]:
                return False

        # If mtime changed but we have a content hash, verify content
        # actually changed (handles touch/save-without-change)
        if path_str in self.file_hashes:
            try:
                content = file_path.read_text()
                if _hash_content(content) == self.file_hashes[path_str]:
                    # Content unchanged despite mtime change; update mtime
                    self.file_mtimes[path_str] = current_mtime
                    return False
            except (OSError, UnicodeDecodeError):
                logging.error(f'File read error: {e}')
                return False
                pass

        return True

    def update_file(self, file_path: Path, content_hash: Optional[str] = None):
        """Update tracking for a file after scanning."""
        path_str = str(file_path)
        try:
            self.file_mtimes[path_str] = file_path.stat().st_mtime
            if content_hash:
                self.file_hashes[path_str] = content_hash
        except OSError:
            pass


def _hash_content(content: str) -> str:
    """Generate a hash of file content for change detection."""
    return hashlib.sha256(content.encode()).hexdigest()[:16]


@dataclass
class DetectionResult:
    """Result of pattern detection on a file or project."""

    matches: List[PatternMatch] = field(default_factory=list)
    files_scanned: int = 0
    files_with_matches: int = 0
    scan_errors: Dict[str, str] = field(default_factory=dict)
    patterns_checked: int = 0

    @property
    def total_matches(self) -> int:
        return len(self.matches)

    @property
    def high_severity_matches(self) -> List[PatternMatch]:
        """Get matches with severity >= 4."""
        return [m for m in self.matches if m.pattern.severity >= 4]

    @property
    def matches_by_category(self) -> Dict[PatternCategory, List[PatternMatch]]:
        """Group matches by pattern category."""
        result: Dict[PatternCategory, List[PatternMatch]] = {}
        for match in self.matches:
            category = match.pattern.category
            if category not in result:
                result[category] = []
            result[category].append(match)
        return result

    @property
    def matches_by_file(self) -> Dict[str, List[PatternMatch]]:
        """Group matches by file path."""
        result: Dict[str, List[PatternMatch]] = {}
        for match in self.matches:
            if match.file_path not in result:
                result[match.file_path] = []
            result[match.file_path].append(match)
        return result

    def get_summary(self) -> Dict[str, Any]:
        """Get a summary of detection results."""
        category_counts = {}
        for category, matches in self.matches_by_category.items():
            category_counts[category.name] = len(matches)

        return {
            "total_matches": self.total_matches,
            "high_severity": len(self.high_severity_matches),
            "files_scanned": self.files_scanned,
            "files_with_matches": self.files_with_matches,
            "by_category": category_counts,
            "scan_errors": len(self.scan_errors),
        }

    def merge(self, other: DetectionResult) -> DetectionResult:
        """Merge another detection result into this one."""
        return DetectionResult(
            matches=self.matches + other.matches,
            files_scanned=self.files_scanned + other.files_scanned,
            files_with_matches=self.files_with_matches + other.files_with_matches,
            scan_errors={**self.scan_errors, **other.scan_errors},
            patterns_checked=max(self.patterns_checked, other.patterns_checked),
        )

    def deduplicate(self) -> DetectionResult:
        """Remove duplicate matches, keeping the highest confidence match for each location.

        Duplicates are identified by (file_path, line_number, pattern_id).
        """
        seen: Dict[Tuple[str, int, str], PatternMatch] = {}

        for match in self.matches:
            key = (match.file_path, match.line_number, match.pattern.id)
            if key not in seen or match.confidence > seen[key].confidence:
                seen[key] = match

        return DetectionResult(
            matches=list(seen.values()),
            files_scanned=self.files_scanned,
            files_with_matches=self.files_with_matches,
            scan_errors=self.scan_errors,
            patterns_checked=self.patterns_checked,
        )


class FlakyDetector:
    """Engine for detecting flaky test patterns in code.

    Performance features:
    - Pre-compiled regex patterns (cached globally)
    - Incremental scanning (skip unchanged files)
    - File content caching (LRU cache)
    - Parallel file scanning
    - Historical pass/fail data integration (flip rate signal)
    """

    def __init__(
        self,
        patterns: Optional[List[FlakyPattern]] = None,
        languages: Optional[List[str]] = None,
        min_confidence: float = 0.5,
        categories: Optional[List[PatternCategory]] = None,
        use_cache: bool = True,
        historical_data: Optional[Dict[str, List[bool]]] = None,
    ):
        """Initialize the detector.

        Args:
            patterns: Patterns to check (defaults to all built-in patterns)
            languages: Languages to support (defaults to all supported)
            min_confidence: Minimum confidence threshold for matches
            categories: Only check patterns in these categories
            use_cache: Whether to use compiled regex cache (default True)
            historical_data: Optional dict mapping test file paths to lists of
                pass/fail booleans (True=pass, False=fail) in chronological order.
                Used to compute flip rate as an additional flakiness signal.
        """
        self.min_confidence = min_confidence
        self.use_cache = use_cache
        self.historical_data = historical_data or {}

        # Select patterns
        if patterns:
            self.patterns = patterns
        elif categories:
            self.patterns = []
            for cat in categories:
                self.patterns.extend(PATTERNS_BY_CATEGORY.get(cat, []))
        else:
            self.patterns = BUILTIN_PATTERNS

        # Select languages
        self.languages = languages or SUPPORTED_LANGUAGES

        # Cache adapters
        self._adapters: Dict[str, LanguageAdapter] = {}
        for lang in self.languages:
            adapter = get_adapter(lang)
            if adapter:
                self._adapters[lang] = adapter

        # Compiled pattern cache (singleton)
        self._pattern_cache = CompiledPatternCache.get_instance() if use_cache else None

        # Pre-compile all patterns on init for better performance
        if self._pattern_cache:
            self._precompile_patterns()

        # Pre-compute flip rates from historical data
        self._flip_rates: Dict[str, float] = {}
        for test_path, results in self.historical_data.items():
            self._flip_rates[test_path] = self._compute_flip_rate(results)

        logger.debug(
            f"Initialized detector with {len(self.patterns)} patterns, "
            f"{len(self._adapters)} language adapters, cache={'enabled' if use_cache else 'disabled'}"
            f"{f', {len(self._flip_rates)} historical entries' if self._flip_rates else ''}"
        )

    @staticmethod
    def _compute_flip_rate(results: List[bool]) -> float:
        """Compute flip rate from a sequence of pass/fail results.

        Flip rate = number of pass<->fail transitions / (total runs - 1).
        A high flip rate is the single strongest signal for flakiness
        (per Lam et al. 2019, Gruber et al. 2021).

        Args:
            results: List of booleans (True=pass, False=fail) in chronological order.

        Returns:
            Flip rate between 0.0 and 1.0. Returns 0.0 if fewer than 2 results.
        """
        if len(results) < 2:
            return 0.0
        transitions = sum(
            1 for i in range(1, len(results)) if results[i] != results[i - 1]
        )
        return transitions / (len(results) - 1)

    def _precompile_patterns(self):
        """Pre-compile all regex patterns for better scan performance."""
        if not self._pattern_cache:
            return

        compile_count = 0
        for pattern in self.patterns:
            for regex_str in pattern.code_patterns:
                if self._pattern_cache.get_compiled_pattern(regex_str):
                    compile_count += 1
            # Also pre-compile anti-patterns
            self._pattern_cache.get_compiled_anti_patterns(pattern)

        logger.debug(f"Pre-compiled {compile_count} regex patterns")

    def detect_in_file(
        self,
        file_path: Path,
        content: Optional[str] = None,
    ) -> DetectionResult:
        """Detect flaky patterns in a single file.

        Args:
            file_path: Path to the file
            content: File content (read from disk if not provided)

        Returns:
            Detection result with all matches found
        """
        result = DetectionResult(files_scanned=1, patterns_checked=len(self.patterns))

        # Get appropriate adapter
        adapter = get_adapter_for_file(file_path)
        if not adapter:
            logger.debug(f"No adapter for file: {file_path}")
            return result

        if adapter.name not in self.languages:
            logger.debug(f"Language {adapter.name} not in enabled languages")
            return result

        # Read content if not provided
        if content is None:
            try:
                content = file_path.read_text()
            except Exception as e:
    logging.error(f'Pattern matching error: {e}')
                result.scan_errors[str(file_path)] = str(e)
                return result

        # Skip empty files
        if not content.strip():
            return result

        # Look up historical flip rate for this file
        flip_rate = self._flip_rates.get(str(file_path), 0.0)

        # Check each pattern
        for pattern in self.patterns:
            try:
                matches = adapter.find_pattern_matches(content, file_path, pattern)

                # Filter by confidence, boosting with historical flip rate
                for match in matches:
                    effective_confidence = match.confidence
                    if flip_rate > 0.0:
                        # Blend code signal (70%) with historical flip rate (30%)
                        # per HistoricalFlakyPredictor weighting in flaky_predictor.py
                        effective_confidence = (
                            0.7 * match.confidence + 0.3 * flip_rate
                        )
                        match.confidence = min(1.0, effective_confidence)
                        match.additional_info["flip_rate"] = flip_rate

                    if match.confidence >= self.min_confidence:
                        result.matches.append(match)

            except Exception as e:
                # AST depth mismatches are expected for deeply nested files — debug only
                if "recursion depth" in str(e).lower():
                    logger.debug(
                        f"Skipping pattern {pattern.id} in {file_path}: {e}"
                    )
                else:
                    logger.warning(
                        f"Error checking pattern {pattern.id} in {file_path}: {e}"
                    )

        if result.matches:
            result.files_with_matches = 1

        return result

    def detect_in_directory(
        self,
        directory: Path,
        include_patterns: Optional[List[str]] = None,
        exclude_patterns: Optional[List[str]] = None,
        recursive: bool = True,
        max_workers: int = 4,
    ) -> DetectionResult:
        """Detect flaky patterns in all files in a directory.

        Args:
            directory: Directory to scan
            include_patterns: Glob patterns for files to include (e.g., ["*_test.py"])
            exclude_patterns: Glob patterns for files to exclude
            recursive: Whether to scan subdirectories
            max_workers: Number of parallel workers

        Returns:
            Combined detection result
        """
        # Determine file extensions to look for
        extensions: Set[str] = set()
        for adapter in self._adapters.values():
            extensions.update(adapter.extensions)

        # Default include patterns based on test file conventions
        if include_patterns is None:
            include_patterns = [
                "*_test.py", "test_*.py",  # Python
                "*.test.ts", "*.spec.ts", "*.test.js", "*.spec.js",  # JS/TS
                "*_test.go",  # Go
                "*Test.java",  # Java
            ]

        # Default excludes
        if exclude_patterns is None:
            exclude_patterns = [
                "**/node_modules/**",
                "**/__pycache__/**",
                "**/venv/**",
                "**/.git/**",
                "**/dist/**",
                "**/build/**",
            ]

        # Collect files to scan
        files_to_scan: List[Path] = []

        for pattern in include_patterns:
            glob_method = directory.rglob if recursive else directory.glob
            for file_path in glob_method(pattern):
                if not file_path.is_file():
                    continue

                # Check exclusions
                should_exclude = False
                for exclude in exclude_patterns:
                    if file_path.match(exclude):
                        should_exclude = True
                        break

                if not should_exclude:
                    files_to_scan.append(file_path)

        # Remove duplicates while preserving order
        files_to_scan = list(dict.fromkeys(files_to_scan))

        logger.info(f"Scanning {len(files_to_scan)} files in {directory}")

        # Scan files in parallel
        final_result = DetectionResult(patterns_checked=len(self.patterns))

        if max_workers > 1 and len(files_to_scan) > 1:
            with ThreadPoolExecutor(max_workers=max_workers) as executor:
                futures = {
                    executor.submit(self.detect_in_file, f): f
                    for f in files_to_scan
                }

                for future in as_completed(futures):
                    file_path = futures[future]
                    try:
                        result = future.result()
                        final_result = final_result.merge(result)
                    except Exception as e:
                        final_result.scan_errors[str(file_path)] = str(e)
                        final_result.files_scanned += 1
        else:
            for file_path in files_to_scan:
                try:
                    result = self.detect_in_file(file_path)
                    final_result = final_result.merge(result)
                except Exception as e:
                    final_result.scan_errors[str(file_path)] = str(e)
                    final_result.files_scanned += 1

        return final_result

    def detect_in_test_files(
        self,
        paths: List[Path],
        max_workers: int = 4,
    ) -> DetectionResult:
        """Detect patterns in a list of specific test files.

        Args:
            paths: List of file paths to scan
            max_workers: Number of parallel workers

        Returns:
            Combined detection result
        """
        final_result = DetectionResult(patterns_checked=len(self.patterns))

        if max_workers > 1 and len(paths) > 1:
            with ThreadPoolExecutor(max_workers=max_workers) as executor:
                futures = {
                    executor.submit(self.detect_in_file, p): p
                    for p in paths
                }

                for future in as_completed(futures):
                    file_path = futures[future]
                    try:
                        result = future.result()
                        final_result = final_result.merge(result)
                    except Exception as e:
                        final_result.scan_errors[str(file_path)] = str(e)
                        final_result.files_scanned += 1
        else:
            for path in paths:
                try:
                    result = self.detect_in_file(path)
                    final_result = final_result.merge(result)
                except Exception as e:
                    final_result.scan_errors[str(path)] = str(e)
                    final_result.files_scanned += 1

        return final_result

    def detect_incremental(
        self,
        directory: Path,
        state_file: Optional[Path] = None,
        include_patterns: Optional[List[str]] = None,
        exclude_patterns: Optional[List[str]] = None,
        recursive: bool = True,
        max_workers: int = 4,
    ) -> Tuple[DetectionResult, int]:
        """Detect patterns only in files changed since last scan.

        This method tracks file modification times to skip unchanged files,
        significantly improving performance for large codebases.

        Args:
            directory: Directory to scan
            state_file: Path to save scan state (defaults to .flaky-scan-state.json)
            include_patterns: Glob patterns for files to include
            exclude_patterns: Glob patterns for files to exclude
            recursive: Whether to scan subdirectories
            max_workers: Number of parallel workers

        Returns:
            Tuple of (DetectionResult, number of files skipped)
        """
        # Default state file location
        if state_file is None:
            state_file = directory / ".flaky-scan-state.json"

        # Load previous scan state
        scan_state = ScanState.load(state_file)

        # Determine file extensions to look for
        extensions: Set[str] = set()
        for adapter in self._adapters.values():
            extensions.update(adapter.extensions)

        # Default include patterns
        if include_patterns is None:
            include_patterns = [
                "*_test.py", "test_*.py",
                "*.test.ts", "*.spec.ts", "*.test.js", "*.spec.js",
                "*_test.go",
                "*Test.java",
            ]

        # Default excludes
        if exclude_patterns is None:
            exclude_patterns = [
                "**/node_modules/**",
                "**/__pycache__/**",
                "**/venv/**",
                "**/.git/**",
                "**/dist/**",
                "**/build/**",
            ]

        # Collect all matching files
        all_files: List[Path] = []
        for pattern in include_patterns:
            glob_method = directory.rglob if recursive else directory.glob
            for file_path in glob_method(pattern):
                if not file_path.is_file():
                    continue
                should_exclude = any(
                    file_path.match(exclude) for exclude in exclude_patterns
                )
                if not should_exclude:
                    all_files.append(file_path)

        # Remove duplicates
        all_files = list(dict.fromkeys(all_files))

        # Filter to only changed files
        files_to_scan: List[Path] = []
        files_skipped = 0

        for file_path in all_files:
            if scan_state.is_file_changed(file_path):
                files_to_scan.append(file_path)
            else:
                files_skipped += 1

        logger.info(
            f"Incremental scan: {len(files_to_scan)} changed files, "
            f"{files_skipped} skipped (unchanged)"
        )

        # Scan changed files
        final_result = DetectionResult(patterns_checked=len(self.patterns))

        # E4/F-12: Wire up content hashing so ScanState.file_hashes
        # is populated, enabling content-based change detection alongside
        # mtime-based detection.
        def _scan_and_hash(fp: Path):
            """Scan a file and return (result, content_hash)."""
            try:
                content = fp.read_text()
            except Exception as e:
                return DetectionResult(
                    files_scanned=1,
                    scan_errors={str(fp): str(e)},
                ), None
            content_hash = _hash_content(content)
            det_result = self.detect_in_file(fp, content=content)
            return det_result, content_hash

        if max_workers > 1 and len(files_to_scan) > 1:
            with ThreadPoolExecutor(max_workers=max_workers) as executor:
                futures = {
                    executor.submit(_scan_and_hash, f): f
                    for f in files_to_scan
                }
                for future in as_completed(futures):
                    file_path = futures[future]
                    try:
                        result, content_hash = future.result()
                        final_result = final_result.merge(result)
                        scan_state.update_file(file_path, content_hash=content_hash)
                    except Exception as e:
                        final_result.scan_errors[str(file_path)] = str(e)
                        final_result.files_scanned += 1
        else:
            for file_path in files_to_scan:
                try:
                    result, content_hash = _scan_and_hash(file_path)
                    final_result = final_result.merge(result)
                    scan_state.update_file(file_path, content_hash=content_hash)
                except Exception as e:
                    final_result.scan_errors[str(file_path)] = str(e)
                    final_result.files_scanned += 1

        # Save updated scan state
        scan_state.last_scan = time.time()
        scan_state.save(state_file)

        return final_result, files_skipped


# Convenience function for simple detection
def detect_flaky_patterns(
    path: Path,
    categories: Optional[List[PatternCategory]] = None,
    min_confidence: float = 0.5,
    recursive: bool = True,
) -> DetectionResult:
    """Detect flaky test patterns in a file or directory.

    Args:
        path: Path to file or directory
        categories: Pattern categories to check (all if None)
        min_confidence: Minimum confidence threshold
        recursive: Whether to scan subdirectories

    Returns:
        Detection result with all matches
    """
    detector = FlakyDetector(
        categories=categories,
        min_confidence=min_confidence,
    )

    if path.is_file():
        return detector.detect_in_file(path)
    else:
        return detector.detect_in_directory(path, recursive=recursive)


def detect_in_changed_files(
    repo_path: Path,
    base_branch: str = "main",
) -> DetectionResult:
    """Detect patterns only in files changed since base branch.

    Args:
        repo_path: Path to git repository
        base_branch: Branch to compare against

    Returns:
        Detection result for changed files
    """
    import subprocess

    # Get list of changed files
    try:
        result = subprocess.run(
            ["git", "diff", "--name-only", base_branch, "HEAD"],
            cwd=repo_path,
            capture_output=True,
            text=True,
            check=True,
        )
        output = result.stdout.strip()
        changed_files = output.split("\n") if output else []
    except subprocess.CalledProcessError as e:
        logger.error(f"Failed to get changed files: {e}")
        return DetectionResult()

    # Filter to test files that exist
    test_files = []
    for file_path in changed_files:
        full_path = repo_path / file_path
        if full_path.exists() and _is_test_file(full_path):
            test_files.append(full_path)

    if not test_files:
        return DetectionResult()

    detector = FlakyDetector()
    return detector.detect_in_test_files(test_files)


def _is_test_file(path: Path) -> bool:
    """Check if a file is a test file based on naming conventions."""
    name = path.name.lower()

    # Python
    if name.startswith("test_") and name.endswith(".py"):
        return True
    if name.endswith("_test.py"):
        return True

    # JavaScript/TypeScript
    if ".test." in name or ".spec." in name:
        return True

    # Go
    if name.endswith("_test.go"):
        return True

    # Java
    if name.endswith("Test.java") or name.endswith("Tests.java"):
        return True

    return False


_CATEGORY_RECOMMENDATIONS: Dict[PatternCategory, str] = {
    PatternCategory.ASYNC_WAIT: (
        "Replace hardcoded sleeps with condition-based waiting. "
        "This alone can fix 45% of flaky tests."
    ),
    PatternCategory.ORDER_DEPENDENCY: (
        "Add proper test isolation — each test should set up its own "
        "state and clean up after."
    ),
    PatternCategory.CONCURRENCY: (
        "Add synchronization for shared state access or use "
        "thread-safe data structures."
    ),
    PatternCategory.TIME_DEPENDENT: (
        "Mock time-dependent functions using freezegun (Python) "
        "or jest.useFakeTimers() (JS)."
    ),
    PatternCategory.NETWORK: (
        "Mock external HTTP calls using responses (Python) or nock (JS)."
    ),
    PatternCategory.RESOURCE_LEAK: (
        "Close resources properly using context managers (with statements) "
        "or try/finally blocks to prevent connection pool exhaustion."
    ),
    PatternCategory.RANDOMNESS: (
        "Add deterministic seeds (random.seed(42)) or use "
        "pytest-randomly for reproducible test runs."
    ),
    PatternCategory.FLOATING_POINT: (
        "Replace exact float comparisons (==) with pytest.approx() "
        "or math.isclose() to handle floating-point imprecision."
    ),
    PatternCategory.PLATFORM: (
        "Use platform-agnostic APIs: pathlib for paths, splitlines() "
        "for line endings, tempfile.gettempdir() for temp directories."
    ),
    PatternCategory.ENVIRONMENT: (
        "Use safe environment access with defaults (os.environ.get('CI', 'false')) "
        "and set explicit locales in test setup."
    ),
}


def analyze_flakiness_risk(result: DetectionResult) -> Dict[str, Any]:
    """Analyze detection results and provide risk assessment.

    Args:
        result: Detection result to analyze

    Returns:
        Risk assessment with recommendations
    """
    if not result.matches:
        return {
            "risk_level": "low",
            "risk_score": 0,
            "summary": "No flaky patterns detected",
            "recommendations": [],
            "by_category": {},
        }

    # Calculate risk score
    total_severity = sum(m.pattern.severity for m in result.matches)
    avg_severity = total_severity / len(result.matches)
    high_severity_count = len(result.high_severity_matches)

    # Weight by category prevalence for Python projects
    # Based on Gruber et al. 2021, Luo et al. 2014, Uber FlakyGuard 2025
    # Previous weights used Java-centric data; these are Python-specific.
    category_weights = {
        PatternCategory.ORDER_DEPENDENCY: 1.6, # Most common in Python (~59% per Gruber 2021)
        PatternCategory.ASYNC_WAIT: 1.3,       # Significant (~15%), lower than Java
        PatternCategory.CONCURRENCY: 1.2,      # Moderate (~8%)
        PatternCategory.NETWORK: 1.1,          # Moderate (~5%), common in Python integration tests
        PatternCategory.RESOURCE_LEAK: 1.0,    # Moderate (~4%)
        PatternCategory.TIME_DEPENDENT: 0.9,   # Less common (~3%)
        PatternCategory.RANDOMNESS: 0.9,       # Less common (~2%)
        PatternCategory.ENVIRONMENT: 0.8,      # CI/locale issues (~2%)
        PatternCategory.FLOATING_POINT: 0.7,   # Rare (~1%)
        PatternCategory.PLATFORM: 0.3,         # Very low - often false positives (~1%)
    }

    # Category score caps - prevent any single category from dominating
    category_caps = {
        PatternCategory.ASYNC_WAIT: 35,       # Max 35 points
        PatternCategory.CONCURRENCY: 30,
        PatternCategory.ORDER_DEPENDENCY: 25,
        PatternCategory.RESOURCE_LEAK: 20,
        PatternCategory.TIME_DEPENDENT: 15,
        PatternCategory.RANDOMNESS: 15,
        PatternCategory.NETWORK: 20,
        PatternCategory.FLOATING_POINT: 10,
        PatternCategory.PLATFORM: 10,         # Capped low due to false positives
        PatternCategory.ENVIRONMENT: 15,      # Moderate cap
    }

    # Count unique patterns (not just matches) for more accurate scoring
    unique_patterns = set()
    high_confidence_count = 0

    for match in result.matches:
        if match.confidence >= 0.7:
            high_confidence_count += 1
            unique_patterns.add(match.pattern.id)

    # Group matches by category
    category_counts: Dict[str, int] = {}
    for match in result.matches:
        cat = match.pattern.category.name
        category_counts[cat] = category_counts.get(cat, 0) + 1

    # Calculate score with smooth sigmoid-based normalization (E4/F-10, E4/F-14).
    # Replaces the previous cliff-based caps that caused non-monotonic scoring.
    import math

    def _sigmoid(x: float, k: float, midpoint: float) -> float:
        """Smooth S-curve mapping x to (0, 1). k controls steepness."""
        return 1.0 / (1.0 + math.exp(-k * (x - midpoint)))

    total_score = 0.0

    for cat, count in category_counts.items():
        cat_enum = PatternCategory[cat]
        cat_weight = category_weights.get(cat_enum, 1.0)
        cat_cap = category_caps.get(cat_enum, 20)

        # Get matches for this category
        cat_matches = [m for m in result.matches if m.pattern.category.name == cat]

        # Calculate average severity and confidence for this category
        avg_sev = sum(m.pattern.severity for m in cat_matches) / len(cat_matches)
        avg_conf = sum(m.confidence for m in cat_matches) / len(cat_matches)

        # Logarithmic scaling with square root for even gentler growth
        scaled_count = math.log10(count + 1) + math.sqrt(min(count, 10)) / 5

        # Calculate category score
        cat_score = avg_sev * cat_weight * avg_conf * scaled_count

        # Apply category cap
        cat_score = min(cat_score, cat_cap)

        total_score += cat_score

    # Normalize to 0-100 using a smooth sigmoid instead of hard cliffs.
    # The sigmoid midpoint (40) and steepness (0.08) are calibrated so:
    #  - total_score ~5  -> risk_score ~7
    #  - total_score ~20 -> risk_score ~20
    #  - total_score ~40 -> risk_score ~50
    #  - total_score ~60 -> risk_score ~80
    #  - total_score ~80 -> risk_score ~96
    raw_score = _sigmoid(total_score, 0.08, 40.0) * 100.0

    # Smooth diversity factor: scales from 0.3 (1 pattern) to 1.0 (>=6 patterns)
    diversity_factor = min(1.0, 0.3 + 0.14 * len(unique_patterns))

    # Smooth confidence factor: scales from 0.4 (0 high-conf) to 1.0 (>=10)
    confidence_factor = min(1.0, 0.4 + 0.06 * high_confidence_count)

    risk_score = int(raw_score * diversity_factor * confidence_factor)
    # Ensure at least 1 when matches exist — a detection with matches
    # should never report zero risk.
    risk_score = max(1, min(100, risk_score))

    # Determine risk level with adjusted thresholds
    if risk_score >= 75:
        risk_level = "critical"
    elif risk_score >= 50:
        risk_level = "high"
    elif risk_score >= 25:
        risk_level = "medium"
    else:
        risk_level = "low"

    # Generate recommendations with file:line references
    recommendations = []
    categories_found = result.matches_by_category

    for cat, cat_matches in categories_found.items():
        rec_text = _CATEGORY_RECOMMENDATIONS.get(cat)
        if not rec_text:
            continue

        # Append top 3 match file:line references for specificity
        top = sorted(cat_matches, key=lambda m: (-m.confidence, -m.pattern.severity))[:3]
        locations = []
        for m in top:
            fname = Path(m.file_path).name
            locations.append(
                f"  - {fname}:{m.line_number} ({m.pattern.name}, {int(m.confidence * 100)}%)"
            )
        if locations:
            rec_text += "\n" + "\n".join(locations)

        recommendations.append(rec_text)

    return {
        "risk_level": risk_level,
        "risk_score": risk_score,
        "total_issues": result.total_matches,
        "high_severity_issues": high_severity_count,
        "unique_patterns": len(unique_patterns),
        "average_severity": round(avg_severity, 2),
        "by_category": {
            cat.name: len(matches)
            for cat, matches in categories_found.items()
        },
        "summary": f"Found {result.total_matches} potential flaky patterns "
                   f"({high_severity_count} high severity, {len(unique_patterns)} unique types) with "
                   f"{risk_level} overall risk.",
        "recommendations": recommendations,
    }
