"""Combinatorial Purged Cross-Validation (CPCV) with Probability of Backtest Overfitting (PBO).

Implements the Bailey et al. methodology for detecting overfitting in
trading strategy backtests using combinatorial data splits.
"""

from __future__ import annotations

import itertools
import math
from dataclasses import dataclass, field
from typing import Any, Callable


@dataclass
class CPCVResult:
    """Result of CPCV analysis.

    Attributes:
        oos_sharpes: Out-of-sample Sharpe ratios for each combination.
        pbo: Probability of Backtest Overfitting (fraction of combos
             where best in-sample underperforms out-of-sample).
        is_overfit: True if PBO > 0.5.
        n_combinations: Total number of combinatorial splits evaluated.
    """

    oos_sharpes: list[float] = field(default_factory=list)
    pbo: float = 0.0
    is_overfit: bool = False
    n_combinations: int = 0


def cpcv(
    data: list[float],
    pipeline_factory: Callable[[dict[str, Any]], Callable[[list[float]], float]],
    param_grid: list[dict[str, Any]],
    n_groups: int = 8,
    purge_gap: float = 0.0,
    max_combinations: int = 500,
) -> CPCVResult:
    """Run Combinatorial Purged Cross-Validation.

    Splits data into n_groups blocks, selects C(n_groups, n_groups/2)
    combinations of train/test splits, grid-searches params on the train
    half, and evaluates the best on the test half.

    Args:
        data: Time series of returns or prices.
        pipeline_factory: Function that takes a param dict and returns a
            strategy function. The strategy function takes a list[float]
            (returns slice) and returns a Sharpe ratio (float).
        param_grid: List of parameter dicts to search over.
        n_groups: Number of data groups (must be even, >= 4).
        purge_gap: Fraction of each group to remove at boundaries (0-0.5).
        max_combinations: Maximum number of combinatorial splits to evaluate
            (to limit computation for large n_groups).

    Returns:
        CPCVResult with OOS Sharpes, PBO, and overfitting flag.
    """
    n_groups = max(4, n_groups)
    if n_groups % 2 != 0:
        n_groups += 1
    half = n_groups // 2

    n = len(data)
    if n < n_groups * 2 or not param_grid:
        return CPCVResult()

    group_size = n // n_groups
    if group_size < 2:
        return CPCVResult()

    purge_gap = max(0.0, min(0.5, purge_gap))
    purge_samples = int(group_size * purge_gap)

    # Split data into groups
    groups: list[list[float]] = []
    for g in range(n_groups):
        start = g * group_size
        end = start + group_size if g < n_groups - 1 else n
        groups.append(data[start:end])

    # Generate all C(n_groups, half) combinations
    all_indices = list(range(n_groups))
    combos = list(itertools.combinations(all_indices, half))

    # Cap combinations
    if len(combos) > max_combinations:
        step = len(combos) // max_combinations
        combos = combos[::step][:max_combinations]

    oos_sharpes = []
    overfit_count = 0

    for train_indices in combos:
        test_indices = [i for i in all_indices if i not in train_indices]

        # Assemble train/test data with purging
        train_data = _assemble_groups(groups, list(train_indices), purge_samples)
        test_data = _assemble_groups(groups, test_indices, purge_samples)

        if len(train_data) < 2 or len(test_data) < 2:
            continue

        # Grid search on train
        best_is_sharpe = float("-inf")
        best_params = param_grid[0]

        for params in param_grid:
            try:
                strategy_fn = pipeline_factory(params)
                is_sharpe = strategy_fn(train_data)
                if math.isfinite(is_sharpe) and is_sharpe > best_is_sharpe:
                    best_is_sharpe = is_sharpe
                    best_params = params
            except Exception:
                continue

        # Evaluate best params on test
        try:
            strategy_fn = pipeline_factory(best_params)
            oos_sharpe = strategy_fn(test_data)
            if not math.isfinite(oos_sharpe):
                oos_sharpe = 0.0
        except Exception:
            oos_sharpe = 0.0

        oos_sharpes.append(oos_sharpe)

        # PBO: does best IS underperform OOS?
        if best_is_sharpe > 0 and oos_sharpe < best_is_sharpe:
            overfit_count += 1

    n_combos = len(oos_sharpes)
    if n_combos == 0:
        return CPCVResult()

    pbo_val = overfit_count / n_combos

    return CPCVResult(
        oos_sharpes=oos_sharpes,
        pbo=pbo_val,
        is_overfit=pbo_val > 0.5,
        n_combinations=n_combos,
    )


def probability_of_overfitting(cpcv_result: CPCVResult) -> float:
    """Extract PBO from a CPCV result.

    Args:
        cpcv_result: Result from cpcv().

    Returns:
        Probability of backtest overfitting (0-1).
    """
    return cpcv_result.pbo


# ===========================================================================
# Helpers
# ===========================================================================


def _assemble_groups(
    groups: list[list[float]],
    indices: list[int],
    purge_samples: int,
) -> list[float]:
    """Concatenate selected groups with optional boundary purging."""
    result: list[float] = []
    for idx in sorted(indices):
        g = groups[idx]
        if purge_samples > 0 and len(g) > 2 * purge_samples:
            g = g[purge_samples : len(g) - purge_samples]
        result.extend(g)
    return result
