"""Statistical robustness testing for prediction market backtests.

Three techniques for assessing whether backtest performance is due to
genuine edge or luck:

1. Event Permutation Test - shuffle market-to-outcome assignments
2. Outcome Randomization - Bernoulli resampling of binary outcomes
3. PnL Path Simulation - shuffle round-trip ordering for path statistics
"""

from __future__ import annotations

import math
import random
from dataclasses import dataclass, field
from typing import Any


# ---------------------------------------------------------------------------
# Result dataclasses
# ---------------------------------------------------------------------------

@dataclass
class PermutationTestResult:
    """Result of event permutation test.

    Shuffles which outcomes map to which markets to test whether the
    strategy genuinely selected the right markets or got lucky.
    """

    observed_pnl: float = 0.0
    mean_permuted_pnl: float = 0.0
    std_permuted_pnl: float = 0.0
    p_value: float = 1.0
    n_permutations: int = 0
    permuted_pnls: list[float] = field(default_factory=list)

    @property
    def is_significant(self) -> bool:
        """True if p_value < 0.05."""
        return self.p_value < 0.05

    def summary(self) -> str:
        """Human-readable summary."""
        sig = "SIGNIFICANT" if self.is_significant else "NOT significant"
        return (
            f"Permutation Test ({self.n_permutations} permutations)\n"
            f"  Observed PnL:   {self.observed_pnl:+.4f}\n"
            f"  Mean permuted:  {self.mean_permuted_pnl:+.4f}\n"
            f"  Std permuted:   {self.std_permuted_pnl:.4f}\n"
            f"  p-value:        {self.p_value:.4f} ({sig})"
        )


@dataclass
class OutcomeRandomizationResult:
    """Result of outcome randomization test.

    Keeps trades fixed, resamples binary outcomes from Bernoulli(p)
    where p is the market's implied probability. Tests whether realized
    outcomes were luckier than expected.
    """

    observed_pnl: float = 0.0
    mean_random_pnl: float = 0.0
    std_random_pnl: float = 0.0
    p_value: float = 1.0
    n_simulations: int = 0
    simulated_pnls: list[float] = field(default_factory=list)

    @property
    def is_significant(self) -> bool:
        """True if p_value < 0.05."""
        return self.p_value < 0.05

    def summary(self) -> str:
        """Human-readable summary."""
        sig = "SIGNIFICANT" if self.is_significant else "NOT significant"
        return (
            f"Outcome Randomization ({self.n_simulations} simulations)\n"
            f"  Observed PnL:   {self.observed_pnl:+.4f}\n"
            f"  Mean random:    {self.mean_random_pnl:+.4f}\n"
            f"  Std random:     {self.std_random_pnl:.4f}\n"
            f"  p-value:        {self.p_value:.4f} ({sig})"
        )


@dataclass
class PathSimulationResult:
    """Result of PnL path simulation.

    Shuffles round-trip PnL ordering to test whether path-dependent
    statistics (max drawdown, consecutive losses) were unusual.
    """

    observed_max_drawdown: float = 0.0
    observed_terminal_equity: float = 0.0
    mean_max_drawdown: float = 0.0
    std_max_drawdown: float = 0.0
    p_value_drawdown: float = 1.0
    max_consecutive_losses_observed: int = 0
    mean_consecutive_losses: float = 0.0
    n_simulations: int = 0
    simulated_max_drawdowns: list[float] = field(default_factory=list)
    simulated_max_consecutive_losses: list[int] = field(default_factory=list)

    @property
    def is_favorable(self) -> bool:
        """True if observed drawdown is in the better half of simulated paths."""
        return self.p_value_drawdown > 0.5

    def summary(self) -> str:
        """Human-readable summary."""
        fav = "FAVORABLE" if self.is_favorable else "UNFAVORABLE"
        return (
            f"Path Simulation ({self.n_simulations} simulations)\n"
            f"  Observed max DD:     {self.observed_max_drawdown:.4f}\n"
            f"  Mean simulated DD:   {self.mean_max_drawdown:.4f}\n"
            f"  Std simulated DD:    {self.std_max_drawdown:.4f}\n"
            f"  DD percentile:       {self.p_value_drawdown:.4f} ({fav})\n"
            f"  Observed max consec losses: {self.max_consecutive_losses_observed}\n"
            f"  Mean max consec losses:     {self.mean_consecutive_losses:.1f}"
        )


@dataclass
class RobustnessReport:
    """Aggregate robustness report from all three tests."""

    permutation: PermutationTestResult | None = None
    outcome: OutcomeRandomizationResult | None = None
    path: PathSimulationResult | None = None

    @property
    def is_robust(self) -> bool:
        """True if all executed tests pass their significance thresholds."""
        tests_run = 0
        tests_passed = 0
        if self.permutation is not None:
            tests_run += 1
            if self.permutation.is_significant:
                tests_passed += 1
        if self.outcome is not None:
            tests_run += 1
            if self.outcome.is_significant:
                tests_passed += 1
        if self.path is not None:
            tests_run += 1
            if self.path.is_favorable:
                tests_passed += 1
        return tests_run > 0 and tests_passed == tests_run

    def summary(self) -> str:
        """Human-readable robustness summary."""
        lines = [
            "=" * 50,
            "  ROBUSTNESS REPORT",
            "=" * 50,
            "",
        ]
        if self.permutation is not None:
            lines.append(self.permutation.summary())
            lines.append("")
        if self.outcome is not None:
            lines.append(self.outcome.summary())
            lines.append("")
        if self.path is not None:
            lines.append(self.path.summary())
            lines.append("")

        robust_str = "ROBUST" if self.is_robust else "NOT ROBUST"
        lines.append(f"  Overall: {robust_str}")
        lines.append("=" * 50)
        return "\n".join(lines)


# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------

def _market_entry_prices(trades: list) -> dict[str, float]:
    """Compute average buy price per market (implied probability)."""
    totals: dict[str, list[float]] = {}
    for fill in trades:
        market_id = getattr(fill, "market_id", "")
        side_str = str(getattr(fill, "order_side", "")).lower()
        if "buy" not in side_str:
            continue
        price = getattr(fill, "price", 0.0)
        totals.setdefault(market_id, []).append(price)
    return {
        mid: sum(ps) / len(ps) if ps else 0.5
        for mid, ps in totals.items()
    }


def _compute_pnl_with_outcomes(
    analysis: Any,
    outcomes: dict[str, float],
) -> float:
    """Compute total PnL: round-trip PnL + resolution PnL for given outcomes."""
    # Round-trip PnL is fixed (independent of outcomes)
    rt_pnl = math.fsum(rt.pnl for rt in analysis.round_trips)

    # Resolution PnL depends on outcomes
    res_pnl = 0.0
    for market_id, positions in analysis.open_longs.items():
        if market_id in outcomes:
            outcome = outcomes[market_id]
            for price, size, fee, _ts in positions:
                res_pnl += size * (outcome - price) - fee
    for market_id, positions in analysis.open_shorts.items():
        if market_id in outcomes:
            outcome = outcomes[market_id]
            for price, size, fee, _ts in positions:
                res_pnl += size * (price - outcome) - fee

    return rt_pnl + res_pnl


def _max_drawdown_from_pnls(pnls: list[float], initial_capital: float) -> float:
    """Compute max drawdown from an ordered sequence of PnLs."""
    if not pnls:
        return 0.0
    peak = initial_capital
    max_dd = 0.0
    equity = initial_capital
    for pnl in pnls:
        equity += pnl
        if equity > peak:
            peak = equity
        dd = peak - equity
        if dd > max_dd:
            max_dd = dd
    return max_dd


def _max_consecutive_losses(pnls: list[float]) -> int:
    """Count the longest streak of negative PnLs."""
    max_streak = 0
    current = 0
    for pnl in pnls:
        if pnl < 0:
            current += 1
            if current > max_streak:
                max_streak = current
        else:
            current = 0
    return max_streak


def _safe_std(values: list[float]) -> float:
    """Standard deviation with len-1 denominator, safe for empty/single."""
    n = len(values)
    if n < 2:
        return 0.0
    mean = math.fsum(values) / n
    variance = math.fsum((v - mean) ** 2 for v in values) / (n - 1)
    return math.sqrt(variance)


# ---------------------------------------------------------------------------
# Core functions
# ---------------------------------------------------------------------------


def permutation_test(
    result: Any,
    n_permutations: int = 1000,
    seed: int | None = None,
) -> PermutationTestResult:
    """Event permutation test: shuffle market-to-outcome assignments.

    Since prediction market events are independent, shuffling which
    outcomes map to which markets tests whether the strategy's market
    *selection* was genuinely skillful.

    Args:
        result: BacktestResult with outcomes set.
        n_permutations: Number of permutations to run.
        seed: Random seed for reproducibility.

    Returns:
        PermutationTestResult with p-value and distribution.

    Raises:
        ValueError: If result.outcomes is not set or has < 2 markets.
    """
    from horizon._horizon import auth_require_pro
    auth_require_pro()

    outcomes = result.outcomes
    if not outcomes or len(outcomes) < 2:
        raise ValueError(
            "permutation_test requires outcomes with at least 2 markets"
        )

    from horizon.analytics import _analyze_trades

    analysis = _analyze_trades(result.trades)
    observed_pnl = _compute_pnl_with_outcomes(analysis, outcomes)

    market_ids = list(outcomes.keys())
    outcome_values = [outcomes[m] for m in market_ids]

    rng = random.Random(seed)
    permuted_pnls: list[float] = []

    for _ in range(n_permutations):
        # Shuffle outcome values across market keys
        shuffled_vals = list(outcome_values)
        rng.shuffle(shuffled_vals)
        shuffled_outcomes = dict(zip(market_ids, shuffled_vals))
        pnl = _compute_pnl_with_outcomes(analysis, shuffled_outcomes)
        permuted_pnls.append(pnl)

    # Conservative p-value: (count >= observed + 1) / (n + 1)
    count_ge = sum(1 for p in permuted_pnls if p >= observed_pnl)
    p_value = (count_ge + 1) / (n_permutations + 1)

    mean_pnl = math.fsum(permuted_pnls) / n_permutations if permuted_pnls else 0.0

    return PermutationTestResult(
        observed_pnl=observed_pnl,
        mean_permuted_pnl=mean_pnl,
        std_permuted_pnl=_safe_std(permuted_pnls),
        p_value=p_value,
        n_permutations=n_permutations,
        permuted_pnls=permuted_pnls,
    )


def outcome_randomization(
    result: Any,
    n_simulations: int = 10000,
    seed: int | None = None,
    probabilities: dict[str, float] | None = None,
) -> OutcomeRandomizationResult:
    """Outcome randomization: Bernoulli-resample binary outcomes.

    Keeps all trades exactly as-is but re-draws each market's outcome
    from Bernoulli(p) where p is the market's implied probability
    (average buy price). Tests whether realized outcomes were luckier
    than expected given the market's pricing.

    Args:
        result: BacktestResult with outcomes set.
        n_simulations: Number of randomization runs.
        seed: Random seed for reproducibility.
        probabilities: Optional explicit probabilities per market.
            If not provided, uses average buy price as implied probability.

    Returns:
        OutcomeRandomizationResult with p-value and distribution.

    Raises:
        ValueError: If result.outcomes is not set.
    """
    from horizon._horizon import auth_require_pro
    auth_require_pro()

    outcomes = result.outcomes
    if not outcomes:
        raise ValueError("outcome_randomization requires outcomes to be set")

    from horizon.analytics import _analyze_trades

    analysis = _analyze_trades(result.trades)
    observed_pnl = _compute_pnl_with_outcomes(analysis, outcomes)

    # Determine probabilities
    if probabilities is None:
        probabilities = _market_entry_prices(result.trades)

    # Ensure all outcome markets have a probability
    market_ids = list(outcomes.keys())
    probs = {m: probabilities.get(m, 0.5) for m in market_ids}

    # Clamp probabilities to (0.01, 0.99) for numerical stability
    probs = {m: max(0.01, min(0.99, p)) for m, p in probs.items()}

    rng = random.Random(seed)
    simulated_pnls: list[float] = []

    # Pre-compute round-trip PnL (invariant across simulations)
    rt_pnl = math.fsum(rt.pnl for rt in analysis.round_trips)

    # Identify markets that have open positions (only these affect variance)
    open_markets = set(analysis.open_longs.keys()) | set(analysis.open_shorts.keys())
    # If no open positions, resolution PnL is always 0 -- observed == every sim
    has_open = bool(open_markets & set(market_ids))

    for _ in range(n_simulations):
        if not has_open:
            simulated_pnls.append(rt_pnl)
            continue

        # Draw random outcomes
        random_outcomes: dict[str, float] = {}
        for m in market_ids:
            random_outcomes[m] = 1.0 if rng.random() < probs[m] else 0.0

        # Only compute resolution PnL (rt_pnl is fixed)
        res_pnl = 0.0
        for market_id, positions in analysis.open_longs.items():
            if market_id in random_outcomes:
                outcome = random_outcomes[market_id]
                for price, size, fee, _ts in positions:
                    res_pnl += size * (outcome - price) - fee
        for market_id, positions in analysis.open_shorts.items():
            if market_id in random_outcomes:
                outcome = random_outcomes[market_id]
                for price, size, fee, _ts in positions:
                    res_pnl += size * (price - outcome) - fee

        simulated_pnls.append(rt_pnl + res_pnl)

    count_ge = sum(1 for p in simulated_pnls if p >= observed_pnl)
    p_value = (count_ge + 1) / (n_simulations + 1)

    mean_pnl = math.fsum(simulated_pnls) / n_simulations if simulated_pnls else 0.0

    return OutcomeRandomizationResult(
        observed_pnl=observed_pnl,
        mean_random_pnl=mean_pnl,
        std_random_pnl=_safe_std(simulated_pnls),
        p_value=p_value,
        n_simulations=n_simulations,
        simulated_pnls=simulated_pnls,
    )


def path_simulation(
    result: Any,
    n_simulations: int = 10000,
    seed: int | None = None,
) -> PathSimulationResult:
    """PnL path simulation: shuffle round-trip order for path statistics.

    The total PnL is invariant to ordering, but path-dependent statistics
    (max drawdown, consecutive losses) depend on the sequence. This test
    answers: "Was my drawdown/streak unusually good or bad given the
    actual trade outcomes?"

    Args:
        result: BacktestResult with trades.
        n_simulations: Number of shuffled paths.
        seed: Random seed for reproducibility.

    Returns:
        PathSimulationResult with drawdown distribution and streak stats.
    """
    from horizon._horizon import auth_require_pro
    auth_require_pro()

    from horizon.analytics import _analyze_trades

    analysis = _analyze_trades(result.trades)
    pnls = [rt.pnl for rt in analysis.round_trips]

    initial_capital = result.initial_capital

    if len(pnls) < 2:
        observed_dd = _max_drawdown_from_pnls(pnls, initial_capital)
        observed_streak = _max_consecutive_losses(pnls)
        return PathSimulationResult(
            observed_max_drawdown=observed_dd,
            observed_terminal_equity=initial_capital + math.fsum(pnls),
            mean_max_drawdown=observed_dd,
            std_max_drawdown=0.0,
            p_value_drawdown=0.5,
            max_consecutive_losses_observed=observed_streak,
            mean_consecutive_losses=float(observed_streak),
            n_simulations=0,
            simulated_max_drawdowns=[],
            simulated_max_consecutive_losses=[],
        )

    observed_dd = _max_drawdown_from_pnls(pnls, initial_capital)
    observed_streak = _max_consecutive_losses(pnls)

    rng = random.Random(seed)
    sim_dds: list[float] = []
    sim_streaks: list[int] = []

    for _ in range(n_simulations):
        shuffled = list(pnls)
        rng.shuffle(shuffled)
        sim_dds.append(_max_drawdown_from_pnls(shuffled, initial_capital))
        sim_streaks.append(_max_consecutive_losses(shuffled))

    # p_value_drawdown: fraction of simulated paths with DD >= observed
    # Higher = your observed DD was typical or worse; lower = you were lucky
    count_ge = sum(1 for dd in sim_dds if dd >= observed_dd)
    p_value_dd = (count_ge + 1) / (n_simulations + 1)

    mean_dd = math.fsum(sim_dds) / n_simulations if sim_dds else 0.0
    mean_streak = sum(sim_streaks) / n_simulations if sim_streaks else 0.0

    return PathSimulationResult(
        observed_max_drawdown=observed_dd,
        observed_terminal_equity=initial_capital + math.fsum(pnls),
        mean_max_drawdown=mean_dd,
        std_max_drawdown=_safe_std([float(d) for d in sim_dds]),
        p_value_drawdown=p_value_dd,
        max_consecutive_losses_observed=observed_streak,
        mean_consecutive_losses=mean_streak,
        n_simulations=n_simulations,
        simulated_max_drawdowns=sim_dds,
        simulated_max_consecutive_losses=sim_streaks,
    )


def robustness_test(
    result: Any,
    tests: list[str] | None = None,
    n_simulations: int = 1000,
    seed: int | None = None,
) -> RobustnessReport:
    """Run statistical robustness tests on a BacktestResult.

    Automatically skips tests that require data not present (e.g.,
    permutation and outcome tests require outcomes).

    Args:
        result: BacktestResult to test.
        tests: List of tests to run: "permutation", "outcome", "path".
            Defaults to all applicable tests.
        n_simulations: Number of simulations per test.
        seed: Random seed for reproducibility.

    Returns:
        RobustnessReport with results for each test run.
    """
    from horizon._horizon import auth_require_pro
    auth_require_pro()

    if tests is None:
        tests = ["permutation", "outcome", "path"]

    report = RobustnessReport()

    has_outcomes = result.outcomes and len(result.outcomes) >= 2
    has_trades = bool(result.trades)

    if "permutation" in tests and has_outcomes:
        report.permutation = permutation_test(
            result, n_permutations=n_simulations, seed=seed,
        )

    if "outcome" in tests and result.outcomes:
        report.outcome = outcome_randomization(
            result, n_simulations=n_simulations, seed=seed,
        )

    if "path" in tests and has_trades:
        report.path = path_simulation(
            result, n_simulations=n_simulations, seed=seed,
        )

    return report
