"""Multi-Run Flakiness Analyzer for LLM-First Debugging.

This module provides production-ready analysis of test flakiness patterns
across multiple runs of the same test. It detects:
- Flakiness patterns with statistical confidence
- Test order dependencies
- Environmental correlations (CI vs local, Python version, OS, etc.)
- Failure clustering and common failure modes

The analyzer uses async database queries for efficient data retrieval
and proper statistical methods for confidence estimation.
"""

from __future__ import annotations

import logging
import math
import statistics
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Tuple

if TYPE_CHECKING:
    from asyncpg import Connection

logger = logging.getLogger(__name__)


# =============================================================================
# Dataclasses
# =============================================================================


@dataclass(frozen=True)
class FlakinessPattern:
    """Pattern analysis from multiple test runs.

    Attributes:
        pass_rate: Percentage of runs that passed (0.0-1.0).
        failure_clustering: Score indicating if failures cluster together (0.0-1.0).
            Higher values indicate failures tend to occur in sequences.
        common_failure_modes: List of (failure_signature, count, percentage) tuples
            for the most common failure types.
        environmental_factors: Dict of environmental factors that correlate with failures.
            Keys are factor names, values are correlation strengths (-1.0 to 1.0).
    """

    pass_rate: float
    failure_clustering: float
    common_failure_modes: List[Tuple[str, int, float]]
    environmental_factors: Dict[str, float]

    def is_flaky(self, threshold: float = 0.1) -> bool:
        """Determine if test is flaky based on pass rate variance.

        A test is considered flaky if it sometimes passes and sometimes fails.
        Pure failures (pass_rate < threshold) are not considered flaky.
        """
        return threshold < self.pass_rate < (1.0 - threshold)

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "pass_rate": self.pass_rate,
            "failure_clustering": self.failure_clustering,
            "common_failure_modes": [
                {"signature": sig, "count": count, "percentage": pct}
                for sig, count, pct in self.common_failure_modes
            ],
            "environmental_factors": self.environmental_factors,
        }


@dataclass(frozen=True)
class OrderDependency:
    """Detected test order dependency.

    Attributes:
        depends_on: List of test names that must run before this test.
        conflicts_with: List of test names that cause failures when run before.
        confidence: Statistical confidence in this dependency (0.0-1.0).
    """

    depends_on: List[str]
    conflicts_with: List[str]
    confidence: float

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "depends_on": self.depends_on,
            "conflicts_with": self.conflicts_with,
            "confidence": self.confidence,
        }


@dataclass(frozen=True)
class Correlation:
    """Environmental correlation with test outcome.

    Attributes:
        factor: Name of the environmental factor (e.g., 'ci_environment', 'python_version').
        value: Specific value of the factor (e.g., 'github_actions', '3.11').
        correlation_coefficient: Pearson correlation with failure (-1.0 to 1.0).
            Positive values indicate this factor correlates with failures.
    """

    factor: str
    value: str
    correlation_coefficient: float

    @property
    def strength(self) -> str:
        """Get human-readable correlation strength."""
        abs_coef = abs(self.correlation_coefficient)
        if abs_coef >= 0.7:
            return "strong"
        elif abs_coef >= 0.4:
            return "moderate"
        elif abs_coef >= 0.2:
            return "weak"
        else:
            return "negligible"

    @property
    def direction(self) -> str:
        """Get correlation direction."""
        if self.correlation_coefficient > 0:
            return "increases failure rate"
        elif self.correlation_coefficient < 0:
            return "decreases failure rate"
        else:
            return "no effect"

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "factor": self.factor,
            "value": self.value,
            "correlation_coefficient": self.correlation_coefficient,
            "strength": self.strength,
            "direction": self.direction,
        }


@dataclass(frozen=True)
class FlakinessStat:
    """Statistical estimate of test flakiness rate.

    Attributes:
        pass_rate: Estimated pass rate (0.0-1.0).
        confidence_interval: Tuple of (lower_bound, upper_bound) for 95% CI.
        sample_size: Number of runs used for estimation.
    """

    pass_rate: float
    confidence_interval: Tuple[float, float]
    sample_size: int

    @property
    def margin_of_error(self) -> float:
        """Calculate margin of error."""
        return (self.confidence_interval[1] - self.confidence_interval[0]) / 2

    @property
    def is_reliable(self) -> bool:
        """Check if estimate is statistically reliable (narrow CI)."""
        return self.margin_of_error < 0.1 and self.sample_size >= 30

    def to_dict(self) -> Dict[str, Any]:
        """Convert to dictionary for serialization."""
        return {
            "pass_rate": self.pass_rate,
            "confidence_interval": {
                "lower": self.confidence_interval[0],
                "upper": self.confidence_interval[1],
            },
            "sample_size": self.sample_size,
            "margin_of_error": self.margin_of_error,
            "is_reliable": self.is_reliable,
        }


# =============================================================================
# Internal Data Structures
# =============================================================================


@dataclass
class TestRunRecord:
    """Internal record of a single test run."""

    test_name: str
    passed: bool
    run_at: datetime
    duration_ms: Optional[int] = None
    failure_signature: Optional[str] = None
    failure_message: Optional[str] = None

    # Environmental context
    ci_environment: Optional[str] = None
    python_version: Optional[str] = None
    os_name: Optional[str] = None
    os_version: Optional[str] = None
    branch: Optional[str] = None
    commit_sha: Optional[str] = None

    # Test ordering context
    tests_run_before: List[str] = field(default_factory=list)
    run_position: Optional[int] = None
    total_tests_in_suite: Optional[int] = None


@dataclass
class TestSessionRecord:
    """Record of a test session containing multiple test runs."""

    session_id: str
    run_at: datetime
    test_order: List[str]
    results: Dict[str, bool]  # test_name -> passed

    # Environmental context
    ci_environment: Optional[str] = None
    python_version: Optional[str] = None
    os_name: Optional[str] = None


# =============================================================================
# Database Protocol
# =============================================================================


class DatabasePool(Protocol):
    """Protocol for async database pool."""

    async def acquire(self) -> Connection:
        """Acquire a connection from the pool."""
        ...

    async def release(self, connection: Connection) -> None:
        """Release a connection back to the pool."""
        ...


# =============================================================================
# Multi-Run Analyzer
# =============================================================================


class MultiRunAnalyzer:
    """Analyzes flakiness patterns across multiple test runs.

    This analyzer provides comprehensive flakiness analysis including:
    - Pass/fail rate estimation with statistical confidence intervals
    - Failure clustering detection (do failures tend to occur together?)
    - Test order dependency detection
    - Environmental correlation analysis

    Example:
        >>> analyzer = MultiRunAnalyzer(db_pool, project_id="proj_123")
        >>> pattern = await analyzer.analyze_flakiness_pattern("test_login", n_runs=100)
        >>> print(f"Pass rate: {pattern.pass_rate:.1%}")
        >>> if pattern.is_flaky():
        ...     print("Test is flaky!")
        ...     for factor, corr in pattern.environmental_factors.items():
        ...         if abs(corr) > 0.3:
        ...             print(f"  {factor} correlation: {corr:.2f}")
    """

    def __init__(
        self,
        db_pool: Optional[DatabasePool] = None,
        project_id: Optional[str] = None,
        *,
        min_runs_for_analysis: int = 10,
        max_runs_to_fetch: int = 1000,
        confidence_level: float = 0.95,
    ):
        """Initialize the multi-run analyzer.

        Args:
            db_pool: Async database connection pool.
            project_id: Project ID for filtering runs.
            min_runs_for_analysis: Minimum runs required for reliable analysis.
            max_runs_to_fetch: Maximum runs to fetch from database.
            confidence_level: Confidence level for statistical intervals (default 95%).
        """
        self._db_pool = db_pool
        self._project_id = project_id
        self._min_runs = min_runs_for_analysis
        self._max_runs = max_runs_to_fetch
        self._confidence_level = confidence_level

        # Z-score for confidence interval (default 1.96 for 95%)
        self._z_score = self._get_z_score(confidence_level)

        # Cache for recent analyses
        self._cache: Dict[str, Tuple[datetime, Any]] = {}
        self._cache_ttl = timedelta(minutes=5)

        logger.debug(
            "MultiRunAnalyzer initialized for project=%s, min_runs=%d",
            project_id,
            min_runs_for_analysis,
        )

    @staticmethod
    def _get_z_score(confidence_level: float) -> float:
        """Get z-score for given confidence level.

        Uses an expanded lookup table for common confidence levels (fast
        and deterministic), then falls back to scipy.stats.norm.ppf for
        exact computation on arbitrary levels, and finally to the
        Abramowitz & Stegun rational approximation if scipy is unavailable.
        """
        # Expanded lookup table covering common confidence levels
        z_scores = {
            0.80: 1.282,
            0.85: 1.440,
            0.90: 1.645,
            0.925: 1.780,
            0.95: 1.960,
            0.975: 2.241,
            0.99: 2.576,
            0.995: 2.807,
            0.999: 3.291,
        }

        if confidence_level in z_scores:
            return z_scores[confidence_level]

        # Try scipy for exact computation on non-standard levels
        try:
            from scipy.stats import norm
            return float(norm.ppf((1 + confidence_level) / 2))
        except ImportError:
            pass

        # Abramowitz & Stegun rational approximation for the inverse
        # normal CDF (formula 26.2.23, |error| < 4.5e-4).
        p = (1 - confidence_level) / 2  # one-tail probability
        if p <= 0 or p >= 0.5:
            return 1.96  # safety fallback

        t = math.sqrt(-2.0 * math.log(p))
        # Coefficients for the approximation
        c0, c1, c2 = 2.515517, 0.802853, 0.010328
        d1, d2, d3 = 1.432788, 0.189269, 0.001308
        z = t - (c0 + c1 * t + c2 * t * t) / (1 + d1 * t + d2 * t * t + d3 * t * t * t)
        return z

    async def analyze_flakiness_pattern(
        self,
        test_name: str,
        n_runs: int = 100,
        *,
        since: Optional[datetime] = None,
    ) -> FlakinessPattern:
        """Analyze flakiness patterns for a test across multiple runs.

        Args:
            test_name: Name/identifier of the test to analyze.
            n_runs: Number of recent runs to analyze.
            since: Optional start date for analysis window.

        Returns:
            FlakinessPattern with comprehensive analysis.
        """
        # Fetch test runs
        runs = await self._fetch_test_runs(test_name, n_runs, since=since)

        if not runs:
            logger.warning("No runs found for test: %s", test_name)
            return FlakinessPattern(
                pass_rate=0.0,
                failure_clustering=0.0,
                common_failure_modes=[],
                environmental_factors={},
            )

        # Calculate pass rate
        pass_count = sum(1 for r in runs if r.passed)
        pass_rate = pass_count / len(runs)

        # Calculate failure clustering
        failure_clustering = self._calculate_failure_clustering(runs)

        # Identify common failure modes
        common_failure_modes = self._identify_common_failures(runs)

        # Analyze environmental correlations
        environmental_factors = self._analyze_environmental_factors(runs)

        logger.info(
            "Analyzed flakiness for %s: pass_rate=%.2f, clustering=%.2f, runs=%d",
            test_name,
            pass_rate,
            failure_clustering,
            len(runs),
        )

        return FlakinessPattern(
            pass_rate=pass_rate,
            failure_clustering=failure_clustering,
            common_failure_modes=common_failure_modes,
            environmental_factors=environmental_factors,
        )

    async def detect_test_order_dependency(
        self,
        test_name: str,
        *,
        min_sessions: int = 20,
        correlation_threshold: float = 0.3,
    ) -> Optional[OrderDependency]:
        """Detect if a test has order dependencies on other tests.

        Analyzes test sessions to find patterns where the test's outcome
        depends on which tests ran before it.

        Args:
            test_name: Name of the test to analyze.
            min_sessions: Minimum sessions required for analysis.
            correlation_threshold: Minimum correlation to consider significant.

        Returns:
            OrderDependency if detected, None otherwise.
        """
        # Fetch test sessions
        sessions = await self._fetch_test_sessions(test_name, min_sessions * 2)

        if len(sessions) < min_sessions:
            logger.debug(
                "Insufficient sessions for order dependency analysis: %d < %d",
                len(sessions),
                min_sessions,
            )
            return None

        # Analyze order dependencies
        depends_on: List[str] = []
        conflicts_with: List[str] = []
        max_confidence = 0.0

        # Get all unique tests that appeared in sessions
        all_tests = set()
        for session in sessions:
            all_tests.update(session.test_order)
        all_tests.discard(test_name)

        for other_test in all_tests:
            # Calculate correlation between other_test running before and this test passing
            runs_with_before = []
            runs_without_before = []

            for session in sessions:
                if test_name not in session.results:
                    continue

                test_passed = session.results[test_name]
                test_idx = session.test_order.index(test_name) if test_name in session.test_order else -1

                if other_test in session.test_order:
                    other_idx = session.test_order.index(other_test)
                    if other_idx < test_idx:
                        runs_with_before.append(1 if test_passed else 0)
                    else:
                        runs_without_before.append(1 if test_passed else 0)
                else:
                    runs_without_before.append(1 if test_passed else 0)

            if len(runs_with_before) < 5 or len(runs_without_before) < 5:
                continue

            # Calculate pass rates
            pass_rate_with = statistics.mean(runs_with_before) if runs_with_before else 0
            pass_rate_without = statistics.mean(runs_without_before) if runs_without_before else 0

            # Calculate effect size
            effect = pass_rate_with - pass_rate_without

            # Simple significance test (difference of proportions)
            n1, n2 = len(runs_with_before), len(runs_without_before)
            pooled = (sum(runs_with_before) + sum(runs_without_before)) / (n1 + n2)

            if pooled > 0 and pooled < 1:
                se = math.sqrt(pooled * (1 - pooled) * (1/n1 + 1/n2))
                if se > 0:
                    z_stat = abs(effect) / se
                    confidence = min(1.0, z_stat / 3)  # Normalize to 0-1

                    if abs(effect) > correlation_threshold and confidence > 0.5:
                        max_confidence = max(max_confidence, confidence)
                        if effect > 0:
                            # Running other_test before increases pass rate
                            depends_on.append(other_test)
                        else:
                            # Running other_test before decreases pass rate
                            conflicts_with.append(other_test)

        if not depends_on and not conflicts_with:
            return None

        logger.info(
            "Detected order dependency for %s: depends_on=%d, conflicts_with=%d, confidence=%.2f",
            test_name,
            len(depends_on),
            len(conflicts_with),
            max_confidence,
        )

        return OrderDependency(
            depends_on=depends_on,
            conflicts_with=conflicts_with,
            confidence=max_confidence,
        )

    async def find_environmental_correlations(
        self,
        test_name: str,
        *,
        min_samples_per_value: int = 5,
    ) -> List[Correlation]:
        """Find environmental factors correlated with test failures.

        Analyzes various environmental factors to identify which ones
        are significantly correlated with test outcomes.

        Args:
            test_name: Name of the test to analyze.
            min_samples_per_value: Minimum samples per factor value.

        Returns:
            List of significant correlations, sorted by strength.
        """
        runs = await self._fetch_test_runs(test_name, self._max_runs)

        if len(runs) < self._min_runs:
            logger.debug(
                "Insufficient runs for correlation analysis: %d < %d",
                len(runs),
                self._min_runs,
            )
            return []

        correlations: List[Correlation] = []

        # Analyze each environmental factor
        factors = [
            ("ci_environment", lambda r: r.ci_environment),
            ("python_version", lambda r: r.python_version),
            ("os_name", lambda r: r.os_name),
            ("os_version", lambda r: r.os_version),
            ("branch", lambda r: r.branch),
        ]

        for factor_name, extractor in factors:
            factor_correlations = self._calculate_factor_correlation(
                runs, factor_name, extractor, min_samples_per_value
            )
            correlations.extend(factor_correlations)

        # Sort by absolute correlation strength
        correlations.sort(key=lambda c: abs(c.correlation_coefficient), reverse=True)

        logger.info(
            "Found %d environmental correlations for %s",
            len(correlations),
            test_name,
        )

        return correlations

    async def estimate_flakiness_rate(
        self,
        test_name: str,
        *,
        since: Optional[datetime] = None,
    ) -> FlakinessStat:
        """Estimate the flakiness rate with statistical confidence.

        Uses Wilson score interval for more accurate confidence intervals
        with small sample sizes.

        Args:
            test_name: Name of the test to analyze.
            since: Optional start date for analysis window.

        Returns:
            FlakinessStat with pass rate and confidence interval.
        """
        runs = await self._fetch_test_runs(test_name, self._max_runs, since=since)

        if not runs:
            return FlakinessStat(
                pass_rate=0.0,
                confidence_interval=(0.0, 1.0),
                sample_size=0,
            )

        n = len(runs)
        pass_count = sum(1 for r in runs if r.passed)
        pass_rate = pass_count / n

        # Wilson score interval for binomial proportion
        # More accurate than normal approximation for small samples
        z = self._z_score
        denominator = 1 + z * z / n

        center = (pass_rate + z * z / (2 * n)) / denominator
        margin = z * math.sqrt(
            (pass_rate * (1 - pass_rate) + z * z / (4 * n)) / n
        ) / denominator

        lower = max(0.0, center - margin)
        upper = min(1.0, center + margin)

        logger.info(
            "Estimated flakiness rate for %s: %.2f (CI: %.2f-%.2f, n=%d)",
            test_name,
            pass_rate,
            lower,
            upper,
            n,
        )

        return FlakinessStat(
            pass_rate=pass_rate,
            confidence_interval=(lower, upper),
            sample_size=n,
        )

    # =========================================================================
    # Private Methods - Data Fetching
    # =========================================================================

    async def _fetch_test_runs(
        self,
        test_name: str,
        limit: int,
        *,
        since: Optional[datetime] = None,
    ) -> List[TestRunRecord]:
        """Fetch test run records from database.

        Args:
            test_name: Name of the test.
            limit: Maximum runs to fetch.
            since: Optional start date filter.

        Returns:
            List of TestRunRecord sorted by run_at descending.
        """
        if self._db_pool is None:
            logger.warning("No database pool configured, returning empty results")
            return []

        try:
            conn = await self._db_pool.acquire()
            try:
                query = """
                    SELECT
                        test_name,
                        passed,
                        run_at,
                        duration_ms,
                        failure_signature,
                        failure_message,
                        ci_environment,
                        python_version,
                        os_name,
                        os_version,
                        branch,
                        commit_sha,
                        run_position,
                        total_tests_in_suite
                    FROM test_runs
                    WHERE project_id = $1
                      AND test_name = $2
                      AND ($3::timestamptz IS NULL OR run_at >= $3)
                    ORDER BY run_at DESC
                    LIMIT $4
                """

                rows = await conn.fetch(
                    query,
                    self._project_id,
                    test_name,
                    since,
                    limit,
                )

                return [
                    TestRunRecord(
                        test_name=row["test_name"],
                        passed=row["passed"],
                        run_at=row["run_at"],
                        duration_ms=row["duration_ms"],
                        failure_signature=row["failure_signature"],
                        failure_message=row["failure_message"],
                        ci_environment=row["ci_environment"],
                        python_version=row["python_version"],
                        os_name=row["os_name"],
                        os_version=row["os_version"],
                        branch=row["branch"],
                        commit_sha=row["commit_sha"],
                        run_position=row["run_position"],
                        total_tests_in_suite=row["total_tests_in_suite"],
                    )
                    for row in rows
                ]
            finally:
                await self._db_pool.release(conn)
        except Exception as e:
            logger.error("Failed to fetch test runs: %s", e)
            return []

    async def _fetch_test_sessions(
        self,
        test_name: str,
        limit: int,
    ) -> List[TestSessionRecord]:
        """Fetch test session records from database.

        Args:
            test_name: Name of the test to find sessions for.
            limit: Maximum sessions to fetch.

        Returns:
            List of TestSessionRecord sorted by run_at descending.
        """
        if self._db_pool is None:
            logger.warning("No database pool configured, returning empty results")
            return []

        try:
            conn = await self._db_pool.acquire()
            try:
                # Fetch sessions that include the target test
                query = """
                    SELECT
                        s.session_id,
                        s.run_at,
                        s.test_order,
                        s.ci_environment,
                        s.python_version,
                        s.os_name,
                        array_agg(r.test_name) as test_names,
                        array_agg(r.passed) as results
                    FROM test_sessions s
                    JOIN test_runs r ON r.session_id = s.session_id
                    WHERE s.project_id = $1
                      AND EXISTS (
                          SELECT 1 FROM test_runs r2
                          WHERE r2.session_id = s.session_id
                            AND r2.test_name = $2
                      )
                    GROUP BY s.session_id, s.run_at, s.test_order,
                             s.ci_environment, s.python_version, s.os_name
                    ORDER BY s.run_at DESC
                    LIMIT $3
                """

                rows = await conn.fetch(query, self._project_id, test_name, limit)

                sessions = []
                for row in rows:
                    # Build results dict from arrays
                    results = dict(zip(row["test_names"], row["results"]))
                    test_order = row["test_order"] if row["test_order"] else []

                    sessions.append(
                        TestSessionRecord(
                            session_id=row["session_id"],
                            run_at=row["run_at"],
                            test_order=test_order,
                            results=results,
                            ci_environment=row["ci_environment"],
                            python_version=row["python_version"],
                            os_name=row["os_name"],
                        )
                    )

                return sessions
            finally:
                await self._db_pool.release(conn)
        except Exception as e:
            logger.error("Failed to fetch test sessions: %s", e)
            return []

    # =========================================================================
    # Private Methods - Analysis
    # =========================================================================

    def _calculate_failure_clustering(self, runs: List[TestRunRecord]) -> float:
        """Calculate failure clustering score.

        Higher scores indicate failures tend to occur in consecutive runs.
        Uses runs ratio method: ratio of actual runs to expected under random.

        Args:
            runs: List of test runs sorted by time.

        Returns:
            Clustering score from 0.0 (random) to 1.0 (highly clustered).
        """
        if len(runs) < 2:
            return 0.0

        # Count runs (consecutive sequences of same result)
        actual_runs = 1
        for i in range(1, len(runs)):
            if runs[i].passed != runs[i - 1].passed:
                actual_runs += 1

        # Expected runs under random (Wald-Wolfowitz)
        n = len(runs)
        n_pass = sum(1 for r in runs if r.passed)
        n_fail = n - n_pass

        if n_pass == 0 or n_fail == 0:
            return 0.0  # All same result, can't calculate clustering

        expected_runs = 1 + (2 * n_pass * n_fail) / n

        # Clustering score: lower actual runs than expected = more clustering
        # Normalize to 0-1 scale
        if expected_runs <= 1:
            return 0.0

        clustering = 1 - (actual_runs / expected_runs)
        return max(0.0, min(1.0, clustering))

    def _identify_common_failures(
        self,
        runs: List[TestRunRecord],
        top_n: int = 5,
    ) -> List[Tuple[str, int, float]]:
        """Identify the most common failure modes.

        Args:
            runs: List of test runs.
            top_n: Number of top failure modes to return.

        Returns:
            List of (signature, count, percentage) tuples.
        """
        failure_counts: Dict[str, int] = defaultdict(int)
        total_failures = 0

        for run in runs:
            if not run.passed:
                total_failures += 1
                signature = run.failure_signature or "unknown"
                failure_counts[signature] += 1

        if total_failures == 0:
            return []

        # Sort by count and take top N
        sorted_failures = sorted(
            failure_counts.items(),
            key=lambda x: x[1],
            reverse=True,
        )[:top_n]

        return [
            (sig, count, count / total_failures)
            for sig, count in sorted_failures
        ]

    def _analyze_environmental_factors(
        self,
        runs: List[TestRunRecord],
    ) -> Dict[str, float]:
        """Analyze environmental factors correlated with failures.

        Args:
            runs: List of test runs.

        Returns:
            Dict mapping factor names to correlation coefficients.
        """
        factors: Dict[str, float] = {}

        # CI vs local correlation
        ci_runs = [r for r in runs if r.ci_environment]
        local_runs = [r for r in runs if not r.ci_environment]

        if len(ci_runs) >= 5 and len(local_runs) >= 5:
            ci_fail_rate = sum(1 for r in ci_runs if not r.passed) / len(ci_runs)
            local_fail_rate = sum(1 for r in local_runs if not r.passed) / len(local_runs)
            factors["ci_vs_local"] = ci_fail_rate - local_fail_rate

        # Python version correlation (aggregate)
        py_versions: Dict[str, List[bool]] = defaultdict(list)
        for run in runs:
            if run.python_version:
                py_versions[run.python_version].append(not run.passed)

        if len(py_versions) >= 2:
            version_fail_rates = {
                v: statistics.mean(fails) if fails else 0
                for v, fails in py_versions.items()
                if len(fails) >= 5
            }
            if version_fail_rates:
                avg_rate = statistics.mean(version_fail_rates.values())
                max_diff = max(abs(r - avg_rate) for r in version_fail_rates.values())
                factors["python_version_variance"] = max_diff

        # OS correlation
        os_names: Dict[str, List[bool]] = defaultdict(list)
        for run in runs:
            if run.os_name:
                os_names[run.os_name].append(not run.passed)

        if len(os_names) >= 2:
            os_fail_rates = {
                os: statistics.mean(fails) if fails else 0
                for os, fails in os_names.items()
                if len(fails) >= 5
            }
            if os_fail_rates:
                avg_rate = statistics.mean(os_fail_rates.values())
                max_diff = max(abs(r - avg_rate) for r in os_fail_rates.values())
                factors["os_variance"] = max_diff

        return factors

    def _calculate_factor_correlation(
        self,
        runs: List[TestRunRecord],
        factor_name: str,
        extractor: callable,
        min_samples: int,
    ) -> List[Correlation]:
        """Calculate point-biserial correlation for a factor.

        Args:
            runs: List of test runs.
            factor_name: Name of the factor.
            extractor: Function to extract factor value from run.
            min_samples: Minimum samples per value.

        Returns:
            List of Correlation objects for each significant value.
        """
        correlations = []

        # Group runs by factor value
        by_value: Dict[str, List[TestRunRecord]] = defaultdict(list)
        for run in runs:
            value = extractor(run)
            if value:
                by_value[value].append(run)

        # Overall failure rate
        total_failures = sum(1 for r in runs if not r.passed)
        overall_fail_rate = total_failures / len(runs) if runs else 0

        for value, value_runs in by_value.items():
            if len(value_runs) < min_samples:
                continue

            # Calculate failure rate for this value
            value_failures = sum(1 for r in value_runs if not r.passed)
            value_fail_rate = value_failures / len(value_runs)

            # Point-biserial correlation: r_pb = (M1 - M0) / s * sqrt(n1*n0/n^2)
            n = len(runs)
            n1 = len(value_runs)
            n0 = n - n1

            if n1 == n or n0 == 0:
                continue  # All runs have this value, no comparison possible

            # M1 = fail rate when factor present, M0 = fail rate when absent
            m1 = value_fail_rate
            value_run_ids = {id(r) for r in value_runs}
            other_failures = sum(1 for r in runs if not r.passed and id(r) not in value_run_ids)
            m0 = other_failures / n0 if n0 > 0 else 0

            # Pooled std dev of the binary outcome (pass/fail)
            import math
            s = math.sqrt(overall_fail_rate * (1 - overall_fail_rate)) if 0 < overall_fail_rate < 1 else 1e-10

            correlation = (m1 - m0) / s * math.sqrt(n1 * n0 / (n * n)) if s > 0 else 0.0
            correlation = max(-1.0, min(1.0, correlation))  # Clamp to valid range

            # Only include if non-negligible
            if abs(correlation) >= 0.05:
                correlations.append(
                    Correlation(
                        factor=factor_name,
                        value=value,
                        correlation_coefficient=correlation,
                    )
                )

        return correlations


# =============================================================================
# Convenience Functions
# =============================================================================


async def quick_flakiness_check(
    db_pool: DatabasePool,
    project_id: str,
    test_name: str,
    *,
    min_runs: int = 10,
) -> Dict[str, Any]:
    """Quick check if a test is flaky with basic statistics.

    Args:
        db_pool: Database connection pool.
        project_id: Project identifier.
        test_name: Name of the test.
        min_runs: Minimum runs for analysis.

    Returns:
        Dict with is_flaky, pass_rate, and recommendation.
    """
    analyzer = MultiRunAnalyzer(
        db_pool,
        project_id,
        min_runs_for_analysis=min_runs,
    )

    stat = await analyzer.estimate_flakiness_rate(test_name)

    is_flaky = 0.1 < stat.pass_rate < 0.9 and stat.sample_size >= min_runs

    return {
        "is_flaky": is_flaky,
        "pass_rate": stat.pass_rate,
        "sample_size": stat.sample_size,
        "confidence_interval": stat.confidence_interval,
        "is_reliable": stat.is_reliable,
        "recommendation": (
            "Test appears flaky - run full analysis"
            if is_flaky
            else "Test appears stable"
            if stat.sample_size >= min_runs
            else f"Insufficient data - need at least {min_runs} runs"
        ),
    }


async def full_flakiness_report(
    db_pool: DatabasePool,
    project_id: str,
    test_name: str,
) -> Dict[str, Any]:
    """Generate a comprehensive flakiness report for a test.

    Args:
        db_pool: Database connection pool.
        project_id: Project identifier.
        test_name: Name of the test.

    Returns:
        Dict with complete flakiness analysis.
    """
    analyzer = MultiRunAnalyzer(db_pool, project_id)

    # Run all analyses
    pattern = await analyzer.analyze_flakiness_pattern(test_name)
    stat = await analyzer.estimate_flakiness_rate(test_name)
    order_dep = await analyzer.detect_test_order_dependency(test_name)
    correlations = await analyzer.find_environmental_correlations(test_name)

    return {
        "test_name": test_name,
        "is_flaky": pattern.is_flaky(),
        "pattern": pattern.to_dict(),
        "statistics": stat.to_dict(),
        "order_dependency": order_dep.to_dict() if order_dep else None,
        "environmental_correlations": [c.to_dict() for c in correlations],
        "recommendations": _generate_recommendations(pattern, order_dep, correlations),
    }


def _generate_recommendations(
    pattern: FlakinessPattern,
    order_dep: Optional[OrderDependency],
    correlations: List[Correlation],
) -> List[str]:
    """Generate actionable recommendations based on analysis.

    Args:
        pattern: Flakiness pattern analysis.
        order_dep: Order dependency detection result.
        correlations: Environmental correlations.

    Returns:
        List of recommendation strings.
    """
    recommendations = []

    # Pass rate recommendations
    if pattern.pass_rate < 0.5:
        recommendations.append(
            "Test has low pass rate (<50%). Consider investigating "
            "root cause before addressing flakiness."
        )
    elif pattern.is_flaky():
        recommendations.append(
            "Test is flaky. Review common failure modes and environmental factors."
        )

    # Failure clustering
    if pattern.failure_clustering > 0.5:
        recommendations.append(
            "Failures tend to cluster together. This may indicate "
            "state pollution or resource exhaustion issues."
        )

    # Order dependency
    if order_dep:
        if order_dep.depends_on:
            recommendations.append(
                f"Test depends on: {', '.join(order_dep.depends_on[:3])}. "
                "Consider explicit setup or fixture dependencies."
            )
        if order_dep.conflicts_with:
            recommendations.append(
                f"Test conflicts with: {', '.join(order_dep.conflicts_with[:3])}. "
                "These tests may share mutable state."
            )

    # Environmental correlations
    strong_correlations = [c for c in correlations if abs(c.correlation_coefficient) > 0.3]
    for corr in strong_correlations[:3]:
        recommendations.append(
            f"Strong correlation with {corr.factor}={corr.value} "
            f"({corr.strength} {corr.direction}). "
            "Investigate environment-specific behavior."
        )

    if not recommendations:
        recommendations.append("No specific issues detected. Test appears stable.")

    return recommendations
