"""Tests for quantitative analytics — Rust functions, pipeline functions, and offline analysis."""

import math
import pytest

from horizon._horizon import (
    CusumDetector,
    OfiTracker,
    PredictionGreeks,
    VpinDetector,
    amihud_ratio,
    benjamini_hochberg,
    bonferroni_threshold,
    cornish_fisher_cvar,
    cornish_fisher_var,
    deflated_sharpe,
    effective_spread,
    hurst_exponent,
    information_coefficient,
    joint_entropy,
    kl_divergence,
    kyles_lambda,
    lob_imbalance,
    mutual_information,
    prediction_greeks,
    realized_spread,
    roll_spread,
    shannon_entropy,
    signal_half_life,
    transfer_entropy,
    variance_ratio,
    weighted_mid,
)
from horizon.quant import (
    change_detector,
    market_efficiency,
    microstructure,
    signal_diagnostics,
    strategy_significance,
    toxic_flow,
)
from horizon.context import Context, FeedData
from horizon._horizon import Market


# ===========================================================================
# Information Theory
# ===========================================================================


class TestShannonEntropy:
    def test_max_entropy(self):
        assert shannon_entropy(0.5) == pytest.approx(1.0, abs=1e-10)

    def test_zero(self):
        assert shannon_entropy(0.0) == 0.0

    def test_one(self):
        assert shannon_entropy(1.0) == 0.0

    def test_low_entropy(self):
        h = shannon_entropy(0.99)
        assert h < 0.1
        assert h >= 0.0

    def test_high_entropy(self):
        h = shannon_entropy(0.5)
        assert h > 0.9

    def test_symmetry(self):
        assert shannon_entropy(0.3) == pytest.approx(shannon_entropy(0.7), abs=1e-10)

    def test_nan(self):
        assert shannon_entropy(float("nan")) == 0.0

    def test_infinity(self):
        assert shannon_entropy(float("inf")) == 0.0

    def test_negative(self):
        assert shannon_entropy(-0.5) == 0.0


class TestJointEntropy:
    def test_uniform_4(self):
        h = joint_entropy([0.25, 0.25, 0.25, 0.25])
        assert h == pytest.approx(2.0, abs=1e-10)

    def test_certain(self):
        assert joint_entropy([1.0]) == pytest.approx(0.0, abs=1e-10)

    def test_binary_uniform(self):
        h = joint_entropy([0.5, 0.5])
        assert h == pytest.approx(1.0, abs=1e-10)

    def test_empty(self):
        assert joint_entropy([]) == 0.0

    def test_skips_zero(self):
        h = joint_entropy([0.0, 0.5, 0.5])
        assert h == pytest.approx(1.0, abs=1e-10)


class TestKLDivergence:
    def test_same_distributions(self):
        kl = kl_divergence([0.5, 0.5], [0.5, 0.5])
        assert kl == pytest.approx(0.0, abs=1e-10)

    def test_different_distributions(self):
        kl = kl_divergence([0.9, 0.1], [0.1, 0.9])
        assert kl > 0.0

    def test_asymmetry(self):
        kl1 = kl_divergence([0.9, 0.1], [0.5, 0.5])
        kl2 = kl_divergence([0.5, 0.5], [0.9, 0.1])
        assert kl1 != pytest.approx(kl2, abs=0.01)

    def test_non_negative(self):
        kl = kl_divergence([0.3, 0.7], [0.6, 0.4])
        assert kl >= 0.0

    def test_length_mismatch(self):
        with pytest.raises(ValueError):
            kl_divergence([0.5], [0.5, 0.5])

    def test_empty(self):
        assert kl_divergence([], []) == 0.0


class TestMutualInformation:
    def test_identical_high_mi(self):
        x = list(range(200))
        mi = mutual_information([float(v) for v in x], [float(v) for v in x], 10)
        assert mi > 0.5

    def test_non_negative(self):
        import math
        x = [math.sin(i * 0.1) for i in range(100)]
        y = [math.cos(i * 0.3 + 2) for i in range(100)]
        mi = mutual_information(x, y, 5)
        assert mi >= 0.0

    def test_length_mismatch(self):
        with pytest.raises(ValueError):
            mutual_information([1.0], [1.0, 2.0], 5)

    def test_short_input(self):
        assert mutual_information([1.0], [1.0], 5) == 0.0


class TestTransferEntropy:
    def test_causal_relationship(self):
        source = [math.sin(i * 0.1) for i in range(100)]
        target = [0.0] + source[:99]
        te = transfer_entropy(source, target, 1, 5)
        assert te >= 0.0

    def test_non_negative(self):
        x = [math.sin(i * 0.1) for i in range(100)]
        y = [math.cos(i * 0.3) for i in range(100)]
        te = transfer_entropy(x, y, 1, 5)
        assert te >= 0.0

    def test_short_input(self):
        assert transfer_entropy([1.0], [2.0], 1, 5) == 0.0

    def test_length_mismatch(self):
        with pytest.raises(ValueError):
            transfer_entropy([1.0, 2.0], [1.0], 1, 5)


# ===========================================================================
# Microstructure
# ===========================================================================


class TestKylesLambda:
    def test_positive_impact(self):
        dp = [0.01, 0.02, -0.01, -0.02, 0.015]
        sv = [100.0, 200.0, -100.0, -200.0, 150.0]
        lam = kyles_lambda(dp, sv)
        assert lam > 0.0

    def test_length_mismatch(self):
        with pytest.raises(ValueError):
            kyles_lambda([0.01], [1.0, 2.0])

    def test_short_input(self):
        assert kyles_lambda([0.01], [100.0]) == 0.0

    def test_zero_volume_variance(self):
        assert kyles_lambda([0.01, 0.02], [1.0, 1.0]) == 0.0


class TestAmihudRatio:
    def test_basic(self):
        r = [0.01, -0.02, 0.03]
        v = [1000.0, 2000.0, 1500.0]
        a = amihud_ratio(r, v)
        assert a > 0.0

    def test_zero_volume(self):
        assert amihud_ratio([0.01], [0.0]) == 0.0

    def test_length_mismatch(self):
        with pytest.raises(ValueError):
            amihud_ratio([0.01], [1.0, 2.0])

    def test_all_zero_volume(self):
        assert amihud_ratio([0.01, 0.02], [0.0, 0.0]) == 0.0


class TestRollSpread:
    def test_alternating_returns(self):
        r = [0.01, -0.01, 0.01, -0.01, 0.01, -0.01]
        s = roll_spread(r)
        assert s > 0.0

    def test_trending_returns(self):
        r = [0.01, 0.01, 0.01, 0.01]
        assert roll_spread(r) == 0.0

    def test_short_input(self):
        assert roll_spread([0.01]) == 0.0

    def test_constant_returns(self):
        assert roll_spread([0.0, 0.0, 0.0]) == 0.0


class TestEffectiveSpread:
    def test_buy(self):
        es = effective_spread(0.52, 0.50, True)
        assert es == pytest.approx(0.04, abs=1e-10)

    def test_sell(self):
        es = effective_spread(0.48, 0.50, False)
        assert es == pytest.approx(0.04, abs=1e-10)

    def test_at_mid(self):
        assert effective_spread(0.50, 0.50, True) == pytest.approx(0.0, abs=1e-10)

    def test_adverse_buy(self):
        # Buy below mid → negative effective spread (maker earning spread)
        es = effective_spread(0.49, 0.50, True)
        assert es < 0.0


class TestRealizedSpread:
    def test_basic_buy(self):
        rs = realized_spread(0.52, 0.51, True)
        assert rs == pytest.approx(0.02, abs=1e-10)

    def test_adverse_selection(self):
        # Buy at 0.52, mid moves to 0.55 → realized spread negative
        rs = realized_spread(0.52, 0.55, True)
        assert rs < 0.0


class TestLobImbalance:
    def test_equal(self):
        imb = lob_imbalance([(0.50, 100.0)], [(0.51, 100.0)], 5)
        assert imb == pytest.approx(0.0, abs=1e-10)

    def test_bid_heavy(self):
        imb = lob_imbalance([(0.50, 200.0)], [(0.51, 100.0)], 5)
        assert imb == pytest.approx(1.0 / 3.0, abs=1e-10)

    def test_ask_heavy(self):
        imb = lob_imbalance([(0.50, 100.0)], [(0.51, 200.0)], 5)
        assert imb == pytest.approx(-1.0 / 3.0, abs=1e-10)

    def test_empty(self):
        assert lob_imbalance([], [], 5) == 0.0

    def test_multi_level(self):
        bids = [(0.50, 100.0), (0.49, 50.0)]
        asks = [(0.51, 80.0), (0.52, 40.0)]
        imb = lob_imbalance(bids, asks, 2)
        # (150 - 120) / 270 ≈ 0.111
        assert abs(imb - 30.0 / 270.0) < 1e-10


class TestWeightedMid:
    def test_equal_qty(self):
        mid = weighted_mid([(0.50, 100.0)], [(0.52, 100.0)])
        assert mid == pytest.approx(0.51, abs=1e-10)

    def test_skewed(self):
        mid = weighted_mid([(0.50, 200.0)], [(0.52, 100.0)])
        expected = (100.0 * 0.50 + 200.0 * 0.52) / 300.0
        assert mid == pytest.approx(expected, abs=1e-10)

    def test_empty_bids(self):
        assert weighted_mid([], [(0.5, 10.0)]) == 0.0

    def test_empty_asks(self):
        assert weighted_mid([(0.5, 10.0)], []) == 0.0

    def test_zero_qty(self):
        mid = weighted_mid([(0.50, 0.0)], [(0.52, 0.0)])
        assert mid == pytest.approx(0.51, abs=1e-10)


# ===========================================================================
# Risk Analytics
# ===========================================================================


class TestCornishFisherVaR:
    def test_basic(self):
        returns = [math.sin(i * 0.123) * 0.01 for i in range(500)]
        v = cornish_fisher_var(returns, 0.95)
        assert math.isfinite(v)

    def test_short_input(self):
        assert cornish_fisher_var([0.01, 0.02], 0.95) == 0.0

    def test_constant_returns(self):
        assert cornish_fisher_var([0.01] * 100, 0.95) == 0.0


class TestCornishFisherCVaR:
    def test_ge_var(self):
        returns = [math.sin(i * 0.1) * 0.02 for i in range(500)]
        v = cornish_fisher_var(returns, 0.95)
        cv = cornish_fisher_cvar(returns, 0.95)
        assert cv >= v - 1e-10

    def test_short_input(self):
        assert cornish_fisher_cvar([0.01, 0.02], 0.95) == 0.0


class TestPredictionGreeks:
    def test_basic(self):
        g = prediction_greeks(0.5, 100.0, True, 24.0, 0.2)
        assert isinstance(g, PredictionGreeks)
        assert g.delta != 0.0
        assert g.gamma >= 0.0
        assert g.theta <= 0.0
        assert g.vega >= 0.0

    def test_yes_no_delta_opposite(self):
        yes = prediction_greeks(0.5, 100.0, True, 24.0, 0.2)
        no = prediction_greeks(0.5, 100.0, False, 24.0, 0.2)
        assert abs(yes.delta + no.delta) < 1e-10

    def test_extreme_probability(self):
        g = prediction_greeks(0.99, 100.0, True, 24.0, 0.2)
        assert math.isfinite(g.delta)
        assert math.isfinite(g.gamma)

    def test_zero_time(self):
        g = prediction_greeks(0.5, 100.0, True, 0.0, 0.2)
        assert math.isfinite(g.delta)

    def test_repr(self):
        g = prediction_greeks(0.5, 100.0, True, 24.0, 0.2)
        assert "PredictionGreeks" in repr(g)


# ===========================================================================
# Signal Analysis
# ===========================================================================


class TestInformationCoefficient:
    def test_perfect(self):
        pred = [0.1, 0.2, 0.3, 0.4, 0.5]
        out = [0.2, 0.4, 0.6, 0.8, 1.0]
        ic = information_coefficient(pred, out)
        assert ic == pytest.approx(1.0, abs=1e-10)

    def test_inverse(self):
        pred = [0.5, 0.4, 0.3, 0.2, 0.1]
        out = [0.2, 0.4, 0.6, 0.8, 1.0]
        ic = information_coefficient(pred, out)
        assert ic == pytest.approx(-1.0, abs=1e-10)

    def test_random(self):
        # Uncorrelated should be near 0
        pred = [math.sin(i * 0.1) for i in range(100)]
        out = [math.cos(i * 0.3 + 2) for i in range(100)]
        ic = information_coefficient(pred, out)
        assert abs(ic) < 0.5

    def test_length_mismatch(self):
        with pytest.raises(ValueError):
            information_coefficient([0.1], [0.1, 0.2])

    def test_short_input(self):
        assert information_coefficient([0.1], [0.2]) == 0.0

    def test_constant(self):
        assert information_coefficient([1.0, 1.0, 1.0], [1.0, 2.0, 3.0]) == 0.0


class TestSignalHalfLife:
    def test_mean_reverting(self):
        series = [1.0]
        for _ in range(200):
            series.append(0.5 * series[-1] + 0.01)
        hl = signal_half_life(series)
        assert hl > 0.0

    def test_short_input(self):
        assert signal_half_life([1.0, 2.0]) == 0.0

    def test_trending(self):
        # Non mean-reverting: phi > 1
        series = [1.0]
        for _ in range(50):
            series.append(1.1 * series[-1])
        assert signal_half_life(series) == 0.0


class TestHurstExponent:
    def test_range(self):
        series = [math.sin(i * 0.1234) for i in range(500)]
        h = hurst_exponent(series)
        assert 0.0 <= h <= 1.0

    def test_short_series(self):
        assert hurst_exponent([1.0] * 10) == 0.5

    def test_trending(self):
        # Strong trend should push H > 0.5
        series = list(range(500))
        h = hurst_exponent([float(x) for x in series])
        assert h > 0.5


class TestVarianceRatio:
    def test_basic(self):
        returns = [math.sin(i * 0.0987) * 0.01 for i in range(1000)]
        vr = variance_ratio(returns, 2)
        assert vr > 0.0

    def test_short_input(self):
        assert variance_ratio([0.01], 2) == 1.0

    def test_period_3(self):
        returns = [0.01 * (-1) ** i for i in range(200)]
        vr = variance_ratio(returns, 3)
        assert math.isfinite(vr)


# ===========================================================================
# Statistical Testing
# ===========================================================================


class TestDeflatedSharpe:
    def test_insignificant(self):
        p = deflated_sharpe(0.5, 100, 100, 0.0, 3.0)
        assert p > 0.05

    def test_strong_signal(self):
        # Very high Sharpe with few trials → should be significant
        p = deflated_sharpe(10.0, 5000, 5, 0.0, 3.0)
        assert p < 0.10

    def test_edge_cases(self):
        assert deflated_sharpe(1.0, 1, 1, 0.0, 3.0) == 1.0
        assert deflated_sharpe(1.0, 100, 0, 0.0, 3.0) == 1.0

    def test_more_trials_higher_pvalue(self):
        p10 = deflated_sharpe(2.0, 500, 10, 0.0, 3.0)
        p100 = deflated_sharpe(2.0, 500, 100, 0.0, 3.0)
        assert p100 >= p10


class TestBonferroniThreshold:
    def test_basic(self):
        assert bonferroni_threshold(0.05, 10) == pytest.approx(0.005, abs=1e-10)

    def test_zero_trials(self):
        assert bonferroni_threshold(0.05, 0) == 0.05

    def test_one_trial(self):
        assert bonferroni_threshold(0.05, 1) == pytest.approx(0.05, abs=1e-10)


class TestBenjaminiHochberg:
    def test_basic(self):
        pvals = [0.001, 0.002, 0.003]
        rejected = benjamini_hochberg(pvals, 0.05)
        assert all(rejected)

    def test_none_significant(self):
        pvals = [0.9, 0.95, 0.99]
        rejected = benjamini_hochberg(pvals, 0.05)
        assert not any(rejected)

    def test_empty(self):
        assert benjamini_hochberg([], 0.05) == []

    def test_mixed(self):
        pvals = [0.01, 0.5, 0.8]
        rejected = benjamini_hochberg(pvals, 0.05)
        assert rejected[0]  # 0.01 should be rejected
        assert not rejected[2]  # 0.8 should not

    def test_single_value(self):
        assert benjamini_hochberg([0.01], 0.05) == [True]
        assert benjamini_hochberg([0.1], 0.05) == [False]


# ===========================================================================
# Streaming Detectors
# ===========================================================================


class TestVpinDetector:
    def test_all_buy(self):
        vpin = VpinDetector(100.0, 5)
        for _ in range(10):
            vpin.update(0.5, 10.0, True)
        assert vpin.current_vpin() == pytest.approx(1.0, abs=1e-10)

    def test_balanced(self):
        vpin = VpinDetector(100.0, 5)
        for _ in range(5):
            vpin.update(0.5, 10.0, True)
            vpin.update(0.5, 10.0, False)
        assert vpin.current_vpin() == pytest.approx(0.0, abs=1e-10)

    def test_reset(self):
        vpin = VpinDetector(100.0, 5)
        for _ in range(10):
            vpin.update(0.5, 10.0, True)
        vpin.reset()
        assert vpin.current_vpin() == 0.0

    def test_multiple_buckets(self):
        vpin = VpinDetector(50.0, 3)
        # Fill 4 buckets (only 3 retained)
        for _ in range(4):
            for _ in range(5):
                vpin.update(0.5, 10.0, True)
        assert vpin.current_vpin() > 0.0

    def test_repr(self):
        vpin = VpinDetector(100.0, 5)
        assert "VpinDetector" in repr(vpin)


class TestCusumDetector:
    def test_no_change(self):
        cusum = CusumDetector(5.0, 0.0)
        triggered = False
        for _ in range(50):
            if cusum.update(1.0):  # Constant value: no change
                triggered = True
        assert not triggered

    def test_shift_detected(self):
        cusum = CusumDetector(3.0, 0.0)
        for _ in range(100):
            cusum.update(1.0)
        triggered = False
        for _ in range(50):
            if cusum.update(10.0):
                triggered = True
                break
        assert triggered

    def test_reset(self):
        cusum = CusumDetector(5.0, 0.0)
        for _ in range(20):
            cusum.update(1.0)
        cusum.reset()
        assert cusum.upper() == 0.0
        assert cusum.lower() == 0.0

    def test_repr(self):
        cusum = CusumDetector(5.0, 0.0)
        assert "CusumDetector" in repr(cusum)


class TestOfiTracker:
    def test_basic(self):
        ofi = OfiTracker()
        d1 = ofi.update(100.0, 100.0)
        assert d1 == 0.0  # First call: no previous
        d2 = ofi.update(120.0, 90.0)
        assert d2 == pytest.approx(30.0, abs=1e-10)

    def test_cumulative(self):
        ofi = OfiTracker()
        ofi.update(100.0, 100.0)
        ofi.update(110.0, 90.0)
        ofi.update(120.0, 80.0)
        # delta1: 10 - (-10) = 20, delta2: 10 - (-10) = 20 → cumulative = 40
        assert ofi.cumulative() == pytest.approx(40.0, abs=1e-10)

    def test_reset(self):
        ofi = OfiTracker()
        ofi.update(100.0, 100.0)
        ofi.update(120.0, 90.0)
        ofi.reset()
        assert ofi.cumulative() == 0.0

    def test_repr(self):
        ofi = OfiTracker()
        assert "OfiTracker" in repr(ofi)


# ===========================================================================
# Pipeline Functions
# ===========================================================================


class TestToxicFlowPipeline:
    def test_returns_dict(self):
        fn = toxic_flow("btc", bucket_volume=100.0, n_buckets=5)
        ctx = Context(
            feeds={"btc": FeedData(price=0.5, last_trade_size=10.0, last_trade_is_buy=True)},
            market=Market(id="mkt1"),
        )
        result = fn(ctx)
        assert "vpin" in result
        assert "flow_toxicity" in result
        assert "toxic_alert" in result

    def test_no_feed(self):
        fn = toxic_flow("btc")
        ctx = Context(feeds={}, market=Market(id="mkt1"))
        result = fn(ctx)
        assert result["vpin"] == 0.0

    def test_per_market_isolation(self):
        fn = toxic_flow("btc", bucket_volume=50.0, n_buckets=2)
        ctx1 = Context(
            feeds={"btc": FeedData(price=0.5, last_trade_size=50.0, last_trade_is_buy=True)},
            market=Market(id="mkt1"),
        )
        ctx2 = Context(
            feeds={"btc": FeedData(price=0.5, last_trade_size=50.0, last_trade_is_buy=False)},
            market=Market(id="mkt2"),
        )
        fn(ctx1)
        fn(ctx2)
        # Different markets should have separate detectors
        r1 = fn(ctx1)
        r2 = fn(ctx2)
        # Both track independently (may differ)
        assert isinstance(r1["vpin"], float)
        assert isinstance(r2["vpin"], float)


class TestMicrostructurePipeline:
    def test_returns_dict(self):
        fn = microstructure("btc", lookback=10)
        ctx = Context(
            feeds={"btc": FeedData(price=0.5, bid=0.49, ask=0.51, volume_24h=1000.0)},
            market=Market(id="mkt1"),
        )
        result = fn(ctx)
        assert "ofi" in result
        assert "kyle_lambda" in result
        assert "amihud" in result
        assert "roll_spread" in result
        assert "market_entropy" in result

    def test_no_feed(self):
        fn = microstructure("btc")
        ctx = Context(feeds={}, market=Market(id="mkt1"))
        result = fn(ctx)
        assert result["ofi"] == 0.0
        assert result["kyle_lambda"] == 0.0

    def test_accumulates_history(self):
        fn = microstructure("btc", lookback=10)
        for i in range(5):
            ctx = Context(
                feeds={"btc": FeedData(
                    price=0.5 + i * 0.01,
                    bid=0.49 + i * 0.01,
                    ask=0.51 + i * 0.01,
                    volume_24h=1000.0,
                )},
                market=Market(id="mkt1"),
            )
            result = fn(ctx)
        assert isinstance(result["market_entropy"], float)


class TestChangeDetectorPipeline:
    def test_returns_dict(self):
        fn = change_detector(threshold=5.0)
        ctx = Context(
            feeds={"btc": FeedData(price=0.5)},
            market=Market(id="mkt1"),
        )
        result = fn(ctx)
        assert "change_detected" in result
        assert "cusum_upper" in result
        assert "cusum_lower" in result

    def test_no_feeds(self):
        fn = change_detector()
        ctx = Context(feeds={}, market=Market(id="mkt1"))
        result = fn(ctx)
        assert result["change_detected"] is False


# ===========================================================================
# Offline Analysis
# ===========================================================================


class TestStrategySignificance:
    def test_with_equity_curve(self):
        eq = [100.0 + i * 0.1 for i in range(100)]
        result = strategy_significance({"equity_curve": eq}, n_trials=10)
        assert "deflated_sharpe_pvalue" in result
        assert "bonferroni_alpha" in result
        assert "is_significant" in result
        assert 0.0 <= result["deflated_sharpe_pvalue"] <= 1.0

    def test_with_returns(self):
        returns = [0.001 * (-1) ** i for i in range(100)]
        result = strategy_significance({"returns": returns}, n_trials=5)
        assert "deflated_sharpe_pvalue" in result

    def test_short_data(self):
        result = strategy_significance({"returns": [0.01]}, n_trials=10)
        assert result["deflated_sharpe_pvalue"] == 1.0
        assert result["is_significant"] is False

    def test_empty_data(self):
        result = strategy_significance({}, n_trials=10)
        assert result["is_significant"] is False


class TestSignalDiagnostics:
    def test_basic(self):
        pred = [0.1, 0.2, 0.3, 0.4, 0.5] * 10
        out = [0.0, 0.0, 0.5, 1.0, 1.0] * 10
        result = signal_diagnostics(pred, out)
        assert "ic" in result
        assert "half_life" in result
        assert "hurst" in result

    def test_short_input(self):
        result = signal_diagnostics([0.5], [1.0])
        assert result["ic"] == 0.0


class TestMarketEfficiency:
    def test_basic(self):
        prices = [0.5 + math.sin(i * 0.01) * 0.1 for i in range(200)]
        result = market_efficiency(prices)
        assert "variance_ratio" in result
        assert "hurst" in result
        assert "is_efficient" in result
        assert isinstance(result["is_efficient"], bool)

    def test_short_input(self):
        result = market_efficiency([0.5, 0.6])
        assert result["is_efficient"] is True
        assert result["variance_ratio"] == 1.0

    def test_trending_inefficient(self):
        # Strong trend: likely inefficient
        prices = [50.0 + i * 0.5 for i in range(200)]
        result = market_efficiency(prices)
        # Trending → Hurst > 0.5
        assert result["hurst"] > 0.4
