"""Stateful technical indicators for Vanta Crunch models.

Each indicator is a lightweight object that updates incrementally:

    from vanta.indicators import RSI, SMA, BollingerBands

    rsi = RSI(period=14)
    for candle in candles:
        rsi.update(candle["close"])
    print(rsi.value)  # current RSI reading

Designed for the tick() → predict() flow. No pandas or numpy required.
"""

from __future__ import annotations

import math
from collections import deque
from typing import NamedTuple


# ── Base ──────────────────────────────────────────────────────────────────────


class Indicator:
    """Base class for all indicators."""

    def __init__(self):
        self._value = None

    @property
    def value(self):
        """Current indicator value, or None if not ready."""
        return self._value

    @property
    def ready(self) -> bool:
        """True once enough data has been fed to produce a value."""
        return self._value is not None

    def reset(self) -> None:
        """Clear all internal state."""
        self._value = None

    def update(self, price: float) -> None:
        """Feed one price. Override in subclasses."""
        raise NotImplementedError


# ── Moving Averages ───────────────────────────────────────────────────────────


class SMA(Indicator):
    """Simple Moving Average.

    >>> sma = SMA(period=3)
    >>> for p in [10, 20, 30]: sma.update(p)
    >>> sma.value
    20.0
    """

    def __init__(self, period: int = 20):
        super().__init__()
        self.period = period
        self._buffer: deque[float] = deque(maxlen=period)

    def update(self, price: float) -> None:
        self._buffer.append(price)
        if len(self._buffer) == self.period:
            self._value = sum(self._buffer) / self.period

    def reset(self) -> None:
        super().reset()
        self._buffer.clear()


class EMA(Indicator):
    """Exponential Moving Average.

    >>> ema = EMA(period=3)
    >>> for p in [10, 20, 30, 40]: ema.update(p)
    >>> ema.ready
    True
    """

    def __init__(self, period: int = 20, smoothing: float = 2.0):
        super().__init__()
        self.period = period
        self._multiplier = smoothing / (period + 1)
        self._count = 0
        self._sum = 0.0

    def update(self, price: float) -> None:
        self._count += 1
        if self._count <= self.period:
            self._sum += price
            if self._count == self.period:
                self._value = self._sum / self.period
        else:
            self._value = (price - self._value) * self._multiplier + self._value

    def reset(self) -> None:
        super().reset()
        self._count = 0
        self._sum = 0.0


# ── RSI ───────────────────────────────────────────────────────────────────────


class RSI(Indicator):
    """Relative Strength Index (0–100).

    >>> rsi = RSI(period=3)
    >>> for p in [44, 44.34, 44.09, 43.61]: rsi.update(p)
    >>> rsi.ready
    True
    """

    def __init__(self, period: int = 14):
        super().__init__()
        self.period = period
        self._prev_price: float | None = None
        self._gains: deque[float] = deque(maxlen=period)
        self._losses: deque[float] = deque(maxlen=period)
        self._avg_gain: float | None = None
        self._avg_loss: float | None = None
        self._count = 0

    def update(self, price: float) -> None:
        if self._prev_price is not None:
            change = price - self._prev_price
            gain = max(change, 0.0)
            loss = max(-change, 0.0)
            self._gains.append(gain)
            self._losses.append(loss)
            self._count += 1

            if self._count == self.period:
                self._avg_gain = sum(self._gains) / self.period
                self._avg_loss = sum(self._losses) / self.period
            elif self._count > self.period:
                self._avg_gain = (self._avg_gain * (self.period - 1) + gain) / self.period
                self._avg_loss = (self._avg_loss * (self.period - 1) + loss) / self.period

            if self._avg_gain is not None:
                if self._avg_loss < 1e-12:
                    self._value = 100.0
                else:
                    rs = self._avg_gain / self._avg_loss
                    self._value = 100.0 - (100.0 / (1.0 + rs))

        self._prev_price = price

    def reset(self) -> None:
        super().reset()
        self._prev_price = None
        self._gains.clear()
        self._losses.clear()
        self._avg_gain = None
        self._avg_loss = None
        self._count = 0


# ── MACD ──────────────────────────────────────────────────────────────────────


class MACDResult(NamedTuple):
    macd: float
    signal: float
    histogram: float


class MACD(Indicator):
    """Moving Average Convergence Divergence.

    `.value` returns a MACDResult(macd, signal, histogram).

    >>> macd = MACD(fast=3, slow=5, signal=3)
    >>> for p in [10, 11, 12, 13, 14, 15, 16]: macd.update(p)
    >>> macd.ready
    True
    """

    def __init__(self, fast: int = 12, slow: int = 26, signal: int = 9):
        super().__init__()
        self._fast_ema = EMA(period=fast)
        self._slow_ema = EMA(period=slow)
        self._signal_ema = EMA(period=signal)

    def update(self, price: float) -> None:
        self._fast_ema.update(price)
        self._slow_ema.update(price)

        if self._fast_ema.ready and self._slow_ema.ready:
            macd_line = self._fast_ema.value - self._slow_ema.value
            self._signal_ema.update(macd_line)

            if self._signal_ema.ready:
                signal_line = self._signal_ema.value
                self._value = MACDResult(
                    macd=macd_line,
                    signal=signal_line,
                    histogram=macd_line - signal_line,
                )

    def reset(self) -> None:
        super().reset()
        self._fast_ema.reset()
        self._slow_ema.reset()
        self._signal_ema.reset()


# ── Bollinger Bands ───────────────────────────────────────────────────────────


class BollingerResult(NamedTuple):
    upper: float
    middle: float
    lower: float
    width: float
    percent_b: float  # where price is within the bands (0 = lower, 1 = upper)


class BollingerBands(Indicator):
    """Bollinger Bands.

    `.value` returns BollingerResult(upper, middle, lower, width, percent_b).

    >>> bb = BollingerBands(period=3, num_std=2.0)
    >>> for p in [10, 11, 12]: bb.update(p)
    >>> bb.ready
    True
    """

    def __init__(self, period: int = 20, num_std: float = 2.0):
        super().__init__()
        self.period = period
        self.num_std = num_std
        self._buffer: deque[float] = deque(maxlen=period)

    def update(self, price: float) -> None:
        self._buffer.append(price)
        if len(self._buffer) == self.period:
            mean = sum(self._buffer) / self.period
            variance = sum((x - mean) ** 2 for x in self._buffer) / self.period
            std = math.sqrt(variance)

            upper = mean + self.num_std * std
            lower = mean - self.num_std * std
            width = (upper - lower) / mean if mean != 0 else 0.0
            band_range = upper - lower
            percent_b = (price - lower) / band_range if band_range > 1e-12 else 0.5

            self._value = BollingerResult(
                upper=upper, middle=mean, lower=lower,
                width=width, percent_b=percent_b,
            )

    def reset(self) -> None:
        super().reset()
        self._buffer.clear()


# ── ATR ───────────────────────────────────────────────────────────────────────


class ATR(Indicator):
    """Average True Range — measures volatility.

    Uses `.update(high, low, close)` instead of `.update(price)`.

    >>> atr = ATR(period=3)
    >>> for h, l, c in [(11,9,10), (12,10,11), (13,11,12), (14,12,13)]:
    ...     atr.update(h, l, c)
    >>> atr.ready
    True
    """

    def __init__(self, period: int = 14):
        super().__init__()
        self.period = period
        self._prev_close: float | None = None
        self._tr_buffer: deque[float] = deque(maxlen=period)
        self._count = 0

    def update(self, high: float, low: float, close: float) -> None:  # type: ignore[override]
        if self._prev_close is not None:
            tr = max(
                high - low,
                abs(high - self._prev_close),
                abs(low - self._prev_close),
            )
        else:
            tr = high - low

        self._tr_buffer.append(tr)
        self._count += 1
        self._prev_close = close

        if self._count >= self.period:
            if self._value is None:
                self._value = sum(self._tr_buffer) / self.period
            else:
                self._value = (self._value * (self.period - 1) + tr) / self.period

    def reset(self) -> None:
        super().reset()
        self._prev_close = None
        self._tr_buffer.clear()
        self._count = 0


# ── VWAP ──────────────────────────────────────────────────────────────────────


class VWAP(Indicator):
    """Volume-Weighted Average Price.

    Uses `.update(price, volume)`. Resets each trading session
    (or call `.reset()` manually).

    >>> vwap = VWAP()
    >>> vwap.update(100.0, 10.0)
    >>> vwap.update(102.0, 20.0)
    >>> abs(vwap.value - 101.333) < 0.01
    True
    """

    def __init__(self):
        super().__init__()
        self._cum_pv = 0.0
        self._cum_volume = 0.0

    def update(self, price: float, volume: float = 1.0) -> None:  # type: ignore[override]
        self._cum_pv += price * volume
        self._cum_volume += volume
        if self._cum_volume > 0:
            self._value = self._cum_pv / self._cum_volume

    def reset(self) -> None:
        super().reset()
        self._cum_pv = 0.0
        self._cum_volume = 0.0


# ── Returns ───────────────────────────────────────────────────────────────────


class Returns(Indicator):
    """Percentage return over N periods.

    >>> ret = Returns(period=1)
    >>> ret.update(100.0)
    >>> ret.update(105.0)
    >>> ret.value
    0.05
    """

    def __init__(self, period: int = 1):
        super().__init__()
        self.period = period
        self._buffer: deque[float] = deque(maxlen=period + 1)

    def update(self, price: float) -> None:
        self._buffer.append(price)
        if len(self._buffer) == self.period + 1:
            old = self._buffer[0]
            if abs(old) > 1e-12:
                self._value = (price - old) / old
            else:
                self._value = 0.0

    def reset(self) -> None:
        super().reset()
        self._buffer.clear()


# ── Order Book Indicators ─────────────────────────────────────────────────────


class OrderBookImbalance(Indicator):
    """Smoothed order book imbalance signal.

    Tracks the EMA of bid/ask depth imbalance to detect persistent
    buying or selling pressure. Positive = more bids, negative = more asks.

    Feed with `data["orderbook"]["imbalance"]` from tick data.

    >>> obi = OrderBookImbalance(period=5)
    >>> for v in [0.3, 0.2, 0.1, -0.1, 0.15]: obi.update(v)
    >>> obi.ready
    True
    """

    def __init__(self, period: int = 10):
        super().__init__()
        self._ema = EMA(period=period)

    def update(self, imbalance: float) -> None:  # type: ignore[override]
        """Feed one imbalance value: (bid_depth - ask_depth) / (bid_depth + ask_depth)."""
        self._ema.update(imbalance)
        self._value = self._ema.value

    @property
    def ready(self) -> bool:
        return self._ema.ready

    def reset(self) -> None:
        super().reset()
        self._ema.reset()


class SpreadTracker(Indicator):
    """Tracks bid-ask spread relative to mid price (basis points).

    Useful for detecting liquidity regime changes. Widening spread often
    precedes volatility.

    >>> st = SpreadTracker(period=3)
    >>> for b, a in [(100, 100.02), (100, 100.03), (100, 100.01)]: st.update(b, a)
    >>> st.ready
    True
    """

    def __init__(self, period: int = 20):
        super().__init__()
        self._sma = SMA(period=period)

    def update(self, best_bid: float, best_ask: float) -> None:  # type: ignore[override]
        mid = (best_bid + best_ask) / 2.0
        spread_bps = (best_ask - best_bid) / mid * 10_000 if mid > 0 else 0.0
        self._sma.update(spread_bps)
        self._value = self._sma.value

    @property
    def ready(self) -> bool:
        return self._sma.ready

    def reset(self) -> None:
        super().reset()
        self._sma.reset()


# ── Funding Rate Indicators ───────────────────────────────────────────────────


class FundingSignal(Indicator):
    """Smoothed funding rate signal for mean-reversion strategies.

    High positive funding → longs crowded → expect downward pressure.
    High negative funding → shorts crowded → expect upward pressure.

    Feed with `data["funding"]["funding_rate"]` from tick data.

    >>> fs = FundingSignal(period=3)
    >>> for r in [0.0001, 0.0002, 0.0003]: fs.update(r)
    >>> fs.ready
    True
    """

    def __init__(self, period: int = 8):
        super().__init__()
        self._ema = EMA(period=period)

    def update(self, funding_rate: float) -> None:
        self._ema.update(funding_rate)
        self._value = self._ema.value

    @property
    def ready(self) -> bool:
        return self._ema.ready

    def reset(self) -> None:
        super().reset()
        self._ema.reset()


class BasisTracker(Indicator):
    """Tracks futures basis (mark - index) / index as a smoothed signal.

    Positive basis = futures premium (bullish sentiment).
    Negative basis = futures discount (bearish sentiment).

    >>> bt = BasisTracker(period=3)
    >>> for b in [0.001, 0.002, 0.0015]: bt.update(b)
    >>> bt.ready
    True
    """

    def __init__(self, period: int = 8):
        super().__init__()
        self._ema = EMA(period=period)

    def update(self, basis: float) -> None:
        self._ema.update(basis)
        self._value = self._ema.value

    @property
    def ready(self) -> bool:
        return self._ema.ready

    def reset(self) -> None:
        super().reset()
        self._ema.reset()


# ── Volume Indicators ─────────────────────────────────────────────────────────


class VolumeRatio(Indicator):
    """Current volume relative to its moving average.

    Values > 1.0 mean above-average volume; < 1.0 is below average.
    Useful for confirming breakouts (high volume) or detecting
    low-conviction moves (low volume).

    >>> vr = VolumeRatio(period=3)
    >>> for v in [100, 200, 300, 600]: vr.update(v)
    >>> vr.value  # 600 / mean(200,300,600) ≈ 1.636
    3.0
    """

    def __init__(self, period: int = 20):
        super().__init__()
        self.period = period
        self._sma = SMA(period=period)
        self._last_vol: float = 0.0

    def update(self, volume: float) -> None:
        self._sma.update(volume)
        self._last_vol = volume
        if self._sma.ready and self._sma.value > 1e-12:
            self._value = self._last_vol / self._sma.value

    def reset(self) -> None:
        super().reset()
        self._sma.reset()
        self._last_vol = 0.0


class OBV(Indicator):
    """On-Balance Volume — cumulative volume in the direction of price.

    Rising OBV with flat price = accumulation (bullish).
    Falling OBV with flat price = distribution (bearish).
    The raw value isn't meaningful — use the trend (via EMA) or
    divergence from price.

    >>> obv = OBV()
    >>> obv.update(100.0, 10.0)  # price, volume
    >>> obv.update(101.0, 15.0)  # price up → add volume
    >>> obv.value
    25.0
    """

    def __init__(self):
        super().__init__()
        self._prev_price: float | None = None
        self._cumulative: float = 0.0

    def update(self, price: float, volume: float) -> None:  # type: ignore[override]
        if self._prev_price is not None:
            if price > self._prev_price:
                self._cumulative += volume
            elif price < self._prev_price:
                self._cumulative -= volume
            # price == prev → no change
        else:
            self._cumulative = volume

        self._prev_price = price
        self._value = self._cumulative

    def reset(self) -> None:
        super().reset()
        self._prev_price = None
        self._cumulative = 0.0


class OBVDivergence(Indicator):
    """Detects divergence between OBV trend and price trend.

    Positive = OBV rising faster than price (accumulation / bullish).
    Negative = OBV falling faster than price (distribution / bearish).

    Uses normalised rate-of-change over a lookback to compare.

    >>> obd = OBVDivergence(period=3)
    >>> for p, v in [(100,10), (101,15), (100,20), (99,25)]:
    ...     obd.update(p, v)
    >>> obd.ready
    True
    """

    def __init__(self, period: int = 20):
        super().__init__()
        self.period = period
        self._obv = OBV()
        self._obv_roc = Returns(period=period)
        self._price_roc = Returns(period=period)

    def update(self, price: float, volume: float) -> None:  # type: ignore[override]
        self._obv.update(price, volume)
        if self._obv.ready:
            self._obv_roc.update(self._obv.value)
        self._price_roc.update(price)

        if self._obv_roc.ready and self._price_roc.ready:
            # Positive divergence = OBV outpacing price (bullish)
            self._value = (self._obv_roc.value or 0.0) - (self._price_roc.value or 0.0)

    @property
    def ready(self) -> bool:
        return self._obv_roc.ready and self._price_roc.ready

    def reset(self) -> None:
        super().reset()
        self._obv.reset()
        self._obv_roc.reset()
        self._price_roc.reset()


class ReturnAutocorrelation(Indicator):
    """Rolling autocorrelation of returns at a given lag.

    Positive autocorrelation → trending (momentum regime).
    Negative autocorrelation → mean-reverting regime.
    Near zero → random walk.

    More principled than ATR-based regime detection.

    >>> ra = ReturnAutocorrelation(period=5, lag=1)
    >>> for p in [100, 101, 102, 103, 104, 105, 106]: ra.update(p)
    >>> ra.ready
    True
    >>> ra.value > 0  # perfect uptrend = positive autocorrelation
    True
    """

    def __init__(self, period: int = 60, lag: int = 1):
        super().__init__()
        self.period = period
        self.lag = lag
        self._returns: deque[float] = deque(maxlen=period + lag)
        self._prev_price: float | None = None

    def update(self, price: float) -> None:
        if self._prev_price is not None and self._prev_price != 0:
            ret = (price - self._prev_price) / self._prev_price
            self._returns.append(ret)

            if len(self._returns) >= self.period + self.lag:
                # Compute autocorrelation at self.lag
                n = self.period
                recent = list(self._returns)
                x = recent[-(n + self.lag): -self.lag]  # lagged
                y = recent[-n:]  # current

                mean_x = sum(x) / n
                mean_y = sum(y) / n
                cov = sum((xi - mean_x) * (yi - mean_y) for xi, yi in zip(x, y)) / n
                std_x = (sum((xi - mean_x) ** 2 for xi in x) / n) ** 0.5
                std_y = (sum((yi - mean_y) ** 2 for yi in y) / n) ** 0.5

                if std_x > 1e-12 and std_y > 1e-12:
                    self._value = cov / (std_x * std_y)
                else:
                    self._value = 0.0

        self._prev_price = price

    def reset(self) -> None:
        super().reset()
        self._returns.clear()
        self._prev_price = None


# ── Gaussian Filter ───────────────────────────────────────────────────────────


class GaussianFilter(Indicator):
    """N-pole Gaussian filter (Ehlers).

    A recursive smoothing filter that removes high-frequency noise while
    preserving the underlying cycle structure.  Higher poles = sharper
    frequency cutoff but more lag.

    The 4-pole variant is the standard for cycle analysis.

    Args:
        period: Cutoff period (bars).  Cycles shorter than this are attenuated.
        poles: Number of filter poles (1–4).  More poles = smoother.

    >>> gf = GaussianFilter(period=40, poles=4)
    >>> for p in range(100): gf.update(100 + p * 0.1)
    >>> gf.ready
    True

    Reference: John Ehlers, "Cybernetic Analysis for Stocks and Futures"
    """

    def __init__(self, period: int = 40, poles: int = 4):
        super().__init__()
        self.period = period
        self.poles = max(1, min(4, poles))

        # Compute alpha from Ehlers' formula
        beta = (1 - math.cos(2 * math.pi / period)) / (2 ** (2.0 / self.poles) - 1)
        self._alpha = -beta + math.sqrt(beta * beta + 2 * beta)

        # Binomial expansion coefficients for n-pole filter:
        # output = α^n * input + C(n,1)*(1-α)*out[1] - C(n,2)*(1-α)^2*out[2] + ...
        a = self._alpha
        b = 1 - a
        n = self.poles
        if n == 1:
            self._coeffs = (a, b)
        elif n == 2:
            self._coeffs = (a * a, 2 * b, -(b * b))
        elif n == 3:
            self._coeffs = (a ** 3, 3 * b, -3 * b * b, b ** 3)
        else:  # n == 4
            self._coeffs = (a ** 4, 4 * b, -6 * b * b, 4 * b ** 3, -(b ** 4))

        self._history = [0.0] * (self.poles + 1)  # output history
        self._count = 0

    def update(self, price: float) -> None:
        self._count += 1

        if self._count <= self.poles:
            # Not enough history for the recursive formula — seed with price
            self._history[0] = price
        else:
            # Apply n-pole recursive filter
            c = self._coeffs
            h = self._history
            if self.poles == 1:
                self._history[0] = c[0] * price + c[1] * h[1]
            elif self.poles == 2:
                self._history[0] = c[0] * price + c[1] * h[1] + c[2] * h[2]
            elif self.poles == 3:
                self._history[0] = c[0] * price + c[1] * h[1] + c[2] * h[2] + c[3] * h[3]
            else:
                self._history[0] = c[0] * price + c[1] * h[1] + c[2] * h[2] + c[3] * h[3] + c[4] * h[4]

        # Shift history
        for i in range(self.poles, 0, -1):
            self._history[i] = self._history[i - 1]

        if self._count >= self.period:
            self._value = self._history[0]

    def reset(self) -> None:
        super().reset()
        self._history = [0.0] * (self.poles + 1)
        self._count = 0
