"""Tests for signal combination (Rust functions + Python pipeline)."""

import os
import pytest

os.environ.setdefault("HORIZON_API_KEY", "test-key-123")

from horizon._horizon import combine_signals, ema, zscore, decay_weight, Market
from horizon.context import Context, FeedData, InventorySnapshot
from horizon.signals import (
    Signal,
    signal_combiner,
    price_signal,
    imbalance_signal,
    spread_signal,
    momentum_signal,
    flow_signal,
)


# ---------------------------------------------------------------------------
# Rust function tests: combine_signals
# ---------------------------------------------------------------------------


class TestCombineSignals:
    def test_weighted_avg(self):
        result = combine_signals([(0.6, 1.0), (0.8, 2.0)], "weighted_avg")
        assert abs(result - 2.2 / 3.0) < 1e-10

    def test_weighted_avg_single(self):
        assert abs(combine_signals([(0.5, 1.0)], "weighted_avg") - 0.5) < 1e-10

    def test_empty(self):
        assert combine_signals([], "weighted_avg") == 0.0

    def test_rank_method(self):
        result = combine_signals([(0.2, 1.0), (0.5, 1.0), (0.8, 1.0)], "rank")
        assert abs(result - 0.5) < 1e-10

    def test_zscore_equal_values(self):
        result = combine_signals([(0.5, 1.0), (0.5, 1.0)], "zscore")
        assert abs(result - 0.5) < 1e-10

    def test_zscore_symmetric(self):
        result = combine_signals([(0.3, 1.0), (0.7, 1.0)], "zscore")
        assert abs(result - 0.5) < 1e-10

    def test_default_method(self):
        # Unknown method should default to weighted_avg
        result = combine_signals([(0.6, 1.0), (0.8, 2.0)], "unknown")
        assert abs(result - 2.2 / 3.0) < 1e-10

    def test_zero_weight_filtered(self):
        result = combine_signals([(0.9, 0.0), (0.5, 1.0)], "weighted_avg")
        assert abs(result - 0.5) < 1e-10


# ---------------------------------------------------------------------------
# Rust function tests: ema
# ---------------------------------------------------------------------------


class TestEma:
    def test_basic(self):
        # alpha = 2/(3+1) = 0.5
        # 1.0 -> 0.5*2+0.5*1=1.5 -> 0.5*3+0.5*1.5=2.25 -> 0.5*4+0.5*2.25=3.125
        assert abs(ema([1.0, 2.0, 3.0, 4.0], 3) - 3.125) < 1e-10

    def test_single(self):
        assert abs(ema([5.0], 10) - 5.0) < 1e-10

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

    def test_zero_span(self):
        assert ema([1.0, 2.0], 0) == 0.0


# ---------------------------------------------------------------------------
# Rust function tests: zscore
# ---------------------------------------------------------------------------


class TestZscore:
    def test_basic(self):
        assert abs(zscore(10.0, 5.0, 2.0) - 2.5) < 1e-10

    def test_zero_std(self):
        assert zscore(10.0, 5.0, 0.0) == 0.0

    def test_nan(self):
        assert zscore(float("nan"), 5.0, 2.0) == 0.0


# ---------------------------------------------------------------------------
# Rust function tests: decay_weight
# ---------------------------------------------------------------------------


class TestDecayWeight:
    def test_zero_age(self):
        assert abs(decay_weight(0.0, 60.0) - 1.0) < 1e-10

    def test_one_half_life(self):
        assert abs(decay_weight(60.0, 60.0) - 0.5) < 1e-10

    def test_two_half_lives(self):
        assert abs(decay_weight(120.0, 60.0) - 0.25) < 1e-10

    def test_negative_age(self):
        assert decay_weight(-1.0, 60.0) == 0.0

    def test_zero_half_life(self):
        assert decay_weight(10.0, 0.0) == 0.0


# ---------------------------------------------------------------------------
# Python pipeline tests: signal_combiner
# ---------------------------------------------------------------------------


def _make_ctx(**feeds):
    return Context(
        feeds={k: FeedData(**v) for k, v in feeds.items()},
        inventory=InventorySnapshot(),
    )


class TestSignalCombiner:
    def test_basic_combination(self):
        sig1 = Signal(name="s1", fn=lambda ctx: 0.6, weight=1.0)
        sig2 = Signal(name="s2", fn=lambda ctx: 0.8, weight=2.0)
        combiner = signal_combiner([sig1, sig2])
        ctx = _make_ctx()
        result = combiner(ctx)
        assert abs(result - 2.2 / 3.0) < 1e-6

    def test_clip(self):
        sig = Signal(name="s", fn=lambda ctx: 1.5, weight=1.0)
        combiner = signal_combiner([sig], clip=(0.0, 1.0))
        result = combiner(_make_ctx())
        assert result <= 1.0

    def test_clip_low(self):
        sig = Signal(name="s", fn=lambda ctx: -0.5, weight=1.0)
        combiner = signal_combiner([sig], clip=(0.0, 1.0))
        result = combiner(_make_ctx())
        assert result >= 0.0

    def test_error_in_signal_skipped(self):
        def bad_signal(ctx):
            raise ValueError("oops")

        sig_ok = Signal(name="ok", fn=lambda ctx: 0.7, weight=1.0)
        sig_bad = Signal(name="bad", fn=bad_signal, weight=1.0)
        combiner = signal_combiner([sig_ok, sig_bad])
        result = combiner(_make_ctx())
        assert abs(result - 0.7) < 1e-6

    def test_empty_signals(self):
        combiner = signal_combiner([])
        assert combiner(_make_ctx()) == 0.0

    def test_smoothing(self):
        counter = [0]

        def incrementing(ctx):
            counter[0] += 1
            return counter[0] * 0.1

        sig = Signal(name="s", fn=incrementing, weight=1.0)
        combiner = signal_combiner([sig], smoothing=5)
        ctx = _make_ctx()
        results = [combiner(ctx) for _ in range(5)]
        # With smoothing, values should be dampened
        assert results[-1] > 0


# ---------------------------------------------------------------------------
# Built-in signal extractors
# ---------------------------------------------------------------------------


class TestBuiltInSignals:
    def test_price_signal(self):
        sig = price_signal("btc", weight=1.0)
        ctx = _make_ctx(btc={"bid": 0.45, "ask": 0.55})
        assert abs(sig.fn(ctx) - 0.50) < 1e-6

    def test_price_signal_missing_feed(self):
        sig = price_signal("missing", weight=1.0)
        assert sig.fn(_make_ctx()) == 0.0

    def test_spread_signal(self):
        sig = spread_signal("feed", weight=1.0)
        # Tight spread (0.02) → high signal
        ctx = _make_ctx(feed={"bid": 0.49, "ask": 0.51})
        result = sig.fn(ctx)
        assert result > 0.5

    def test_spread_signal_wide(self):
        sig = spread_signal("feed", weight=1.0)
        # Wide spread (0.20) → low signal
        ctx = _make_ctx(feed={"bid": 0.40, "ask": 0.60})
        result = sig.fn(ctx)
        assert result < 0.5

    def test_momentum_signal_no_data(self):
        sig = momentum_signal("feed", lookback=20)
        ctx = _make_ctx()
        assert abs(sig.fn(ctx) - 0.5) < 1e-6

    def test_momentum_signal_uptrend(self):
        sig = momentum_signal("feed", lookback=10)
        for price in [0.40, 0.42, 0.44, 0.46, 0.48]:
            ctx = _make_ctx(feed={"price": price})
            val = sig.fn(ctx)
        assert val > 0.5

    def test_flow_signal_initial(self):
        sig = flow_signal("feed", window=50)
        ctx = _make_ctx()
        assert abs(sig.fn(ctx) - 0.5) < 1e-6

    def test_flow_signal_buy_flow(self):
        sig = flow_signal("feed", window=10)
        # Consecutive up-ticks
        for price in [0.50, 0.51, 0.52, 0.53, 0.54]:
            ctx = _make_ctx(feed={"price": price})
            val = sig.fn(ctx)
        assert val > 0.5

    def test_imbalance_signal_no_data(self):
        sig = imbalance_signal("feed", levels=5)
        assert abs(sig.fn(_make_ctx()) - 0.5) < 1e-6

    def test_imbalance_signal_with_bid_ask(self):
        sig = imbalance_signal("feed", levels=5)
        ctx = _make_ctx(feed={"bid": 0.48, "ask": 0.52})
        result = sig.fn(ctx)
        assert 0.0 <= result <= 1.0


# ---------------------------------------------------------------------------
# Per-market isolation tests (5B fix verification)
# ---------------------------------------------------------------------------


def _make_market(market_id: str) -> Market:
    return Market(id=market_id, name=market_id, slug=market_id, exchange="paper")


def _make_ctx_with_market(market_id: str, **feeds):
    return Context(
        feeds={k: FeedData(**v) for k, v in feeds.items()},
        inventory=InventorySnapshot(),
        market=_make_market(market_id),
    )


class TestPerMarketIsolation:
    def test_momentum_separate_histories(self):
        """momentum_signal should maintain separate history per market."""
        sig = momentum_signal("feed", lookback=10)

        # Feed market A with uptrend
        for price in [0.40, 0.42, 0.44, 0.46, 0.48]:
            ctx = _make_ctx_with_market("mkt_A", feed={"price": price})
            val_a = sig.fn(ctx)

        # Feed market B with downtrend
        for price in [0.60, 0.58, 0.56, 0.54, 0.52]:
            ctx = _make_ctx_with_market("mkt_B", feed={"price": price})
            val_b = sig.fn(ctx)

        # Market A should show uptrend, B should show downtrend
        assert val_a > 0.5, f"Market A uptrend signal={val_a}, expected >0.5"
        assert val_b < 0.5, f"Market B downtrend signal={val_b}, expected <0.5"

        # Re-query market A - should still reflect uptrend, not contaminated by B
        ctx_a = _make_ctx_with_market("mkt_A", feed={"price": 0.50})
        val_a_check = sig.fn(ctx_a)
        assert val_a_check > 0.5, f"Market A should still be uptrend after B queries, got {val_a_check}"

    def test_flow_separate_histories(self):
        """flow_signal should maintain separate tick history per market."""
        sig = flow_signal("feed", window=10)

        # Feed market A with buy flow (up-ticks)
        for price in [0.50, 0.51, 0.52, 0.53, 0.54]:
            ctx = _make_ctx_with_market("mkt_A", feed={"price": price})
            val_a = sig.fn(ctx)

        # Feed market B with sell flow (down-ticks)
        for price in [0.50, 0.49, 0.48, 0.47, 0.46]:
            ctx = _make_ctx_with_market("mkt_B", feed={"price": price})
            val_b = sig.fn(ctx)

        assert val_a > 0.5, f"Market A buy flow={val_a}, expected >0.5"
        assert val_b < 0.5, f"Market B sell flow={val_b}, expected <0.5"

    def test_combiner_smoothing_separate_histories(self):
        """signal_combiner EMA smoothing should be per-market."""
        counter = {"mkt_A": 0, "mkt_B": 0}

        def sig_fn(ctx):
            mid = ctx.market.id if ctx.market else "__default__"
            counter[mid] = counter.get(mid, 0) + 1
            # A goes up, B stays flat
            if mid == "mkt_A":
                return counter[mid] * 0.1
            return 0.5

        sig = Signal(name="test", fn=sig_fn, weight=1.0)
        combiner = signal_combiner([sig], smoothing=5)

        # Run A several times (increasing)
        for _ in range(5):
            ctx = _make_ctx_with_market("mkt_A")
            val_a = combiner(ctx)

        # Run B several times (flat)
        for _ in range(5):
            ctx = _make_ctx_with_market("mkt_B")
            val_b = combiner(ctx)

        # A's smoothed value should differ from B's since trends differ
        assert val_a != val_b, "Combiner smoothing should be separate per market"
