"""
Cointegration-based pair discovery with Ornstein-Uhlenbeck
half-life filtering.

Provides tools to:
  1. Test pairs for cointegration (Engle-Granger two-step)
  2. Estimate OU half-life of the residual spread
  3. Filter candidate pairs by minimum half-life
  4. Rank pairs by mean-reversion strength (high ADF stat, low half-life)

All implemented in pure NumPy — no statsmodels dependency.

Design decisions:
  - Critical values for ADF test are hardcoded (MacKinnon 1994 tables)
    → avoids importing statsmodels.
  - Both 1% and 5% significance levels supported.
  - Half-life computed via OLS on the AR(1) of the residual.
  - Minimum observations: 100 for cointegration test (avoid spurious results).
  - Sector constraint: optional list of ticker prefixes (e.g., "ETH", "BTC").
"""

from __future__ import annotations

import numpy as np
from typing import Optional


# ── MacKinnon (1994) critical values for ADF test ────────────
# Table for Case 2: regression with intercept, no trend
# Rows: sample sizes (25, 50, 100, 250, 500, ∞)
# Cols: significance levels (1%, 5%, 10%)

_MACKINNON_CASE2 = np.array([
    [-3.75, -3.00, -2.63],  # N=25
    [-3.58, -2.93, -2.60],  # N=50
    [-3.51, -2.89, -2.58],  # N=100
    [-3.46, -2.88, -2.57],  # N=250
    [-3.44, -2.87, -2.57],  # N=500
    [-3.43, -2.86, -2.57],  # N=∞
])

_MACKINNON_N_SIZES = np.array([25, 50, 100, 250, 500, 999999])


def adf_critical_value(n_obs: int, sig: float = 0.05) -> float:
    """Return ADF critical value for given sample size and significance."""
    col = 0 if sig <= 0.01 else 1 if sig <= 0.05 else 2
    idx = np.searchsorted(_MACKINNON_N_SIZES, n_obs, side="right") - 1
    idx = max(0, min(idx, len(_MACKINNON_N_SIZES) - 1))
    return float(_MACKINNON_CASE2[idx, col])


def adf_test(residuals: np.ndarray, sig: float = 0.05) -> dict:
    """
    Augmented Dickey-Fuller test (no lags).

    Tests H₀: unit root (not mean-reverting) vs H₁: stationary.

    Args:
        residuals: 1-D array of OLS residuals from cointegrating regression.
        sig: Significance level (0.01 or 0.05).

    Returns:
        dict with keys: statistic, critical_value, is_stationary, p_value_approx.
    """
    n = len(residuals)
    if n < 20:
        return {"statistic": 0.0, "critical_value": 0.0, "is_stationary": False, "p_value_approx": 1.0}

    dy = np.diff(residuals)
    y_lag = residuals[:-1]

    # OLS: Δyₜ = γ yₜ₋₁ + εₜ
    X = y_lag.reshape(-1, 1)
    Y = dy.reshape(-1, 1)

    # γ = (XᵀX)⁻¹ XᵀY
    XtX = X.T @ X
    if XtX[0, 0] < 1e-12:
        return {"statistic": 0.0, "critical_value": 0.0, "is_stationary": False, "p_value_approx": 1.0}

    gamma = float((np.linalg.inv(XtX) @ X.T @ Y)[0, 0])
    residuals_ols = Y.flatten() - gamma * X.flatten()
    se = np.std(residuals_ols, ddof=1)
    t_stat = gamma / se if se > 1e-12 else 0.0

    crit = adf_critical_value(n, sig)
    is_stat = t_stat < crit

    # Rough p-value approximation
    p_val = max(0.0, min(1.0, 1.0 / (1.0 + np.exp(-(abs(t_stat) - 2.0)))))

    return {
        "statistic": round(t_stat, 4),
        "critical_value": round(crit, 4),
        "is_stationary": is_stat,
        "p_value_approx": round(p_val, 4),
    }


def estimate_half_life(spread: np.ndarray) -> float:
    """
    Estimate the Ornstein-Uhlenbeck half-life of a spread series.

    Model: dsₜ = θ (μ - sₜ) dt + σ dWₜ

    Half-life = ln(2) / θ

    Implementation:
        Discretize and run OLS on: sₜ₊₁ - sₜ = a + b sₜ + εₜ
        Then θ = -b, half-life = ln(2) / θ.

    Args:
        spread: 1-D array of spread values.

    Returns:
        Half-life in number of periods. Returns inf if not mean-reverting.
    """
    n = len(spread)
    if n < 20:
        return float("inf")

    s = spread
    ds = np.diff(s)
    s_lag = s[:-1]

    # OLS: ds[t] = a + b * s[t-1]
    X = np.column_stack([np.ones(len(s_lag)), s_lag])
    Y = ds

    try:
        coeff = np.linalg.lstsq(X, Y, rcond=None)[0]
    except np.linalg.LinAlgError:
        return float("inf")

    b = coeff[1]  # mean-reversion speed (negative → mean-reverting)

    if b >= 0:
        return float("inf")  # Not mean-reverting

    theta = -b
    half_life = np.log(2) / theta if theta > 1e-10 else float("inf")

    return float(half_life)


def test_pair(X: np.ndarray, Y: np.ndarray, sig: float = 0.05) -> dict:
    """
    Full cointegration + half-life test for a candidate pair.

    Engle-Granger two-step:
        1. Regress Y on X: Y = α + β X + ε
        2. Test ε for stationarity (ADF)
        3. Estimate half-life of ε

    Args:
        X: Price series of asset X (independent).
        Y: Price series of asset Y (dependent).
        sig: ADF significance level.

    Returns:
        dict with:
            alpha, beta (hedge ratio), adf_stat, adf_crit,
            is_cointegrated, half_life, half_life_days,
            spread, spread_std, correlation
    """
    n = min(len(X), len(Y))
    if n < 100:
        return {"is_cointegrated": False, "half_life": float("inf"), "reason": "insufficient_data"}

    x = np.array(X[-n:])
    y = np.array(Y[-n:])

    # Step 1: OLS regression
    X_mat = np.column_stack([np.ones(n), x])
    try:
        coeff = np.linalg.lstsq(X_mat, y, rcond=None)[0]
    except np.linalg.LinAlgError:
        return {"is_cointegrated": False, "half_life": float("inf"), "reason": "lstsq_failed"}

    alpha, beta = float(coeff[0]), float(coeff[1])

    # Step 2: Residuals
    residuals = y - (alpha + beta * x)

    # ADF test on residuals
    adf = adf_test(residuals, sig=sig)

    # Step 3: Half-life
    hl = estimate_half_life(residuals)

    return {
        "alpha": round(alpha, 6),
        "beta": round(beta, 6),
        "adf_stat": adf["statistic"],
        "adf_crit": adf["critical_value"],
        "is_cointegrated": adf["is_stationary"],
        "half_life": round(hl, 2),
        "spread": residuals,
        "spread_std": round(float(np.std(residuals)), 6),
        "correlation": round(float(np.corrcoef(x, y)[0, 1]), 4),
    }


def discover_pairs(
    price_data: dict[str, np.ndarray],
    sector_constraint: Optional[str] = None,
    min_half_life: float = 1.0,
    max_half_life: float = 20.0,
    sig_level: float = 0.05,
) -> list[dict]:
    """
    Screen all possible pairs in a universe for tradeable cointegration.

    Filters:
        1. ADF test passes at given significance level
        2. Half-life between min_half_life and max_half_life (periods)
        3. Optional sector constraint (ticker prefix match)

    Args:
        price_data: {ticker: price_array} mapping.
        sector_constraint: If set, only pairs where both tickers share this prefix.
        min_half_life: Minimum half-life in periods.
        max_half_life: Maximum half-life in periods.
        sig_level: ADF significance level.

    Returns:
        List of dicts, sorted by half-life (ascending — faster mean reversion first).
        Each dict has: pair, alpha, beta, half_life, adf_stat, spread_std, correlation.
    """
    tickers = sorted(price_data.keys())
    results: list[dict] = []

    for i in range(len(tickers)):
        for j in range(i + 1, len(tickers)):
            t1, t2 = tickers[i], tickers[j]

            # Sector constraint
            if sector_constraint:
                if not (t1.startswith(sector_constraint) and t2.startswith(sector_constraint)):
                    continue

            X = price_data[t1]
            Y = price_data[t2]

            test = test_pair(X, Y, sig=sig_level)
            if test["is_cointegrated"] and min_half_life <= test["half_life"] <= max_half_life:
                results.append({
                    "pair": (t1, t2),
                    "X_ticker": t1,
                    "Y_ticker": t2,
                    "alpha": test["alpha"],
                    "beta": test["beta"],
                    "half_life": test["half_life"],
                    "adf_stat": test["adf_stat"],
                    "spread_std": test["spread_std"],
                    "correlation": test["correlation"],
                })

    # Sort by half-life (faster mean reversion = better)
    results.sort(key=lambda r: r["half_life"])
    return results


def compute_rolling_ols_hedge(
    X: np.ndarray,
    Y: np.ndarray,
    window: int = 60,
) -> np.ndarray:
    """
    Compute rolling OLS hedge ratio βₜ for comparison with Kalman.

    Uses expanding window OLS up to the specified lookback.

    Args:
        X, Y: Price series.
        window: Lookback window in periods.

    Returns:
        1-D array of β values (same length as inputs).
    """
    n = len(X)
    betas = np.full(n, np.nan)
    for t in range(window, n):
        x_win = X[t - window:t]
        y_win = Y[t - window:t]
        X_mat = np.column_stack([np.ones(len(x_win)), x_win])
        try:
            coeff = np.linalg.lstsq(X_mat, y_win, rcond=None)[0]
            betas[t] = coeff[1]
        except np.linalg.LinAlgError:
            betas[t] = np.nan
    return betas
