"""Intraday feature computations for the ORB strategy.

Pure functions operating on dict-based daily bar data (from fetch_daily_bars_bulk).
No API calls or I/O.

Used by orb_simulator.py and orb_pre_screen_candidates in screener.py.
"""
from __future__ import annotations

from collections import deque
import math
import statistics


def compute_atr_from_dicts(daily_bars: list[dict], period: int = 14) -> float | None:
    """14-period ATR using true range formula.

    Same formula as libs/features/market_features.py:atr_14() but operates on
    plain dicts with 'date', 'high', 'low', 'close' keys.

    Returns partial average if fewer than `period` bars are available.
    Requires at least 2 bars to compute one true range.
    """
    if len(daily_bars) < 2:
        return None
    sorted_bars = sorted(daily_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 not true_ranges:
        return None
    recent = true_ranges[-period:]
    return sum(recent) / len(recent)


def compute_avg_dollar_volume(daily_bars: list[dict], lookback: int = 30) -> float | None:
    """Average daily dollar volume = mean(close × volume) over last `lookback` bars."""
    if not daily_bars:
        return None
    sorted_bars = sorted(daily_bars, key=lambda b: b["date"])
    recent = sorted_bars[-lookback:]
    dollar_vols = [
        b["close"] * b["volume"]
        for b in recent
        if b.get("volume") and b.get("close")
    ]
    if not dollar_vols:
        return None
    return sum(dollar_vols) / len(dollar_vols)


def compute_avg_daily_volume(daily_bars: list[dict], lookback: int = 14) -> float | None:
    """Average daily share volume over last `lookback` bars."""
    if not daily_bars:
        return None
    sorted_bars = sorted(daily_bars, key=lambda b: b["date"])
    recent = sorted_bars[-lookback:]
    volumes = [b["volume"] for b in recent if b.get("volume")]
    if not volumes:
        return None
    return sum(volumes) / len(volumes)


def compute_gap_pct(prev_close: float, today_open: float) -> float | None:
    """Gap % = (today_open - prev_close) / prev_close.

    Positive = gap up, negative = gap down.
    """
    if prev_close <= 0 or today_open <= 0:
        return None
    return (today_open - prev_close) / prev_close


def compute_entropy_approx(daily_bars: list[dict], lookback: int = 20) -> float | None:
    """Normalized Shannon entropy of recent close-to-close returns.

    Uses fixed return buckets and returns a value in [0, 1], where lower values
    indicate more ordered / repetitive recent behaviour and higher values
    indicate a broader return distribution.
    """
    if len(daily_bars) < max(lookback, 2):
        return None
    sorted_bars = sorted(daily_bars, key=lambda b: b["date"])
    recent = sorted_bars[-(lookback + 1):]
    returns: list[float] = []
    for i in range(1, len(recent)):
        prev_close = recent[i - 1].get("close")
        curr_close = recent[i].get("close")
        if not prev_close or prev_close <= 0 or curr_close is None:
            continue
        returns.append((curr_close - prev_close) / prev_close)
    if len(returns) < lookback:
        return None

    edges = [-0.05, -0.02, -0.01, -0.0025, 0.0025, 0.01, 0.02, 0.05]
    counts = [0] * (len(edges) + 1)
    for ret in returns[-lookback:]:
        placed = False
        for idx, edge in enumerate(edges):
            if ret < edge:
                counts[idx] += 1
                placed = True
                break
        if not placed:
            counts[-1] += 1

    total = sum(counts)
    if total <= 0:
        return None
    probs = [count / total for count in counts if count > 0]
    if not probs:
        return None
    entropy = -sum(p * math.log(p) for p in probs)
    max_entropy = math.log(len(counts))
    if max_entropy <= 0:
        return None
    return entropy / max_entropy


def compute_obv_slope_approx(daily_bars: list[dict], lookback: int = 20) -> float | None:
    """OBV accumulation slope over `lookback` days, normalized by average volume.

    Positive = accumulation (volume on up-days exceeds down-days in recent window).
    Negative = distribution. Returns slope-per-day / avg_volume, roughly in [-1, 1].
    """
    if len(daily_bars) < lookback + 2:
        return None
    sorted_bars = sorted(daily_bars, key=lambda b: b["date"])
    recent = sorted_bars[-(lookback + 1):]
    obv_series = [0.0]
    total_vol = 0.0
    for i in range(1, len(recent)):
        pc = recent[i - 1].get("close")
        cc = recent[i].get("close")
        vol = float(recent[i].get("volume") or 0)
        total_vol += vol
        if not pc or pc <= 0:
            continue
        if cc > pc:
            obv_series.append(obv_series[-1] + vol)
        elif cc < pc:
            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
    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
    return (numerator / denominator) / avg_vol


def compute_average_true_range(daily_bars: list[dict], lookback: int) -> float | None:
    """Average true range over the last `lookback` completed daily bars."""
    if len(daily_bars) < 2:
        return None
    sorted_bars = sorted(daily_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) < lookback:
        return None
    recent = true_ranges[-lookback:]
    return sum(recent) / len(recent)


def compute_average_range(daily_bars: list[dict], lookback: int) -> float | None:
    """Average high-low range over the last `lookback` completed daily bars."""
    if len(daily_bars) < lookback:
        return None
    sorted_bars = sorted(daily_bars, key=lambda b: b["date"])
    recent = sorted_bars[-lookback:]
    ranges = [b["high"] - b["low"] for b in recent if b.get("high") is not None and b.get("low") is not None]
    if len(ranges) < lookback:
        return None
    return sum(ranges) / len(ranges)


def compute_gap_zscore(
    daily_bars: list[dict],
    today_open: float,
    lookback: int = 20,
) -> float | None:
    """Today's opening gap z-score relative to prior completed daily gaps."""
    if len(daily_bars) < max(lookback + 1, 2):
        return None
    sorted_bars = sorted(daily_bars, key=lambda b: b["date"])
    if today_open <= 0:
        return None
    prev_close = sorted_bars[-1].get("close")
    if prev_close is None or prev_close <= 0:
        return None

    gaps: list[float] = []
    for i in range(1, len(sorted_bars)):
        prev = sorted_bars[i - 1].get("close")
        curr_open = sorted_bars[i].get("open")
        if prev and prev > 0 and curr_open and curr_open > 0:
            gaps.append((curr_open - prev) / prev)
    if len(gaps) < lookback:
        return None

    sample = gaps[-lookback:]
    mean_gap = statistics.mean(sample)
    std_gap = statistics.stdev(sample) if len(sample) >= 2 else 0.0
    if std_gap <= 0:
        return 0.0
    today_gap = (today_open - prev_close) / prev_close
    return (today_gap - mean_gap) / std_gap


def compute_rvol_approx(
    first_bar_volume: float,
    avg_daily_volume: float,
    bars_per_day: float = 78.0,
) -> float | None:
    """Approximate Relative Volume (RVOL) at market open.

    RVOL = first_bar_volume / expected_bar_volume
    where expected = avg_daily_volume / bars_per_day (uniform distribution assumption).

    78 = 6.5 hours × 12 five-min-bars/hour = bars per full trading day.

    Note: actual morning volume is typically 2–3× the uniform expectation, so this
    RVOL will be systematically higher than "true" first-5-min RVOL. Factor this
    in when calibrating min_rvol thresholds (e.g. min_rvol=1.0 here ≈ 0.4 true RVOL).
    The approximation is consistent across all tickers, making it useful for ranking
    even if the absolute scale is inflated.
    """
    if avg_daily_volume <= 0 or bars_per_day <= 0:
        return None
    expected = avg_daily_volume / bars_per_day
    if expected <= 0:
        return None
    return first_bar_volume / expected


def enrich_daily_bars(
    daily_bars_by_ticker: dict[str, list[dict]],
    trading_days: list[str],
) -> dict[str, dict[str, dict]]:
    """Compute per-ticker per-day derived features from daily bars.

    Called once between Phase 1 and Phase 2 when using the ORB strategy.
    All features are computed from bars BEFORE the given date (no lookahead).

    Args:
        daily_bars_by_ticker: {ticker: [bar_dict, ...]} from fetch_daily_bars_bulk().
        trading_days: Ordered list of date strings to compute enrichment for.

    Returns:
        {ticker: {date: {
            "atr_14": float | None,        — ATR(14) from prior 14 days
            "avg_dollar_vol_30d": float | None, — 30-day avg daily dollar volume
            "avg_daily_vol_14d": float | None,  — 14-day avg daily share volume
            "prev_close": float | None,    — prior day's close (for gap calc)
            "today_open": float | None,    — today's open (from today's bar)
            "entropy_20d": float | None,   — normalized entropy of recent returns
            "atr_ratio_10_60": float | None, — ATR(10) / ATR(60)
            "range_compression_10_60": float | None, — avg_range_10 / avg_range_60
            "gap_zscore_20d": float | None, — today's opening gap z-score
        }}}
    """
    result: dict[str, dict[str, dict]] = {}
    trading_days_set = set(trading_days)

    for ticker, bars in daily_bars_by_ticker.items():
        if not bars:
            continue

        sorted_bars = sorted(bars, key=lambda b: b["date"])
        ticker_result: dict[str, dict] = {}

        for i, today_bar in enumerate(sorted_bars):
            today_date = today_bar["date"][:10]
            if today_date not in trading_days_set:
                continue

            # bars BEFORE today (lookahead-free)
            prev_bars = sorted_bars[:i]

            prev_close = sorted_bars[i - 1]["close"] if i > 0 else None

            # 5-day prior momentum: (prev_close / close_5d_ago) - 1
            # Lookahead-free: uses only bars before today.
            ret_5d: float | None = None
            if len(prev_bars) >= 6 and prev_close:
                close_5d_ago = prev_bars[-5]["close"]
                if close_5d_ago and close_5d_ago > 0:
                    ret_5d = (prev_close - close_5d_ago) / close_5d_ago

            ticker_result[today_date] = {
                "atr_14": (
                    compute_atr_from_dicts(prev_bars, period=14)
                    if len(prev_bars) >= 2 else None
                ),
                "avg_dollar_vol_30d": (
                    compute_avg_dollar_volume(prev_bars, lookback=30)
                    if prev_bars else None
                ),
                "avg_daily_vol_14d": (
                    compute_avg_daily_volume(prev_bars, lookback=14)
                    if prev_bars else None
                ),
                "prev_close": prev_close,
                "today_open": today_bar.get("open"),
                "synthetic_today_daily": bool(today_bar.get("synthetic_today_daily")),
                "ret_5d": ret_5d,
                "entropy_20d": (
                    compute_entropy_approx(prev_bars, lookback=20)
                    if len(prev_bars) >= 20 else None
                ),
                "obv_slope_20": (
                    compute_obv_slope_approx(prev_bars, lookback=20)
                    if len(prev_bars) >= 22 else None
                ),
                "obv_slope_5": (
                    compute_obv_slope_approx(prev_bars, lookback=5)
                    if len(prev_bars) >= 7 else None
                ),
                "atr_ratio_10_60": _compute_ratio(
                    compute_average_true_range(prev_bars, lookback=10),
                    compute_average_true_range(prev_bars, lookback=60),
                ),
                "range_compression_10_60": _compute_ratio(
                    compute_average_range(prev_bars, lookback=10),
                    compute_average_range(prev_bars, lookback=60),
                ),
                "gap_zscore_20d": (
                    compute_gap_zscore(prev_bars, today_bar.get("open") or 0.0, lookback=20)
                    if len(prev_bars) >= 21 and (today_bar.get("open") or 0.0) > 0
                    else None
                ),
            }

        if ticker_result:
            result[ticker] = ticker_result

    return result


def _compute_ratio(numerator: float | None, denominator: float | None) -> float | None:
    if numerator is None or denominator is None or denominator == 0:
        return None
    return numerator / denominator
