"""Market-side feature calculations from price bar data."""
from __future__ import annotations

from typing import Any

from libs.oracle_client.models import PriceBar


def reaction_day_return(bars: list[PriceBar], event_date: str) -> float | None:
    """(close - prev_close) / prev_close on event date."""
    dated = {b.date: b for b in bars}
    if event_date not in dated:
        return None
    event_bar = dated[event_date]
    # Find previous bar
    sorted_dates = sorted(dated.keys())
    idx = sorted_dates.index(event_date)
    if idx == 0:
        return None
    prev_bar = dated[sorted_dates[idx - 1]]
    if prev_bar.close == 0:
        return None
    return (event_bar.close - prev_bar.close) / prev_bar.close


def volume_ratio_20d(bars: list[PriceBar], event_date: str) -> float | None:
    """Event day volume / 20-day average volume before event."""
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < 1:
        return None
    prior = sorted_dates[max(0, idx - 20) : idx]
    if not prior:
        return None
    avg_vol = sum(dated[d].volume for d in prior) / len(prior)
    if avg_vol == 0:
        return None
    return dated[event_date].volume / avg_vol


def avg_dollar_volume_20d(bars: list[PriceBar], event_date: str) -> float | None:
    """20-day average dollar volume before the event day."""
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < 1:
        return None
    prior = sorted_dates[max(0, idx - 20) : idx]
    if not prior:
        return None
    dollar_volumes = [dated[d].close * dated[d].volume for d in prior]
    return sum(dollar_volumes) / len(dollar_volumes)


def close_location(bar: PriceBar) -> float | None:
    """(close - low) / (high - low): 0=closed at low, 1=at high."""
    rng = bar.high - bar.low
    if rng == 0:
        return None
    return (bar.close - bar.low) / rng


def gap_size(bars: list[PriceBar], event_date: str) -> float | None:
    """(open_today - close_yesterday) / close_yesterday."""
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx == 0:
        return None
    today = dated[event_date]
    yesterday = dated[sorted_dates[idx - 1]]
    if yesterday.close == 0:
        return None
    return (today.open - yesterday.close) / yesterday.close


def atr_14(bars: list[PriceBar]) -> float | None:
    """14-period Average True Range."""
    if len(bars) < 2:
        return None
    sorted_bars = sorted(bars, key=lambda b: b.date)
    true_ranges: list[float] = []
    for i in range(1, len(sorted_bars)):
        curr = sorted_bars[i]
        prev = sorted_bars[i - 1]
        tr = max(
            curr.high - curr.low,
            abs(curr.high - prev.close),
            abs(curr.low - prev.close),
        )
        true_ranges.append(tr)
    if len(true_ranges) < 14:
        return sum(true_ranges) / len(true_ranges) if true_ranges else None
    return sum(true_ranges[-14:]) / 14


def pre_event_momentum(bars: list[PriceBar], event_date: str, lookback: int = 20) -> float | None:
    """Return of the stock over `lookback` trading days before event_date."""
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < lookback:
        return None
    close_now = dated[sorted_dates[idx]].close
    close_past = dated[sorted_dates[idx - lookback]].close
    if close_past <= 0:
        return None
    return (close_now - close_past) / close_past


def price_vs_sma(bars: list[PriceBar], event_date: str, window: int = 20) -> float | None:
    """(close - SMA) / SMA on event_date. Positive = above SMA."""
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < window:
        return None
    close_now = dated[sorted_dates[idx]].close
    sma = sum(dated[sorted_dates[idx - i]].close for i in range(window)) / window
    if sma <= 0:
        return None
    return (close_now - sma) / sma


def pre_event_volatility(bars: list[PriceBar], event_date: str, lookback: int = 20) -> float | None:
    """Standard deviation of daily returns over `lookback` days before event_date."""
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < lookback + 1:  # need lookback+1 bars to get lookback returns
        return None
    returns = []
    for i in range(idx - lookback, idx):
        prev_close = dated[sorted_dates[i]].close
        curr_close = dated[sorted_dates[i + 1]].close
        if prev_close <= 0:
            continue
        returns.append((curr_close - prev_close) / prev_close)
    if len(returns) < lookback // 2:
        return None
    mean = sum(returns) / len(returns)
    variance = sum((r - mean) ** 2 for r in returns) / len(returns)
    return variance ** 0.5


def pre_event_rsi(bars: list[PriceBar], event_date: str, period: int = 14) -> float | None:
    """RSI on event_date using `period` lookback. Returns 0-100 scale."""
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < period + 1:
        return None
    gains = []
    losses = []
    for i in range(idx - period, idx):
        prev_close = dated[sorted_dates[i]].close
        curr_close = dated[sorted_dates[i + 1]].close
        change = curr_close - prev_close
        if change >= 0:
            gains.append(change)
            losses.append(0.0)
        else:
            gains.append(0.0)
            losses.append(abs(change))
    avg_gain = sum(gains) / period
    avg_loss = sum(losses) / period
    if avg_loss == 0:
        return 100.0
    rs = avg_gain / avg_loss
    return 100.0 - (100.0 / (1.0 + rs))


def pre_event_bb_position(bars: list[PriceBar], event_date: str, window: int = 20, num_std: float = 2.0) -> float | None:
    """Bollinger Band %B: (close - lower) / (upper - lower). >1 = above upper band."""
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < window:
        return None
    closes = [dated[sorted_dates[idx - i]].close for i in range(window)]
    sma = sum(closes) / window
    if sma <= 0:
        return None
    std = (sum((c - sma) ** 2 for c in closes) / window) ** 0.5
    if std <= 0:
        return 0.5  # flat price = middle of band
    upper = sma + num_std * std
    lower = sma - num_std * std
    band_width = upper - lower
    if band_width <= 0:
        return 0.5
    close = dated[sorted_dates[idx]].close
    return (close - lower) / band_width


def pre_event_obv_slope(bars: list[PriceBar], event_date: str, lookback: int = 20) -> float | None:
    """OBV slope over `lookback` days, normalized by average volume.

    Positive = accumulation (volume on up-days > volume on down-days).
    Returns slope per day / avg_volume, roughly in [-1, 1] range.
    """
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < lookback + 1:
        return None
    # Compute OBV series for the lookback window
    obv_series = [0.0]
    total_vol = 0.0
    for i in range(idx - lookback + 1, idx + 1):
        prev_close = dated[sorted_dates[i - 1]].close
        curr = dated[sorted_dates[i]]
        vol = float(curr.volume)
        total_vol += vol
        if curr.close > prev_close:
            obv_series.append(obv_series[-1] + vol)
        elif curr.close < prev_close:
            obv_series.append(obv_series[-1] - vol)
        else:
            obv_series.append(obv_series[-1])
    avg_vol = total_vol / lookback if lookback > 0 else 1.0
    if avg_vol <= 0:
        return None
    # Linear regression slope: OBV vs time index
    n = len(obv_series)
    x_mean = (n - 1) / 2.0
    y_mean = sum(obv_series) / n
    numerator = sum((i - x_mean) * (obv_series[i] - y_mean) for i in range(n))
    denominator = sum((i - x_mean) ** 2 for i in range(n))
    if denominator <= 0:
        return 0.0
    slope = numerator / denominator
    return slope / avg_vol  # normalized slope


def pre_event_hurst(bars: list[PriceBar], event_date: str, lookback: int = 60) -> float | None:
    """Hurst exponent via R/S (Rescaled Range) analysis.

    H > 0.5 = trending (persistent), H < 0.5 = mean-reverting (anti-persistent).
    H ≈ 0.5 = random walk. Uses sub-windows of sizes [8, 16, 32] for regression.
    """
    import math
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < lookback + 1:
        return None
    returns = []
    for i in range(idx - lookback, idx):
        prev_close = dated[sorted_dates[i]].close
        curr_close = dated[sorted_dates[i + 1]].close
        if prev_close <= 0:
            continue
        returns.append((curr_close - prev_close) / prev_close)
    if len(returns) < 30:
        return None

    def rs_stat(series: list[float]) -> float:
        n = len(series)
        mean = sum(series) / n
        deviations = [x - mean for x in series]
        cumdev = []
        s = 0.0
        for d in deviations:
            s += d
            cumdev.append(s)
        r = max(cumdev) - min(cumdev)
        std = (sum(d ** 2 for d in deviations) / n) ** 0.5
        if std <= 0:
            return 0.0
        return r / std

    # Compute R/S for different window sizes
    window_sizes = [s for s in [8, 12, 16, 24, 32] if s <= len(returns) // 2]
    if len(window_sizes) < 2:
        return None

    log_n = []
    log_rs = []
    for w in window_sizes:
        rs_values = []
        for start in range(0, len(returns) - w + 1, w):
            chunk = returns[start:start + w]
            if len(chunk) == w:
                rs_values.append(rs_stat(chunk))
        if rs_values:
            avg_rs = sum(rs_values) / len(rs_values)
            if avg_rs > 0:
                log_n.append(math.log(w))
                log_rs.append(math.log(avg_rs))

    if len(log_n) < 2:
        return None

    # Linear regression: log(R/S) = H * log(n) + c
    n = len(log_n)
    x_mean = sum(log_n) / n
    y_mean = sum(log_rs) / n
    num = sum((log_n[i] - x_mean) * (log_rs[i] - y_mean) for i in range(n))
    den = sum((log_n[i] - x_mean) ** 2 for i in range(n))
    if den <= 0:
        return 0.5
    return num / den


def pre_event_entropy(bars: list[PriceBar], event_date: str, lookback: int = 60, n_bins: int = 10) -> float | None:
    """Shannon entropy of return distribution. Lower = more predictable patterns."""
    import math
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < lookback + 1:
        return None
    returns = []
    for i in range(idx - lookback, idx):
        prev_close = dated[sorted_dates[i]].close
        curr_close = dated[sorted_dates[i + 1]].close
        if prev_close <= 0:
            continue
        returns.append((curr_close - prev_close) / prev_close)
    if len(returns) < lookback // 2:
        return None

    # Bin returns
    min_r = min(returns)
    max_r = max(returns)
    if max_r <= min_r:
        return 0.0  # zero entropy = perfectly predictable
    bin_width = (max_r - min_r) / n_bins
    counts = [0] * n_bins
    for r in returns:
        b = min(n_bins - 1, int((r - min_r) / bin_width))
        counts[b] += 1

    # Shannon entropy
    total = len(returns)
    entropy = 0.0
    for c in counts:
        if c > 0:
            p = c / total
            entropy -= p * math.log(p)
    return entropy


def pre_event_ou_theta(bars: list[PriceBar], event_date: str, lookback: int = 60) -> float | None:
    """Ornstein-Uhlenbeck mean-reversion speed θ via AR(1) regression.

    Fits x_t = α + β*x_{t-1} + ε on log prices. θ = -ln(β).
    High θ = fast reversion = PEAD unfriendly. Low θ = slow/no reversion = PEAD friendly.
    """
    import math
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < lookback + 1:
        return None
    log_prices = []
    for i in range(idx - lookback, idx + 1):
        c = dated[sorted_dates[i]].close
        if c <= 0:
            return None
        log_prices.append(math.log(c))
    if len(log_prices) < lookback:
        return None
    # AR(1) regression: y = log_prices[1:], x = log_prices[:-1]
    y = log_prices[1:]
    x = log_prices[:-1]
    n = len(y)
    x_mean = sum(x) / n
    y_mean = sum(y) / n
    num = sum((x[i] - x_mean) * (y[i] - y_mean) for i in range(n))
    den = sum((x[i] - x_mean) ** 2 for i in range(n))
    if den <= 0:
        return None
    beta = num / den
    if beta <= 0 or beta >= 1.0:
        return 0.0  # no mean reversion or explosive
    return -math.log(beta)


def pre_event_gravitational_pull(bars: list[PriceBar], event_date: str) -> float | None:
    """Distance from MA cluster (SMA20, SMA50) normalized by ATR14.

    High value = price far from MAs = strong gravitational pull back = mean reversion risk.
    Low value = price near MAs = stable = PEAD friendly.
    """
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < 50:
        return None
    close = dated[sorted_dates[idx]].close
    sma20 = sum(dated[sorted_dates[idx - i]].close for i in range(20)) / 20
    sma50 = sum(dated[sorted_dates[idx - i]].close for i in range(50)) / 50
    ma_center = (sma20 + sma50) / 2
    # ATR14 for normalization
    true_ranges = []
    for i in range(max(1, idx - 14), idx + 1):
        curr = dated[sorted_dates[i]]
        prev = dated[sorted_dates[i - 1]]
        tr = max(curr.high - curr.low, abs(curr.high - prev.close), abs(curr.low - prev.close))
        true_ranges.append(tr)
    atr = sum(true_ranges) / len(true_ranges) if true_ranges else 1.0
    if atr <= 0:
        return None
    return abs(close - ma_center) / atr


def pre_event_market_temperature(bars: list[PriceBar], event_date: str) -> float | None:
    """Market temperature: 5d vol / 20d vol ratio.

    > 1.0 = heating up (recent vol increasing). < 1.0 = cooling down (calming).
    Low temperature (< 1.0) = orderly conditions = PEAD friendly.
    """
    dated = {b.date: b for b in bars}
    sorted_dates = sorted(dated.keys())
    if event_date not in dated:
        return None
    idx = sorted_dates.index(event_date)
    if idx < 21:
        return None

    def vol_window(start_idx: int, length: int) -> float:
        rets = []
        for i in range(start_idx - length, start_idx):
            pc = dated[sorted_dates[i]].close
            cc = dated[sorted_dates[i + 1]].close
            if pc > 0:
                rets.append((cc - pc) / pc)
        if len(rets) < 2:
            return 0.0
        mean = sum(rets) / len(rets)
        return (sum((r - mean) ** 2 for r in rets) / len(rets)) ** 0.5

    vol_5d = vol_window(idx, 5)
    vol_20d = vol_window(idx, 20)
    if vol_20d <= 0:
        return None
    return vol_5d / vol_20d


def compute_market_features(
    bars: list[PriceBar], event_date: str
) -> dict[str, Any]:
    """Compute all market features for an event date."""
    dated = {b.date: b for b in bars}
    event_bar = dated.get(event_date)

    features: dict[str, Any] = {
        "reaction_day_return": reaction_day_return(bars, event_date),
        "volume_ratio_20d": volume_ratio_20d(bars, event_date),
        "avg_dollar_volume_20d": avg_dollar_volume_20d(bars, event_date),
        "gap_size": gap_size(bars, event_date),
        "atr_14": atr_14(bars),
        "pre_event_momentum_20d": pre_event_momentum(bars, event_date, 20),
        "price_vs_sma20": price_vs_sma(bars, event_date, 20),
        "pre_event_volatility_20d": pre_event_volatility(bars, event_date, 20),
        "pre_event_rsi_14": pre_event_rsi(bars, event_date, 14),
        "pre_event_bb_position": pre_event_bb_position(bars, event_date, 20),
        "pre_event_obv_slope_20d": pre_event_obv_slope(bars, event_date, 20),
        "pre_event_hurst_60d": pre_event_hurst(bars, event_date, 60),
        "pre_event_entropy_60d": pre_event_entropy(bars, event_date, 60),
        "pre_event_ou_theta_60d": pre_event_ou_theta(bars, event_date, 60),
        "pre_event_gravitational_pull": pre_event_gravitational_pull(bars, event_date),
        "pre_event_market_temperature": pre_event_market_temperature(bars, event_date),
    }
    if event_bar:
        features["close_location"] = close_location(event_bar)
        features["event_close"] = event_bar.close
        features["event_volume"] = event_bar.volume
        features["reaction_day_low"] = event_bar.low
        features["reaction_day_high"] = event_bar.high
    else:
        features["close_location"] = None
        features["event_close"] = None
        features["event_volume"] = None
        features["reaction_day_low"] = None
        features["reaction_day_high"] = None

    return features
