"""Early Flake Detection - catch flaky tests before merge.

Runs new tests multiple times to detect flakiness BEFORE they hit main.
This is based on Datadog's Early Flake Detection feature.

Process:
1. Detect new tests in PR (via git diff)
2. Run each new test N times (default: 10)
3. If any test passes AND fails, it's flaky
4. Block/warn before merge

Usage:
    detector = EarlyFlakeDetector()
    results = await detector.check_pr(base_ref="main", head_ref="HEAD")

    for result in results:
        if result.is_flaky:
            print(f"Flaky test detected: {result.test_name}")
"""

from __future__ import annotations

import ast
import asyncio
import re
import subprocess
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import List, Optional


class TestStatus(Enum):
    """Status of a single test run."""
    PASSED = "passed"
    FAILED = "failed"
    ERROR = "error"
    SKIPPED = "skipped"
    TIMEOUT = "timeout"


@dataclass
class NewTest:
    """A newly added test detected in the PR."""
    name: str  # e.g., "test_login"
    full_name: str  # e.g., "tests/test_auth.py::test_login"
    file_path: str  # e.g., "tests/test_auth.py"
    line_number: int
    is_new_file: bool  # True if entire file is new
    is_async: bool = False
    decorators: List[str] = field(default_factory=list)


@dataclass
class TestRunResult:
    """Result of a single test run."""
    run_number: int
    status: TestStatus
    duration_seconds: float
    error_message: Optional[str] = None
    stdout: str = ""
    stderr: str = ""


@dataclass
class EarlyFlakeResult:
    """Result of early flake detection for a single test."""
    test_name: str
    full_name: str
    total_runs: int
    passed: int
    failed: int
    errors: int
    is_flaky: bool
    failure_patterns: List[str]
    run_details: List[TestRunResult]
    total_duration_seconds: float
    recommendation: str  # "block", "warn", "pass"

    @property
    def flakiness_rate(self) -> float:
        """Calculate flakiness rate (0 = stable, 1 = completely random)."""
        if self.total_runs == 0:
            return 0.0
        # A test is maximally flaky when it's 50/50 pass/fail
        p = self.passed / self.total_runs
        return 4 * p * (1 - p)  # Peaks at 1.0 when p=0.5


@dataclass
class EarlyFlakeReport:
    """Complete early flake detection report."""
    base_ref: str
    head_ref: str
    new_tests_found: int
    tests_checked: int
    flaky_tests_detected: int
    results: List[EarlyFlakeResult]
    total_duration_seconds: float
    recommendation: str  # "block", "warn", "pass"
    generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    def to_github_comment(self) -> str:
        """Generate GitHub PR comment."""
        if self.flaky_tests_detected == 0:
            return f"""## ✅ Early Flake Detection Passed

Checked {self.tests_checked} new test(s) by running each {self.results[0].total_runs if self.results else 10}x.
No flaky tests detected.

<details>
<summary>Test Results</summary>

| Test | Runs | Passed | Status |
|------|------|--------|--------|
""" + "\n".join(
                f"| `{r.test_name}` | {r.total_runs} | {r.passed}/{r.total_runs} | ✅ |"
                for r in self.results
            ) + "\n</details>"

        flaky_list = [r for r in self.results if r.is_flaky]
        return f"""## ⚠️ Early Flake Detection Found Issues

**{self.flaky_tests_detected} flaky test(s) detected** in this PR.

These tests passed and failed across {flaky_list[0].total_runs if flaky_list else 10} runs,
indicating they may cause intermittent CI failures.

### Flaky Tests

| Test | Pass Rate | Flakiness | Recommendation |
|------|-----------|-----------|----------------|
""" + "\n".join(
            f"| `{r.test_name}` | {r.passed}/{r.total_runs} ({r.passed/r.total_runs:.0%}) | {r.flakiness_rate:.0%} | {r.recommendation} |"
            for r in flaky_list
        ) + """

### Failure Patterns

""" + "\n".join(
            f"**`{r.test_name}`**: {', '.join(r.failure_patterns[:3]) or 'Unknown'}"
            for r in flaky_list
        ) + """

---
*Run `hikaflow early-flake --help` for more options.*
"""


class NewTestDetector:
    """Detect new tests added in a PR."""

    def __init__(self, repo_path: Optional[Path] = None):
        """Initialize detector.

        Args:
            repo_path: Path to git repository. Uses cwd if None.
        """
        self.repo_path = repo_path or Path.cwd()

    async def find_new_tests(
        self,
        base_ref: str,
        head_ref: str = "HEAD",
        test_pattern: str = "test_*.py",
    ) -> List[NewTest]:
        """Find tests that are new in head compared to base.

        Args:
            base_ref: Base branch/commit (e.g., "main", "origin/main")
            head_ref: Head branch/commit (e.g., "HEAD", "feature-branch")
            test_pattern: Glob pattern for test files

        Returns:
            List of newly added tests.
        """
        # Get changed files
        diff_result = await self._run_git(
            "diff", "--name-status", f"{base_ref}...{head_ref}", "--", f"**/{test_pattern}"
        )

        new_tests: List[NewTest] = []

        for line in diff_result.stdout.strip().split("\n"):
            if not line:
                continue

            parts = line.split("\t")
            if len(parts) < 2:
                continue

            status, file_path = parts[0], parts[-1]

            if not file_path.endswith(".py"):
                continue

            if status == "A":
                # New file - all tests are new
                tests = await self._extract_tests_from_file(
                    Path(file_path), is_new_file=True
                )
                new_tests.extend(tests)

            elif status.startswith("M"):
                # Modified file - find new test functions
                tests = await self._find_new_tests_in_modified_file(
                    base_ref, head_ref, file_path
                )
                new_tests.extend(tests)

            elif status.startswith("R"):
                # Renamed file - treat destination as new file
                tests = await self._extract_tests_from_file(
                    Path(file_path), is_new_file=False
                )
                new_tests.extend(tests)

            elif status.startswith("C"):
                # Copied file - treat destination as new file
                tests = await self._extract_tests_from_file(
                    Path(file_path), is_new_file=True
                )
                new_tests.extend(tests)

        return new_tests

    async def _run_git(self, *args: str) -> subprocess.CompletedProcess:
        """Run a git command."""
        proc = await asyncio.create_subprocess_exec(
            "git", *args,
            cwd=self.repo_path,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await proc.communicate()
        return subprocess.CompletedProcess(
            args=["git"] + list(args),
            returncode=proc.returncode or 0,
            stdout=stdout.decode("utf-8", errors="replace"),
            stderr=stderr.decode("utf-8", errors="replace"),
        )

    async def _extract_tests_from_file(
        self,
        file_path: Path,
        is_new_file: bool = False,
    ) -> List[NewTest]:
        """Extract all test functions from a Python file."""
        full_path = self.repo_path / file_path
        if not full_path.exists():
            return []

        try:
            source = full_path.read_text()
            tree = ast.parse(source)
        except (OSError, SyntaxError):
            return []

        tests = []
        for node in ast.walk(tree):
            if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef):
                if node.name.startswith("test_"):
                    decorators = [
                        self._get_decorator_name(d) for d in node.decorator_list
                    ]
                    tests.append(NewTest(
                        name=node.name,
                        full_name=f"{file_path}::{node.name}",
                        file_path=str(file_path),
                        line_number=node.lineno,
                        is_new_file=is_new_file,
                        is_async=isinstance(node, ast.AsyncFunctionDef),
                        decorators=decorators,
                    ))

        return tests

    async def _find_new_tests_in_modified_file(
        self,
        base_ref: str,
        head_ref: str,
        file_path: str,
    ) -> List[NewTest]:
        """Find new test functions added to a modified file."""
        # Get tests in base version
        base_tests = await self._get_tests_at_ref(base_ref, file_path)

        # Get tests in head version
        head_tests = await self._extract_tests_from_file(Path(file_path))

        # Find tests that exist in head but not in base
        base_names = {t.name for t in base_tests}
        return [t for t in head_tests if t.name not in base_names]

    async def _get_tests_at_ref(self, ref: str, file_path: str) -> List[NewTest]:
        """Get test functions from a file at a specific git ref."""
        result = await self._run_git("show", f"{ref}:{file_path}")

        if result.returncode != 0:
            return []

        try:
            tree = ast.parse(result.stdout)
        except SyntaxError:
            return []

        tests = []
        for node in ast.walk(tree):
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                if node.name.startswith("test_"):
                    tests.append(NewTest(
                        name=node.name,
                        full_name=f"{file_path}::{node.name}",
                        file_path=file_path,
                        line_number=node.lineno,
                        is_new_file=False,
                    ))

        return tests

    def _get_decorator_name(self, decorator: ast.expr) -> str:
        """Extract decorator name from AST node."""
        if isinstance(decorator, ast.Name):
            return decorator.id
        elif isinstance(decorator, ast.Attribute):
            return f"{self._get_decorator_name(decorator.value)}.{decorator.attr}"
        elif isinstance(decorator, ast.Call):
            return self._get_decorator_name(decorator.func)
        return "unknown"


class EarlyFlakeDetector:
    """Run new tests multiple times to detect flakiness before merge."""

    def __init__(
        self,
        num_runs: Optional[int] = None,
        flaky_threshold: int = 1,
        timeout_per_run: int = 300,
        parallel: int = 4,
        repo_path: Optional[Path] = None,
        historical_total_runs: int = 0,
        historical_inconsistent_runs: int = 0,
    ):
        """Initialize early flake detector.

        Args:
            num_runs: Number of times to run each test. None = use adaptive_n.
            flaky_threshold: Number of failures to mark as flaky.
            timeout_per_run: Timeout per test run in seconds.
            parallel: Number of parallel test runs.
            repo_path: Path to repository.
            historical_total_runs: Total historical runs (for adaptive N).
            historical_inconsistent_runs: Historical inconsistent runs (for adaptive N).
        """
        self.num_runs = num_runs
        self.flaky_threshold = flaky_threshold
        self.timeout_per_run = timeout_per_run
        self.parallel = parallel
        self.repo_path = repo_path or Path.cwd()
        self.test_detector = NewTestDetector(repo_path)
        self.historical_total_runs = historical_total_runs
        self.historical_inconsistent_runs = historical_inconsistent_runs

    async def check_pr(
        self,
        base_ref: str,
        head_ref: str = "HEAD",
        test_command_template: str = "pytest {test} -x --tb=short",
    ) -> EarlyFlakeReport:
        """Check a PR for flaky new tests.

        Args:
            base_ref: Base branch (e.g., "main")
            head_ref: Head branch (e.g., "HEAD")
            test_command_template: Template for running tests. {test} is replaced.

        Returns:
            EarlyFlakeReport with all results.
        """
        start_time = time.time()

        # Find new tests
        new_tests = await self.test_detector.find_new_tests(base_ref, head_ref)

        if not new_tests:
            return EarlyFlakeReport(
                base_ref=base_ref,
                head_ref=head_ref,
                new_tests_found=0,
                tests_checked=0,
                flaky_tests_detected=0,
                results=[],
                total_duration_seconds=time.time() - start_time,
                recommendation="pass",
            )

        # Check each new test in parallel
        tasks = [self.check_test(test, test_command_template) for test in new_tests]
        results = await asyncio.gather(*tasks)

        flaky_count = sum(1 for r in results if r.is_flaky)

        # Determine recommendation
        if flaky_count > 0:
            recommendation = "warn"  # Could be "block" for stricter policy
        else:
            recommendation = "pass"

        return EarlyFlakeReport(
            base_ref=base_ref,
            head_ref=head_ref,
            new_tests_found=len(new_tests),
            tests_checked=len(results),
            flaky_tests_detected=flaky_count,
            results=results,
            total_duration_seconds=time.time() - start_time,
            recommendation=recommendation,
        )

    async def check_test(
        self,
        test: NewTest,
        test_command_template: str,
    ) -> EarlyFlakeResult:
        """Check a single test for flakiness.

        Args:
            test: The test to check.
            test_command_template: Template for running the test.

        Returns:
            EarlyFlakeResult for this test.
        """
        start_time = time.time()
        run_details: List[TestRunResult] = []

        # Determine N: use adaptive_n if num_runs not set
        if self.num_runs is not None:
            n_runs = self.num_runs
        else:
            try:
                from autodebug.detection.adaptive_n import (
                    calculate_adaptive_n,
                    estimate_flake_rate_from_history,
                )
                # Use historical data if available, otherwise fall back to default
                flake_rate = estimate_flake_rate_from_history(
                    total_runs=self.historical_total_runs,
                    inconsistent_runs=self.historical_inconsistent_runs,
                )
                n_runs = calculate_adaptive_n(
                    estimated_flake_rate=flake_rate,
                    target_power=0.95,
                    max_n=100,
                    budget_seconds=self.timeout_per_run * 20,
                    avg_test_duration_seconds=self.timeout_per_run,
                )
            except ImportError:
                n_runs = 10

        # Run test N times
        semaphore = asyncio.Semaphore(self.parallel)

        async def run_with_semaphore(run_number: int) -> TestRunResult:
            async with semaphore:
                return await self._run_test_once(test, test_command_template, run_number)

        tasks = [run_with_semaphore(i + 1) for i in range(n_runs)]
        run_details = await asyncio.gather(*tasks)

        # Analyze results
        passed = sum(1 for r in run_details if r.status == TestStatus.PASSED)
        failed = sum(1 for r in run_details if r.status == TestStatus.FAILED)
        errors = sum(1 for r in run_details if r.status in (TestStatus.ERROR, TestStatus.TIMEOUT))

        # A test is flaky if it both passes AND fails
        is_flaky = passed > 0 and (failed + errors) >= self.flaky_threshold

        # Extract failure patterns
        failure_patterns = self._extract_failure_patterns(run_details)

        # Determine recommendation
        if is_flaky:
            if failed >= n_runs // 2:
                recommendation = "block"  # Fails more than half the time
            else:
                recommendation = "warn"  # Occasional failures
        else:
            recommendation = "pass"

        return EarlyFlakeResult(
            test_name=test.name,
            full_name=test.full_name,
            total_runs=n_runs,
            passed=passed,
            failed=failed,
            errors=errors,
            is_flaky=is_flaky,
            failure_patterns=failure_patterns,
            run_details=list(run_details),
            total_duration_seconds=time.time() - start_time,
            recommendation=recommendation,
        )

    async def _run_test_once(
        self,
        test: NewTest,
        test_command_template: str,
        run_number: int,
    ) -> TestRunResult:
        """Run a test once and capture the result."""
        start_time = time.time()

        import shlex
        command = test_command_template.format(test=shlex.quote(test.full_name))

        try:
            process = await asyncio.create_subprocess_shell(
                command,
                cwd=self.repo_path,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )

            try:
                stdout, stderr = await asyncio.wait_for(
                    process.communicate(),
                    timeout=self.timeout_per_run,
                )
            except asyncio.TimeoutError:
                process.kill()
                await process.communicate()
                return TestRunResult(
                    run_number=run_number,
                    status=TestStatus.TIMEOUT,
                    duration_seconds=time.time() - start_time,
                    error_message=f"Timeout after {self.timeout_per_run}s",
                )

            duration = time.time() - start_time

            if process.returncode == 0:
                status = TestStatus.PASSED
            elif process.returncode == 5:
                status = TestStatus.SKIPPED  # pytest exit code for no tests
            else:
                status = TestStatus.FAILED

            return TestRunResult(
                run_number=run_number,
                status=status,
                duration_seconds=duration,
                stdout=stdout.decode("utf-8", errors="replace")[:2000],
                stderr=stderr.decode("utf-8", errors="replace")[:2000],
            )

        except Exception as e:
            return TestRunResult(
                run_number=run_number,
                status=TestStatus.ERROR,
                duration_seconds=time.time() - start_time,
                error_message=str(e),
            )

    def _extract_failure_patterns(self, run_details: List[TestRunResult]) -> List[str]:
        """Extract common failure patterns from test runs."""
        patterns: List[str] = []

        for detail in run_details:
            if detail.status not in (TestStatus.FAILED, TestStatus.ERROR):
                continue

            combined_output = detail.stdout + detail.stderr

            # Look for common patterns (independent checks, not elif)
            if "AssertionError" in combined_output:
                match = re.search(r'AssertionError: (.+?)(?:\n|$)', combined_output)
                if match:
                    patterns.append(f"AssertionError: {match.group(1)[:50]}")

            if "TimeoutError" in combined_output:
                patterns.append("TimeoutError")

            if "ConnectionError" in combined_output:
                patterns.append("ConnectionError")

            if "HTTPError" in combined_output:
                patterns.append("HTTPError")

            if detail.error_message:
                patterns.append(detail.error_message[:50])

        # Deduplicate and return most common
        from collections import Counter
        counter = Counter(patterns)
        return [p for p, _ in counter.most_common(5)]
