"""Flakiness analysis for detecting non-deterministic tests.

This module provides:
1. Flakiness detection by running validation multiple times
2. Test health tracking with flakiness scores
3. Quarantine management for flaky tests

Based on Atlassian Flakinator architecture:
https://www.atlassian.com/blog/atlassian-engineering/taming-test-flakiness-how-we-built-a-scalable-tool-to-detect-and-manage-flaky-tests
"""

from __future__ import annotations

import hashlib
import json
import os
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set

import requests


@dataclass
class RunResult:
    """Result of a single validation run."""

    run_number: int
    exit_code: int
    duration_seconds: float
    exception_type: Optional[str] = None
    exception_message: Optional[str] = None
    exception_location: Optional[str] = None
    output_hash: Optional[str] = None
    error_output: Optional[str] = None

    @property
    def passed(self) -> bool:
        return self.exit_code == 0

    @property
    def failure_signature(self) -> str:
        """Generate a signature for this failure type."""
        if self.passed:
            return "PASS"
        parts = [self.exception_type or "UnknownError"]
        if self.exception_location:
            parts.append(self.exception_location)
        return ":".join(parts)


@dataclass
class FlakinessResult:
    """Result of flakiness analysis."""

    total_runs: int
    pass_count: int
    fail_count: int
    unique_failures: Dict[str, List[RunResult]]
    total_duration_seconds: float
    runs: List[RunResult] = field(default_factory=list)

    @property
    def flakiness_score(self) -> float:
        """Calculate flakiness as percentage of different failure types."""
        if self.fail_count == 0:
            return 0.0
        # If all failures are identical, flakiness is 0
        # If all failures are different, flakiness is 100
        unique_count = len(self.unique_failures)
        if unique_count <= 1:
            return 0.0
        return (unique_count - 1) / self.fail_count * 100

    @property
    def is_deterministic(self) -> bool:
        """Check if test is deterministic (same result every time)."""
        if not self.runs:
            return True
        first_sig = self.runs[0].failure_signature
        return all(r.failure_signature == first_sig for r in self.runs)

    @property
    def primary_failure(self) -> Optional[str]:
        """Get the most common failure signature."""
        if not self.unique_failures:
            return None
        return max(self.unique_failures.keys(), key=lambda k: len(self.unique_failures[k]))


def run_harness_once(
    harness_path: Path,
    run_number: int,
    timeout: int = 120,
) -> RunResult:
    """Run the harness once and capture results.

    Args:
        harness_path: Path to harness directory or Docker image
        run_number: Run number for identification
        timeout: Timeout in seconds

    Returns:
        RunResult with run details
    """
    start_time = time.time()

    try:
        # Build docker command
        cmd = [
            "docker",
            "run",
            "--rm",
            "--network=none",
            str(harness_path),
        ]

        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
        )

        duration = time.time() - start_time

        # Parse output for exception info
        exception_type = None
        exception_message = None
        exception_location = None
        output_hash = hashlib.sha256(result.stdout.encode()).hexdigest()[:16]

        # Try to parse structured output
        try:
            output = json.loads(result.stdout)
            exception_type = output.get("exc_type")
            exception_message = output.get("message")
            if output.get("stack"):
                top_frame = output["stack"][0]
                exception_location = f"{top_frame.get('file', '?')}:{top_frame.get('line', '?')}"
        except (json.JSONDecodeError, KeyError, IndexError):
            # Try to parse from stderr
            if result.returncode != 0:
                for line in result.stderr.split("\n"):
                    if "Error" in line or "Exception" in line:
                        exception_type = line.split(":")[0].strip().split()[-1]
                        break

        return RunResult(
            run_number=run_number,
            exit_code=result.returncode,
            duration_seconds=duration,
            exception_type=exception_type,
            exception_message=exception_message,
            exception_location=exception_location,
            output_hash=output_hash,
            error_output=result.stderr[:500] if result.stderr else None,
        )

    except subprocess.TimeoutExpired:
        return RunResult(
            run_number=run_number,
            exit_code=-1,
            duration_seconds=timeout,
            exception_type="TimeoutError",
            exception_message=f"Run timed out after {timeout}s",
        )
    except Exception as e:
        return RunResult(
            run_number=run_number,
            exit_code=-1,
            duration_seconds=time.time() - start_time,
            exception_type=type(e).__name__,
            exception_message=str(e),
        )


def analyze_flakiness(
    harness_path: Path,
    num_runs: int = 100,
    parallel: int = 4,
    timeout: int = 120,
    progress_callback: Optional[callable] = None,
) -> FlakinessResult:
    """Run validation multiple times and analyze flakiness.

    Args:
        harness_path: Path to harness directory
        num_runs: Number of validation runs
        parallel: Number of parallel runs
        timeout: Timeout per run in seconds
        progress_callback: Optional callback(completed, total) for progress

    Returns:
        FlakinessResult with analysis
    """
    start_time = time.time()
    runs: List[RunResult] = []

    with ThreadPoolExecutor(max_workers=parallel) as executor:
        futures = {
            executor.submit(run_harness_once, harness_path, i, timeout): i
            for i in range(num_runs)
        }

        for future in as_completed(futures):
            result = future.result()
            runs.append(result)

            if progress_callback:
                progress_callback(len(runs), num_runs)

    # Sort by run number
    runs.sort(key=lambda r: r.run_number)

    # Group by failure signature
    unique_failures: Dict[str, List[RunResult]] = {}
    for run in runs:
        sig = run.failure_signature
        if sig not in unique_failures:
            unique_failures[sig] = []
        unique_failures[sig].append(run)

    return FlakinessResult(
        total_runs=num_runs,
        pass_count=sum(1 for r in runs if r.passed),
        fail_count=sum(1 for r in runs if not r.passed),
        unique_failures=unique_failures,
        total_duration_seconds=time.time() - start_time,
        runs=runs,
    )


def format_flakiness_report(result: FlakinessResult) -> str:
    """Format flakiness analysis as a human-readable report.

    Args:
        result: FlakinessResult to format

    Returns:
        Formatted report string
    """
    lines = [
        "",
        "Flakiness Analysis Report",
        "=" * 60,
        "",
        f"Total runs:     {result.total_runs}",
        f"Passed:         {result.pass_count}",
        f"Failed:         {result.fail_count}",
        f"Unique failures: {len(result.unique_failures)}",
        f"Duration:       {result.total_duration_seconds:.1f}s",
        "",
        f"Flakiness score: {result.flakiness_score:.1f}%",
        f"Deterministic:   {'Yes' if result.is_deterministic else 'No'}",
        "",
    ]

    if result.is_deterministic:
        if result.pass_count == result.total_runs:
            lines.append("All runs passed - test is stable.")
        else:
            lines.append("All runs failed with same error - consistent failure.")
            if result.primary_failure and result.primary_failure != "PASS":
                lines.append(f"Failure: {result.primary_failure}")
    else:
        lines.append("WARNING: Non-deterministic behavior detected!")
        lines.append("")
        lines.append("Failure breakdown:")

        for sig, runs in sorted(
            result.unique_failures.items(), key=lambda x: -len(x[1])
        ):
            count = len(runs)
            pct = count / result.total_runs * 100
            lines.append(f"  {sig}: {count} ({pct:.1f}%)")

            # Show example run numbers
            examples = [str(r.run_number) for r in runs[:5]]
            if len(runs) > 5:
                examples.append("...")
            lines.append(f"    Runs: {', '.join(examples)}")

        lines.append("")
        lines.append("Possible causes:")
        lines.append("  - Race conditions in async code")
        lines.append("  - Time-dependent logic")
        lines.append("  - Uncontrolled randomness")
        lines.append("  - External service variability")

    # Timing analysis
    if result.runs:
        durations = [r.duration_seconds for r in result.runs]
        avg_duration = sum(durations) / len(durations)
        min_duration = min(durations)
        max_duration = max(durations)

        lines.append("")
        lines.append("Timing:")
        lines.append(f"  Average: {avg_duration:.2f}s")
        lines.append(f"  Min:     {min_duration:.2f}s")
        lines.append(f"  Max:     {max_duration:.2f}s")

    return "\n".join(lines)


def quick_flakiness_check(
    harness_path: Path,
    threshold_runs: int = 10,
    timeout: int = 60,
) -> Dict[str, Any]:
    """Quick check if a test is likely flaky.

    Runs a small number of times to detect obvious flakiness.

    Args:
        harness_path: Path to harness
        threshold_runs: Number of runs for quick check
        timeout: Timeout per run

    Returns:
        Dict with is_flaky, confidence, and details
    """
    result = analyze_flakiness(
        harness_path,
        num_runs=threshold_runs,
        parallel=2,
        timeout=timeout,
    )

    return {
        "is_flaky": not result.is_deterministic,
        "confidence": "high" if threshold_runs >= 10 else "low",
        "unique_failures": len(result.unique_failures),
        "flakiness_score": result.flakiness_score,
        "recommendation": (
            "Test appears deterministic"
            if result.is_deterministic
            else "Test is flaky - run full analysis with more iterations"
        ),
    }


# ============================================================================
# Quarantine Management
# ============================================================================

@dataclass
class TestResultRecord:
    """A single test execution result for quarantine tracking."""
    test_id: str
    passed: bool
    duration_ms: Optional[int] = None
    divergence_code: Optional[str] = None
    failure_message: Optional[str] = None
    created_at: Optional[datetime] = None


@dataclass
class TestHealth:
    """Aggregated health metrics for a test."""
    test_id: str
    total_runs: int
    pass_count: int
    fail_count: int
    transitions: int  # Number of pass->fail or fail->pass transitions
    last_run_at: Optional[datetime] = None
    last_transition_at: Optional[datetime] = None

    @property
    def flakiness_score(self) -> float:
        """Calculate flakiness score (0-1).

        Score interpretation:
        - 0.0: Always passes or always fails (stable)
        - 1.0: Alternates every run (maximally flaky)
        - 0.3+: Typically considered flaky
        """
        if self.total_runs < 2:
            return 0.0
        return self.transitions / (self.total_runs - 1)

    @property
    def pass_rate(self) -> float:
        """Calculate pass rate (0-1)."""
        if self.total_runs == 0:
            return 0.0
        return self.pass_count / self.total_runs

    @property
    def is_flaky(self) -> bool:
        """Determine if test is flaky based on thresholds."""
        return self.flakiness_score >= 0.3 and self.total_runs >= 5


@dataclass
class QuarantineEntry:
    """A quarantined test entry."""
    test_id: str
    project_id: str
    quarantined_at: datetime
    quarantined_by: str  # 'auto' or user_id
    reason: Optional[str] = None
    flakiness_score: Optional[float] = None
    expires_at: Optional[datetime] = None


class FlakinessCalculator:
    """Calculate flakiness metrics from test results."""

    def __init__(self, window: int = 10):
        """Initialize calculator.

        Args:
            window: Number of recent runs to consider for flakiness
        """
        self.window = window

    def calculate_from_results(self, results: List[TestResultRecord]) -> TestHealth:
        """Calculate health metrics from a list of results.

        Results should be sorted by created_at descending (most recent first).
        """
        if not results:
            return TestHealth(
                test_id="",
                total_runs=0,
                pass_count=0,
                fail_count=0,
                transitions=0,
            )

        # Take only the window
        window_results = results[:self.window]

        test_id = window_results[0].test_id
        total_runs = len(window_results)
        pass_count = sum(1 for r in window_results if r.passed)
        fail_count = total_runs - pass_count

        # Count transitions
        transitions = 0
        for i in range(1, len(window_results)):
            if window_results[i].passed != window_results[i - 1].passed:
                transitions += 1

        # Find last transition time
        last_transition_at = None
        for i in range(1, len(window_results)):
            if window_results[i].passed != window_results[i - 1].passed:
                last_transition_at = window_results[i - 1].created_at
                break

        return TestHealth(
            test_id=test_id,
            total_runs=total_runs,
            pass_count=pass_count,
            fail_count=fail_count,
            transitions=transitions,
            last_run_at=window_results[0].created_at if window_results else None,
            last_transition_at=last_transition_at,
        )


class QuarantineManager:
    """Manage test quarantine state."""

    def __init__(
        self,
        api_base: Optional[str] = None,
        token: Optional[str] = None,
        project_id: Optional[str] = None,
    ):
        """Initialize quarantine manager.

        Args:
            api_base: API base URL (defaults to HIKAFLOW_ENDPOINT env var)
            token: Auth token (defaults to stored credentials)
            project_id: Project ID to manage quarantine for
        """
        self.api_base = api_base or os.environ.get("HIKAFLOW_ENDPOINT", "").rstrip("/")
        self.token = token
        self.project_id = project_id
        self._quarantined_cache: Optional[Set[str]] = None
        self._cache_time: Optional[datetime] = None

    def _headers(self) -> Dict[str, str]:
        """Get request headers with auth."""
        if not self.token:
            # Try to load from stored credentials
            try:
                from autodebug.config import get_valid_token
                self.token = get_valid_token()
            except Exception:
                pass

        headers = {"Content-Type": "application/json"}
        if self.token:
            headers["Authorization"] = f"Bearer {self.token}"
        return headers

    def get_quarantined_tests(self, force_refresh: bool = False) -> Set[str]:
        """Get set of quarantined test IDs for the project.

        Results are cached for 60 seconds to avoid excessive API calls.
        """
        if not self.project_id:
            return set()

        # Check cache
        if not force_refresh and self._quarantined_cache is not None:
            if self._cache_time and datetime.now(timezone.utc) - self._cache_time < timedelta(seconds=60):
                return self._quarantined_cache

        try:
            url = f"{self.api_base}/v1/projects/{self.project_id}/quarantine"
            resp = requests.get(url, headers=self._headers(), timeout=10)
            resp.raise_for_status()
            data = resp.json()

            self._quarantined_cache = set(item["test_id"] for item in data.get("tests", []))
            self._cache_time = datetime.now(timezone.utc)
            return self._quarantined_cache
        except Exception:
            # On error, return cached or empty
            return self._quarantined_cache or set()

    def is_quarantined(self, test_id: str) -> bool:
        """Check if a specific test is quarantined."""
        return test_id in self.get_quarantined_tests()

    def quarantine_test(
        self,
        test_id: str,
        reason: Optional[str] = None,
        flakiness_score: Optional[float] = None,
        expires_days: int = 14,
    ) -> bool:
        """Quarantine a test.

        Returns True if successful.
        """
        self._last_error: Optional[str] = None
        if not self.project_id:
            self._last_error = "No project ID configured"
            return False

        try:
            url = f"{self.api_base}/v1/projects/{self.project_id}/quarantine"
            expires_at = (datetime.now(timezone.utc) + timedelta(days=expires_days)).isoformat()

            resp = requests.post(
                url,
                headers=self._headers(),
                json={
                    "test_id": test_id,
                    "reason": reason,
                    "flakiness_score": flakiness_score,
                    "expires_at": expires_at,
                },
                timeout=10,
            )
            resp.raise_for_status()

            # Invalidate cache
            self._quarantined_cache = None
            return True
        except Exception as e:
            self._last_error = str(e)
            return False

    def unquarantine_test(self, test_id: str) -> bool:
        """Remove a test from quarantine.

        Returns True if successful.
        """
        self._last_error = None
        if not self.project_id:
            self._last_error = "No project ID configured"
            return False

        try:
            url = f"{self.api_base}/v1/projects/{self.project_id}/quarantine/{test_id}"
            resp = requests.delete(url, headers=self._headers(), timeout=10)
            resp.raise_for_status()

            # Invalidate cache
            self._quarantined_cache = None
            return True
        except Exception as e:
            self._last_error = str(e)
            return False

    def record_result(self, result: TestResultRecord) -> bool:
        """Record a test result for flakiness tracking.

        Returns True if successful.
        """
        if not self.project_id:
            return False

        try:
            url = f"{self.api_base}/v1/projects/{self.project_id}/test-results"
            resp = requests.post(
                url,
                headers=self._headers(),
                json={
                    "test_id": result.test_id,
                    "passed": result.passed,
                    "duration_ms": result.duration_ms,
                    "divergence_code": result.divergence_code,
                    "failure_message": result.failure_message,
                },
                timeout=10,
            )
            resp.raise_for_status()
            return True
        except Exception:
            return False

    def get_test_health(self, test_id: str) -> Optional[TestHealth]:
        """Get health metrics for a specific test."""
        if not self.project_id:
            return None

        try:
            url = f"{self.api_base}/v1/projects/{self.project_id}/tests/{test_id}/health"
            resp = requests.get(url, headers=self._headers(), timeout=10)
            resp.raise_for_status()
            data = resp.json()

            return TestHealth(
                test_id=test_id,
                total_runs=data.get("total_runs", 0),
                pass_count=data.get("pass_count", 0),
                fail_count=data.get("fail_count", 0),
                transitions=data.get("transitions", 0),
                last_run_at=datetime.fromisoformat(data["last_run_at"]) if data.get("last_run_at") else None,
                last_transition_at=datetime.fromisoformat(data["last_transition_at"]) if data.get("last_transition_at") else None,
            )
        except Exception:
            return None


def determine_exit_code(
    results: List[TestResultRecord],
    quarantined: Set[str],
) -> int:
    """Determine CLI exit code based on test results and quarantine status.

    Exit codes:
    - 0: All tests passed (including quarantined that failed)
    - 1: Real test failure (non-quarantined test failed)
    - 2: Only quarantined tests failed (CI should pass but signal activity)

    Args:
        results: List of test results
        quarantined: Set of quarantined test IDs

    Returns:
        Exit code (0, 1, or 2)
    """
    if not results:
        return 0

    real_failures = [
        r for r in results
        if not r.passed and r.test_id not in quarantined
    ]

    if real_failures:
        return 1

    quarantine_failures = [
        r for r in results
        if not r.passed and r.test_id in quarantined
    ]

    if quarantine_failures:
        return 2  # CI passes, but signal quarantine activity

    return 0


def format_quarantine_summary(
    results: List[TestResultRecord],
    quarantined: Set[str],
) -> str:
    """Format a summary of test results with quarantine info.

    Returns a human-readable summary string.
    """
    total = len(results)
    passed = sum(1 for r in results if r.passed)
    failed = total - passed

    quarantine_failures = [
        r for r in results
        if not r.passed and r.test_id in quarantined
    ]
    real_failures = [
        r for r in results
        if not r.passed and r.test_id not in quarantined
    ]

    lines = [
        f"Test Results: {passed}/{total} passed",
    ]

    if quarantined:
        lines.append(f"  Quarantined tests: {len(quarantined)}")

    if quarantine_failures:
        lines.append(f"  Quarantined failures (ignored): {len(quarantine_failures)}")
        for r in quarantine_failures[:5]:  # Show first 5
            lines.append(f"    - {r.test_id}")
        if len(quarantine_failures) > 5:
            lines.append(f"    ... and {len(quarantine_failures) - 5} more")

    if real_failures:
        lines.append(f"  Real failures: {len(real_failures)}")
        for r in real_failures[:5]:
            msg = r.failure_message or "unknown error"
            if len(msg) > 60:
                msg = msg[:60] + "..."
            lines.append(f"    - {r.test_id}: {msg}")
        if len(real_failures) > 5:
            lines.append(f"    ... and {len(real_failures) - 5} more")

    return "\n".join(lines)


def group_test_failures(
    failures: List[Dict[str, Any]],
    key: str = "failure_signature",
) -> Dict[str, List[Dict[str, Any]]]:
    """Group test failures by signature to deduplicate healing attempts.

    Given a list of test failure dicts (each with a failure_signature),
    groups them so that identical failures share a single healing attempt
    instead of each triggering an independent one.

    Args:
        failures: List of failure dicts, each with at least ``key`` field.
        key: Dict key to group by (default: failure_signature).

    Returns:
        Dict mapping signature -> list of failures with that signature.
    """
    groups: Dict[str, List[Dict[str, Any]]] = {}
    for f in failures:
        sig = f.get(key) or "unknown"
        if sig not in groups:
            groups[sig] = []
        groups[sig].append(f)
    return groups
