"""Tests for volatility suite (Rust functions + Python pipeline)."""

import math

import pytest

import horizon as hz
from horizon._horizon import (
    estimate_volatility,
    ewma_vol,
    garman_klass_vol,
    parkinson_vol,
    rolling_vol,
    yang_zhang_vol,
)
from horizon.volatility import VolatilitySnapshot, volatility


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

SQRT_365 = math.sqrt(365)

# A simple trending price series
PRICES = [1.0, 1.01, 0.99, 1.02, 0.98, 1.03, 0.97, 1.04, 0.96, 1.05]

# OHLC data for range-based estimators
OPENS = [1.00, 1.02, 0.98, 1.01, 0.99]
HIGHS = [1.05, 1.06, 1.04, 1.07, 1.03]
LOWS = [0.95, 0.94, 0.96, 0.93, 0.95]
CLOSES = [1.02, 0.98, 1.01, 1.03, 0.97]


# ===========================================================================
# Rust function tests: estimate_volatility
# ===========================================================================


class TestEstimateVolatility:
    def test_basic_positive(self):
        vol = estimate_volatility(PRICES)
        assert vol > 0.0

    def test_constant_prices(self):
        assert estimate_volatility([1.0, 1.0, 1.0, 1.0]) == 0.0

    def test_too_few(self):
        assert estimate_volatility([1.0]) == 0.0
        assert estimate_volatility([]) == 0.0

    def test_annualize_flag(self):
        vol_raw = estimate_volatility(PRICES, annualize=False)
        vol_ann = estimate_volatility(PRICES, annualize=True)
        assert vol_ann == pytest.approx(vol_raw * SQRT_365, rel=1e-10)

    def test_backward_compat_default_no_annualize(self):
        """Default annualize=False preserves existing behavior."""
        vol = estimate_volatility(PRICES)
        vol_explicit = estimate_volatility(PRICES, annualize=False)
        assert vol == vol_explicit


# ===========================================================================
# Rust function tests: parkinson_vol
# ===========================================================================


class TestParkinsonVol:
    def test_basic_positive(self):
        vol = parkinson_vol(HIGHS, LOWS, annualize=False)
        assert vol > 0.0

    def test_annualized(self):
        raw = parkinson_vol(HIGHS, LOWS, annualize=False)
        ann = parkinson_vol(HIGHS, LOWS, annualize=True)
        assert ann == pytest.approx(raw * SQRT_365, rel=1e-10)

    def test_default_annualize_true(self):
        vol = parkinson_vol(HIGHS, LOWS)
        raw = parkinson_vol(HIGHS, LOWS, annualize=False)
        assert vol == pytest.approx(raw * SQRT_365, rel=1e-10)

    def test_empty(self):
        assert parkinson_vol([], [], annualize=False) == 0.0

    def test_equal_high_low(self):
        assert parkinson_vol([1.0, 1.0], [1.0, 1.0], annualize=False) == 0.0

    def test_mismatched_lengths(self):
        """Uses min length."""
        vol = parkinson_vol([1.05, 1.06], [0.95], annualize=False)
        assert vol > 0.0

    def test_invalid_values(self):
        """Negative/zero values are skipped."""
        vol = parkinson_vol([1.05, -1.0], [0.95, 0.94], annualize=False)
        assert vol >= 0.0


# ===========================================================================
# Rust function tests: garman_klass_vol
# ===========================================================================


class TestGarmanKlassVol:
    def test_basic_positive(self):
        vol = garman_klass_vol(OPENS, HIGHS, LOWS, CLOSES, annualize=False)
        assert vol > 0.0

    def test_annualized(self):
        raw = garman_klass_vol(OPENS, HIGHS, LOWS, CLOSES, annualize=False)
        ann = garman_klass_vol(OPENS, HIGHS, LOWS, CLOSES, annualize=True)
        assert ann == pytest.approx(raw * SQRT_365, rel=1e-10)

    def test_empty(self):
        assert garman_klass_vol([], [], [], [], annualize=False) == 0.0

    def test_single_bar(self):
        vol = garman_klass_vol([1.0], [1.05], [0.95], [1.02], annualize=False)
        assert vol > 0.0


# ===========================================================================
# Rust function tests: yang_zhang_vol
# ===========================================================================


class TestYangZhangVol:
    def test_basic_positive(self):
        vol = yang_zhang_vol(OPENS, HIGHS, LOWS, CLOSES, annualize=False)
        assert vol > 0.0

    def test_annualized(self):
        raw = yang_zhang_vol(OPENS, HIGHS, LOWS, CLOSES, annualize=False)
        ann = yang_zhang_vol(OPENS, HIGHS, LOWS, CLOSES, annualize=True)
        assert ann == pytest.approx(raw * SQRT_365, rel=1e-10)

    def test_too_few_bars(self):
        """Need at least 2 bars for overnight returns."""
        assert yang_zhang_vol([1.0], [1.05], [0.95], [1.02], annualize=False) == 0.0

    def test_empty(self):
        assert yang_zhang_vol([], [], [], [], annualize=False) == 0.0

    def test_two_bars(self):
        vol = yang_zhang_vol(
            [1.0, 1.01],
            [1.05, 1.06],
            [0.95, 0.94],
            [1.02, 0.98],
            annualize=False,
        )
        assert vol > 0.0


# ===========================================================================
# Rust function tests: ewma_vol
# ===========================================================================


class TestEwmaVol:
    def test_basic_positive(self):
        vol = ewma_vol(PRICES, span=5, annualize=False)
        assert vol > 0.0

    def test_annualized(self):
        raw = ewma_vol(PRICES, span=5, annualize=False)
        ann = ewma_vol(PRICES, span=5, annualize=True)
        assert ann == pytest.approx(raw * SQRT_365, rel=1e-10)

    def test_constant_prices(self):
        assert ewma_vol([1.0, 1.0, 1.0, 1.0], span=3, annualize=False) == 0.0

    def test_too_few(self):
        assert ewma_vol([1.0], span=5, annualize=False) == 0.0

    def test_zero_span(self):
        assert ewma_vol(PRICES, span=0, annualize=False) == 0.0

    def test_default_span(self):
        vol = ewma_vol(PRICES)
        assert vol > 0.0

    def test_higher_span_smoother(self):
        """Higher span should generally be smoother (less reactive)."""
        vol_low = ewma_vol(PRICES, span=3, annualize=False)
        vol_high = ewma_vol(PRICES, span=50, annualize=False)
        # Both should be positive, but they may differ
        assert vol_low > 0.0
        assert vol_high > 0.0


# ===========================================================================
# Rust function tests: rolling_vol
# ===========================================================================


class TestRollingVol:
    def test_basic_series(self):
        vols = rolling_vol(PRICES, window=3, annualize=False)
        assert len(vols) > 0
        for v in vols:
            assert v >= 0.0

    def test_annualized(self):
        raw = rolling_vol(PRICES, window=3, annualize=False)
        ann = rolling_vol(PRICES, window=3, annualize=True)
        for r, a in zip(raw, ann):
            assert a == pytest.approx(r * SQRT_365, rel=1e-10)

    def test_too_few(self):
        assert rolling_vol([1.0, 1.01], window=5, annualize=False) == []

    def test_constant_prices(self):
        vols = rolling_vol([1.0, 1.0, 1.0, 1.0, 1.0], window=3, annualize=False)
        for v in vols:
            assert abs(v) < 1e-14

    def test_output_length(self):
        """Output length = len(returns) - window + 1 = (len(prices)-1) - window + 1."""
        vols = rolling_vol(PRICES, window=3, annualize=False)
        expected_len = len(PRICES) - 1 - 3 + 1  # 10-1-3+1 = 7
        assert len(vols) == expected_len

    def test_small_window(self):
        vols = rolling_vol(PRICES, window=2, annualize=False)
        assert len(vols) > 0


# ===========================================================================
# Python: VolatilitySnapshot
# ===========================================================================


class TestVolatilitySnapshot:
    def test_default_zeros(self):
        snap = VolatilitySnapshot()
        assert snap.realized == 0.0
        assert snap.best == 0.0

    def test_best_preference_order(self):
        """yang_zhang > garman_klass > parkinson > ewma > realized."""
        snap = VolatilitySnapshot(realized=0.01, ewma=0.02, parkinson=0.03)
        assert snap.best == 0.03  # parkinson is highest priority with nonzero

        snap2 = VolatilitySnapshot(realized=0.01, yang_zhang=0.05)
        assert snap2.best == 0.05

    def test_best_skips_zero(self):
        snap = VolatilitySnapshot(yang_zhang=0.0, garman_klass=0.0, realized=0.01)
        assert snap.best == 0.01

    def test_frozen(self):
        snap = VolatilitySnapshot(realized=0.01)
        with pytest.raises(AttributeError):
            snap.realized = 0.02  # type: ignore

    def test_as_dict(self):
        snap = VolatilitySnapshot(realized=0.01, ewma=0.02)
        d = snap.as_dict()
        assert d["realized"] == 0.01
        assert d["ewma"] == 0.02
        assert "best" in d


# ===========================================================================
# Python: volatility pipeline (unit test without full engine)
# ===========================================================================


class TestVolatilityPipeline:
    """Test the volatility pipeline factory returns a callable with correct name."""

    def test_returns_callable(self):
        fn = volatility("test_feed")
        assert callable(fn)
        assert fn.__name__ == "volatility"


# ===========================================================================
# Cross-estimator consistency
# ===========================================================================


class TestCrossEstimator:
    """Sanity checks that different estimators produce reasonable relative values."""

    def test_all_positive_on_volatile_data(self):
        vol_realized = estimate_volatility(PRICES, annualize=False)
        vol_ewma = ewma_vol(PRICES, span=5, annualize=False)
        vol_park = parkinson_vol(HIGHS, LOWS, annualize=False)
        vol_gk = garman_klass_vol(OPENS, HIGHS, LOWS, CLOSES, annualize=False)
        vol_yz = yang_zhang_vol(OPENS, HIGHS, LOWS, CLOSES, annualize=False)

        for name, val in [
            ("realized", vol_realized),
            ("ewma", vol_ewma),
            ("parkinson", vol_park),
            ("garman_klass", vol_gk),
            ("yang_zhang", vol_yz),
        ]:
            assert val > 0.0, f"{name} should be positive, got {val}"

    def test_parkinson_higher_efficiency(self):
        """Parkinson typically has lower variance than close-to-close, so
        on a fixed series they may differ. Just check both are positive."""
        vol_cc = estimate_volatility(PRICES, annualize=False)
        vol_pk = parkinson_vol(HIGHS, LOWS, annualize=False)
        assert vol_cc > 0.0
        assert vol_pk > 0.0
