"""Tests for Phase 5: Feed system overhaul and broken functionality fixes.

Covers:
- 5.1 Fix misleading tests (imbalance_signal with real orderbook, daily_pnl baseline)
- 5.2 New tests for fixed functionality (paper tick per-market feed, arb_scanner engine,
      submit_order with Side.No, fills_this_cycle, backtest volume, book_imbalance in mm)
"""

import os
import time

import pytest

# Save and clear HORIZON_API_KEY to avoid auth validation in Engine tests
_saved_api_key = os.environ.pop("HORIZON_API_KEY", None)

from horizon._horizon import (
    Engine,
    Fill,
    FeedSnapshot,
    FeedMetrics,
    Market,
    OrderRequest,
    OrderSide,
    Quote,
    RiskConfig,
    Side,
)
from horizon.context import Context, FeedData, InventorySnapshot
from horizon.signals import imbalance_signal, flow_signal, Signal
from horizon.mm import market_maker
from horizon.backtest import Tick, _build_timeline
from horizon.strategy import _build_context, _process_result, _run_pipeline


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


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


def _make_engine(**kwargs) -> Engine:
    config = RiskConfig(max_position_per_market=1000.0)
    return Engine(risk_config=config, **kwargs)


def _make_ctx(
    market_id: str = "mkt1",
    feeds: dict | None = None,
    params: dict | None = None,
    market: Market | None = None,
) -> Context:
    if feeds is None:
        feeds = {"feed": FeedData(price=0.5, bid=0.45, ask=0.55, timestamp=1.0)}
    if market is None:
        market = _make_market(market_id)
    return Context(
        feeds=feeds,
        inventory=InventorySnapshot(),
        market=market,
        params=params if params is not None else {},
    )


# ---------------------------------------------------------------------------
# 5.1a: imbalance_signal with real OrderbookSnapshot in ctx.params["orderbooks"]
# ---------------------------------------------------------------------------


class TestImbalanceSignalWithOrderbook:
    def test_l2_path_returns_normalized_value(self):
        """When orderbook is available in params, imbalance_signal should use L2 data."""
        # Create a mock orderbook that has an imbalance() method
        class MockOrderbook:
            def imbalance(self, levels):
                return 0.6  # positive imbalance (more bids)

        sig = imbalance_signal("feed", levels=5)
        ctx = _make_ctx(params={"orderbooks": {"feed": MockOrderbook()}})
        result = sig.fn(ctx)
        # imbalance 0.6 -> (0.6 + 1.0) / 2.0 = 0.8
        assert abs(result - 0.8) < 1e-6

    def test_l2_path_negative_imbalance(self):
        """Negative imbalance (more asks) should produce value < 0.5."""

        class MockOrderbook:
            def imbalance(self, levels):
                return -0.6  # negative imbalance (more asks)

        sig = imbalance_signal("feed", levels=5)
        ctx = _make_ctx(params={"orderbooks": {"feed": MockOrderbook()}})
        result = sig.fn(ctx)
        # imbalance -0.6 -> (-0.6 + 1.0) / 2.0 = 0.2
        assert abs(result - 0.2) < 1e-6

    def test_l2_path_balanced(self):
        """Zero imbalance should produce 0.5."""

        class MockOrderbook:
            def imbalance(self, levels):
                return 0.0

        sig = imbalance_signal("feed", levels=5)
        ctx = _make_ctx(params={"orderbooks": {"feed": MockOrderbook()}})
        result = sig.fn(ctx)
        assert abs(result - 0.5) < 1e-6

    def test_fallback_to_bid_ask_when_orderbook_missing(self):
        """Without orderbook in params, should fall back to bid/ask heuristic."""
        sig = imbalance_signal("feed", levels=5)
        ctx = _make_ctx(params={})  # No orderbooks
        result = sig.fn(ctx)
        assert 0.0 <= result <= 1.0

    def test_fallback_when_orderbook_raises(self):
        """If orderbook.imbalance() raises, should fall back to bid/ask."""

        class BrokenOrderbook:
            def imbalance(self, levels):
                raise RuntimeError("connection lost")

        sig = imbalance_signal("feed", levels=5)
        ctx = _make_ctx(params={"orderbooks": {"feed": BrokenOrderbook()}})
        result = sig.fn(ctx)
        assert 0.0 <= result <= 1.0


# ---------------------------------------------------------------------------
# 5.1b: daily_pnl != total_pnl after baseline set
# ---------------------------------------------------------------------------


class TestDailyPnlBaseline:
    def test_daily_pnl_starts_at_zero(self):
        engine = _make_engine()
        status = engine.status()
        assert abs(status.daily_pnl) < 1e-10

    def test_daily_pnl_after_fill(self):
        """After a fill, daily_pnl should equal total_pnl (baseline is 0)."""
        engine = _make_engine()
        fill = Fill(
            fill_id="f1",
            order_id="o1",
            market_id="mkt_1",
            side=Side.Yes,
            order_side=OrderSide.Buy,
            price=0.50,
            size=10.0,
        )
        engine.process_fill(fill)
        engine.update_mark_price("mkt_1", Side.Yes, 0.60)
        status = engine.status()
        # With baseline=0, daily_pnl should equal total_pnl
        assert abs(status.daily_pnl - status.total_pnl()) < 1e-10

    def test_daily_pnl_after_reset(self):
        """After reset_daily_pnl(), daily_pnl resets to 0."""
        engine = _make_engine()
        fill = Fill(
            fill_id="f1",
            order_id="o1",
            market_id="mkt_1",
            side=Side.Yes,
            order_side=OrderSide.Buy,
            price=0.50,
            size=10.0,
        )
        engine.process_fill(fill)
        engine.update_mark_price("mkt_1", Side.Yes, 0.60)

        # Reset daily baseline to current PnL
        engine.reset_daily_pnl()
        status = engine.status()
        # Daily PnL should be near 0 after reset
        assert abs(status.daily_pnl) < 1e-10

    def test_daily_pnl_diverges_from_total_after_reset(self):
        """After reset, further PnL changes only show in daily, not from epoch."""
        engine = _make_engine()

        # Phase 1: Generate some initial PnL
        fill1 = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Buy, price=0.50, size=10.0,
        )
        engine.process_fill(fill1)
        engine.update_mark_price("mkt_1", Side.Yes, 0.60)

        status1 = engine.status()
        total_before_reset = status1.total_pnl()
        assert total_before_reset > 0

        # Phase 2: Reset daily baseline
        engine.reset_daily_pnl()

        # Phase 3: Mark price moves further
        engine.update_mark_price("mkt_1", Side.Yes, 0.70)
        status2 = engine.status()

        # Total PnL includes all PnL since epoch
        assert status2.total_pnl() > total_before_reset
        # Daily PnL only includes PnL since reset
        assert status2.daily_pnl < status2.total_pnl()
        # Daily PnL should be roughly the incremental gain
        # (0.70 - 0.60) * 10 = 1.0
        assert abs(status2.daily_pnl - 1.0) < 1e-6


# ---------------------------------------------------------------------------
# 5.2a: Paper tick per-market feed selection
# ---------------------------------------------------------------------------


class TestPaperTickPerMarketFeed:
    def test_process_result_uses_market_feed(self):
        """_process_result should prefer a feed matching the market ID."""
        engine = _make_engine()
        market = _make_market("btc")

        # Set up context with multiple feeds, one matching market.id
        feeds = {
            "btc": FeedData(price=0.60, bid=0.58, ask=0.62, timestamp=1.0),
            "eth": FeedData(price=0.40, bid=0.38, ask=0.42, timestamp=1.0),
        }
        ctx = _make_ctx(market_id="btc", feeds=feeds, market=market)

        # Submit a buy order at 0.65 (above btc mid ~0.60, should fill)
        order_id = engine.submit_order(OrderRequest(
            market_id="btc", side=Side.Yes, order_side=OrderSide.Buy,
            size=5.0, price=0.65,
        ))

        quotes = [Quote(bid=0.58, ask=0.62, size=5.0)]
        _process_result(engine, market, quotes, ctx)

    def test_process_result_fallback_to_first_feed(self):
        """When no feed matches market.id, should use first available feed."""
        engine = _make_engine()
        market = _make_market("some_market")

        feeds = {
            "eth": FeedData(price=0.40, bid=0.38, ask=0.42, timestamp=1.0),
        }
        ctx = _make_ctx(market_id="some_market", feeds=feeds, market=market)

        quotes = [Quote(bid=0.38, ask=0.42, size=5.0)]
        # Should not crash
        _process_result(engine, market, quotes, ctx)


# ---------------------------------------------------------------------------
# 5.2b: arb_scanner with engine in ctx.params["engine"]
# ---------------------------------------------------------------------------


class TestArbScannerEngineInjection:
    def test_arb_scanner_receives_engine(self):
        """arb_scanner should find engine in ctx.params['engine']."""
        from horizon.arb import arb_scanner

        engine = _make_engine()
        scanner = arb_scanner(
            market_id="mkt1",
            exchanges=["paper"],
            feed_map={"paper": "feed"},
            min_edge=0.01,
        )

        ctx = _make_ctx(params={"engine": engine})
        # Should run without error, return None (no arb opportunity on paper)
        result = scanner(ctx)
        assert result is None  # No actual arb data

    def test_arb_scanner_without_engine_returns_none(self):
        """Without engine in params, arb_scanner should return None gracefully."""
        from horizon.arb import arb_scanner

        scanner = arb_scanner(
            market_id="mkt1",
            exchanges=["paper"],
            feed_map={"paper": "feed"},
        )

        ctx = _make_ctx(params={})  # No engine
        result = scanner(ctx)
        assert result is None


# ---------------------------------------------------------------------------
# 5.2c: submit_order with Side.No
# ---------------------------------------------------------------------------


class TestSubmitOrderSideNo:
    def test_buy_no_side(self):
        """Engine should accept orders with Side.No."""
        engine = _make_engine()
        req = OrderRequest(
            market_id="mkt_1",
            side=Side.No,
            order_side=OrderSide.Buy,
            size=10.0,
            price=0.40,
        )
        order_id = engine.submit_order(req)
        assert order_id.startswith("p")

    def test_sell_no_side(self):
        """Sell order on No side should work."""
        engine = _make_engine()

        # First build a No position via fill
        fill = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.No, order_side=OrderSide.Buy, price=0.40, size=10.0,
        )
        engine.process_fill(fill)

        # Now sell No
        req = OrderRequest(
            market_id="mkt_1",
            side=Side.No,
            order_side=OrderSide.Sell,
            size=5.0,
            price=0.50,
        )
        order_id = engine.submit_order(req)
        assert order_id.startswith("p")

    def test_position_tracking_no_side(self):
        """Position should track No side correctly."""
        engine = _make_engine()
        fill = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.No, order_side=OrderSide.Buy, price=0.40, size=10.0,
        )
        engine.process_fill(fill)
        positions = engine.positions()
        assert len(positions) == 1
        assert positions[0].side == Side.No
        assert abs(positions[0].size - 10.0) < 1e-10


# ---------------------------------------------------------------------------
# 5.2d: fills_this_cycle population
# ---------------------------------------------------------------------------


class TestFillsThisCycle:
    def test_build_context_populates_fills_this_cycle(self):
        """_build_context should populate fills_this_cycle grouped by market."""
        engine = _make_engine()

        # Create some fills
        fill1 = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_A",
            side=Side.Yes, order_side=OrderSide.Buy, price=0.50, size=5.0,
        )
        fill2 = Fill(
            fill_id="f2", order_id="o2", market_id="mkt_B",
            side=Side.Yes, order_side=OrderSide.Buy, price=0.60, size=3.0,
        )
        fill3 = Fill(
            fill_id="f3", order_id="o3", market_id="mkt_A",
            side=Side.Yes, order_side=OrderSide.Sell, price=0.55, size=2.0,
        )
        engine.process_fill(fill1)
        engine.process_fill(fill2)
        engine.process_fill(fill3)

        feeds = {"feed": "binance_ws"}
        params: dict = {}
        market = _make_market("mkt_A")

        # Start a feed so feed_snapshot works (returns None otherwise, which is fine)
        ctx = _build_context(engine, market, feeds, params)

        assert "fills_this_cycle" in ctx.params
        fills_by_market = ctx.params["fills_this_cycle"]
        assert isinstance(fills_by_market, dict)
        # Should have fills grouped by market_id
        assert "mkt_A" in fills_by_market
        assert "mkt_B" in fills_by_market
        assert len(fills_by_market["mkt_A"]) == 2
        assert len(fills_by_market["mkt_B"]) == 1

    def test_build_context_injects_engine(self):
        """_build_context should inject engine into params."""
        engine = _make_engine()
        feeds = {"feed": "binance_ws"}
        params: dict = {}
        market = _make_market("mkt_A")

        ctx = _build_context(engine, market, feeds, params)
        assert ctx.params.get("engine") is engine

    def test_build_context_injects_orderbooks(self):
        """_build_context should inject orderbook snapshots when available."""
        engine = _make_engine()
        feeds = {"feed": "binance_ws"}
        params: dict = {}
        market = _make_market("mkt_A")

        ctx = _build_context(engine, market, feeds, params)
        # orderbooks may or may not be present depending on whether feeds are started
        # but params dict should exist and not crash
        assert isinstance(ctx.params, dict)


# ---------------------------------------------------------------------------
# 5.2e: Backtest volume propagation through FeedData
# ---------------------------------------------------------------------------


class TestBacktestVolumeProps:
    def test_volume_propagated_in_timeline(self):
        """_build_timeline should propagate tick.volume into FeedData.volume_24h."""
        ticks = [
            Tick(timestamp=1.0, price=0.50, bid=0.48, ask=0.52, volume=1000.0),
            Tick(timestamp=2.0, price=0.55, bid=0.53, ask=0.57, volume=2000.0),
            Tick(timestamp=3.0, price=0.60, bid=0.58, ask=0.62, volume=3000.0),
        ]
        feed_ticks = {"feed": ticks}
        timeline = _build_timeline(feed_ticks)

        assert len(timeline) == 3
        for ts, feed_states in timeline:
            fd = feed_states["feed"]
            assert isinstance(fd, FeedData)
            if ts == 1.0:
                assert abs(fd.volume_24h - 1000.0) < 1e-6
            elif ts == 2.0:
                assert abs(fd.volume_24h - 2000.0) < 1e-6
            elif ts == 3.0:
                assert abs(fd.volume_24h - 3000.0) < 1e-6

    def test_volume_default_zero(self):
        """Ticks without volume should have volume_24h=0."""
        ticks = [Tick(timestamp=1.0, price=0.50, bid=0.48, ask=0.52)]
        feed_ticks = {"feed": ticks}
        timeline = _build_timeline(feed_ticks)

        assert len(timeline) == 1
        fd = timeline[0][1]["feed"]
        assert abs(fd.volume_24h) < 1e-6

    def test_multi_feed_volume_isolation(self):
        """Volume should be isolated per feed in multi-feed timelines."""
        feed_ticks = {
            "btc": [
                Tick(timestamp=1.0, price=42000.0, bid=41999.0, ask=42001.0, volume=500.0),
                Tick(timestamp=2.0, price=42100.0, bid=42099.0, ask=42101.0, volume=600.0),
            ],
            "eth": [
                Tick(timestamp=1.5, price=2000.0, bid=1999.0, ask=2001.0, volume=1000.0),
            ],
        }
        timeline = _build_timeline(feed_ticks)

        # At t=1.5, btc should carry forward its last state (vol=500), eth should be 1000
        ts_1_5 = [t for t in timeline if t[0] == 1.5]
        assert len(ts_1_5) == 1
        btc_fd = ts_1_5[0][1]["btc"]
        eth_fd = ts_1_5[0][1]["eth"]
        assert abs(btc_fd.volume_24h - 500.0) < 1e-6
        assert abs(eth_fd.volume_24h - 1000.0) < 1e-6


# ---------------------------------------------------------------------------
# 5.2f: book_imbalance wired in market maker from orderbook
# ---------------------------------------------------------------------------


class TestMarketMakerBookImbalance:
    def test_mm_uses_orderbook_imbalance(self):
        """Market maker should use L2 orderbook imbalance when available."""

        class MockOrderbook:
            def imbalance(self, levels):
                return 0.8  # strong buy pressure

        mm = market_maker(feed_name="feed", size=5.0)
        ctx = _make_ctx(
            params={"orderbooks": {"feed": MockOrderbook()}},
        )
        quotes = mm(ctx)
        assert len(quotes) >= 1
        for q in quotes:
            assert isinstance(q, Quote)
            assert q.bid < q.ask

    def test_mm_works_without_orderbook(self):
        """Market maker should work fine without orderbook (fallback to 0 imbalance)."""
        mm = market_maker(feed_name="feed", size=5.0)
        ctx = _make_ctx(params={})
        quotes = mm(ctx)
        assert len(quotes) >= 1


# ---------------------------------------------------------------------------
# 5.2g: flow_signal with real trade data
# ---------------------------------------------------------------------------


class TestFlowSignalTradeData:
    def test_volume_weighted_flow_buy(self):
        """flow_signal should use volume-weighted flow when trade data available."""
        sig = flow_signal("feed", window=10)
        # Simulate buy trades
        for _ in range(5):
            ctx = Context(
                feeds={"feed": FeedData(
                    price=0.50, bid=0.48, ask=0.52,
                    last_trade_size=10.0, last_trade_is_buy=True,
                )},
                inventory=InventorySnapshot(),
                market=_make_market("mkt1"),
            )
            val = sig.fn(ctx)
        assert val > 0.5  # Buy pressure

    def test_volume_weighted_flow_sell(self):
        """Sell trades should produce signal < 0.5."""
        sig = flow_signal("feed", window=10)
        for _ in range(5):
            ctx = Context(
                feeds={"feed": FeedData(
                    price=0.50, bid=0.48, ask=0.52,
                    last_trade_size=10.0, last_trade_is_buy=False,
                )},
                inventory=InventorySnapshot(),
                market=_make_market("mkt2"),
            )
            val = sig.fn(ctx)
        assert val < 0.5  # Sell pressure

    def test_fallback_to_tick_rule_without_trade_data(self):
        """Without trade data, flow_signal should use tick-rule heuristic."""
        sig = flow_signal("feed", window=10)
        # Up-ticks with no trade size data
        for price in [0.50, 0.51, 0.52, 0.53, 0.54]:
            ctx = Context(
                feeds={"feed": FeedData(price=price, last_trade_size=0.0)},
                inventory=InventorySnapshot(),
                market=_make_market("mkt3"),
            )
            val = sig.fn(ctx)
        assert val > 0.5  # Up-ticks → buy flow


# ---------------------------------------------------------------------------
# 5.1c + 5.2h: FeedSnapshot new fields
# ---------------------------------------------------------------------------


class TestFeedSnapshotNewFields:
    def test_new_fields_defaults(self):
        """New FeedSnapshot fields should default to sensible values."""
        snap = FeedSnapshot()
        assert abs(snap.last_trade_size) < 1e-10
        assert snap.last_trade_is_buy is False
        assert abs(snap.volume_24h) < 1e-10

    def test_new_fields_set(self):
        """New FeedSnapshot fields should be settable via constructor."""
        snap = FeedSnapshot(
            price=100.0, volume_24h=5000.0,
            last_trade_size=1.5, last_trade_is_buy=True,
        )
        assert abs(snap.last_trade_size - 1.5) < 1e-10
        assert snap.last_trade_is_buy is True
        assert abs(snap.volume_24h - 5000.0) < 1e-10

    def test_repr_includes_volume(self):
        snap = FeedSnapshot(price=100.0, volume_24h=5000.0, source="test")
        r = repr(snap)
        assert "5000" in r


# ---------------------------------------------------------------------------
# 5.2i: FeedMetrics Python access
# ---------------------------------------------------------------------------


class TestFeedMetricsPython:
    def test_feed_metrics_defaults(self):
        """FeedMetrics fields should be accessible from Python."""
        engine = _make_engine()
        engine.start_feed("btc", "binance_ws", symbol="btcusdt")
        time.sleep(0.1)

        m = engine.feed_metrics("btc")
        assert m is not None
        assert isinstance(m.update_count, int)
        assert isinstance(m.error_count, int)
        assert isinstance(m.is_connected, bool)
        assert isinstance(m.reconnect_count, int)
        engine.stop_feeds()

    def test_feed_metrics_none_for_unknown(self):
        """feed_metrics should return None for unknown feeds."""
        engine = _make_engine()
        assert engine.feed_metrics("nonexistent") is None

    def test_all_feed_metrics(self):
        """all_feed_metrics should return dict of metrics."""
        engine = _make_engine()
        engine.start_feed("btc", "binance_ws", symbol="btcusdt")
        time.sleep(0.1)

        all_m = engine.all_feed_metrics()
        assert isinstance(all_m, dict)
        assert "btc" in all_m
        engine.stop_feeds()

    def test_is_feed_running(self):
        """is_feed_running should return True for started feeds."""
        engine = _make_engine()
        engine.start_feed("btc", "binance_ws", symbol="btcusdt")
        time.sleep(0.1)

        assert engine.is_feed_running("btc") is True
        assert engine.is_feed_running("nonexistent") is False
        engine.stop_feeds()

    def test_stop_feed(self):
        """stop_feed should stop a single feed."""
        engine = _make_engine()
        engine.start_feed("btc", "binance_ws", symbol="btcusdt")
        engine.start_feed("eth", "binance_ws", symbol="ethusdt")
        time.sleep(0.1)

        engine.stop_feed("btc")
        time.sleep(0.2)

        # btc should be stopped, eth should still be running
        assert engine.is_feed_running("btc") is False
        assert engine.is_feed_running("eth") is True
        engine.stop_feeds()


# ---------------------------------------------------------------------------
# 5.2j: FeedData new fields
# ---------------------------------------------------------------------------


class TestFeedDataNewFields:
    def test_feeddata_defaults(self):
        fd = FeedData()
        assert abs(fd.volume_24h) < 1e-10
        assert fd.source == ""
        assert abs(fd.last_trade_size) < 1e-10
        assert fd.last_trade_is_buy is False

    def test_feeddata_with_values(self):
        fd = FeedData(
            price=0.50, volume_24h=1000.0, source="binance:btcusdt",
            last_trade_size=2.0, last_trade_is_buy=True,
        )
        assert abs(fd.volume_24h - 1000.0) < 1e-6
        assert fd.source == "binance:btcusdt"
        assert abs(fd.last_trade_size - 2.0) < 1e-6
        assert fd.last_trade_is_buy is True


# ---------------------------------------------------------------------------
# 5.2k: build_context propagates new feed fields
# ---------------------------------------------------------------------------


class TestBuildContextFeedFields:
    def test_volume_and_source_propagated(self):
        """_build_context should propagate volume_24h and source from FeedSnapshot."""
        engine = _make_engine()
        engine.start_feed("btc", "binance_ws", symbol="btcusdt")
        time.sleep(0.1)

        feeds = {"btc": "binance_ws"}
        params: dict = {}
        market = _make_market("mkt1")

        ctx = _build_context(engine, market, feeds, params)
        fd = ctx.feeds.get("btc")
        assert fd is not None
        # These should be float/str, not crash
        assert isinstance(fd.volume_24h, float)
        assert isinstance(fd.source, str)
        assert isinstance(fd.last_trade_size, float)
        assert isinstance(fd.last_trade_is_buy, bool)
        engine.stop_feeds()
