"""Tests for Phase 3: Multi-Exchange Engine.

Covers: multi-backend engine, exchange-tagged types, cross-exchange
cancel/poll, exchange routing, exposure netting, and Python API.
"""

import pytest

from horizon._horizon import (
    Engine,
    Fill,
    Market,
    OrderRequest,
    OrderSide,
    Quote,
    RiskConfig,
    Side,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def config():
    return RiskConfig(
        max_position_per_market=1000.0,
        max_portfolio_notional=50000.0,
        max_order_size=500.0,
    )


@pytest.fixture
def engine(config):
    return Engine(risk_config=config)


def buy_order(market="mkt_1", price=0.55, size=10.0):
    return OrderRequest(
        market_id=market, side=Side.Yes, order_side=OrderSide.Buy,
        size=size, price=price,
    )


def sell_order(market="mkt_1", price=0.60, size=5.0):
    return OrderRequest(
        market_id=market, side=Side.Yes, order_side=OrderSide.Sell,
        size=size, price=price,
    )


# ---------------------------------------------------------------------------
# Multi-exchange management
# ---------------------------------------------------------------------------

class TestMultiExchangeManagement:
    def test_default_single_paper(self, engine):
        assert engine.exchange_count() == 1
        assert engine.exchange_names() == ["paper"]
        assert engine.exchange_name() == "paper"

    def test_add_second_paper_fails(self, engine):
        with pytest.raises(ValueError, match="paper exchange already registered"):
            engine.add_exchange("paper")

    def test_add_unknown_exchange_fails(self, engine):
        with pytest.raises(ValueError, match="unknown exchange type"):
            engine.add_exchange("binance")

    def test_exchange_count_after_add(self, config):
        # Start with paper, add a second paper would fail, but we can test
        # the count on a freshly created engine
        engine = Engine(risk_config=config)
        assert engine.exchange_count() == 1


# ---------------------------------------------------------------------------
# Exchange-tagged types
# ---------------------------------------------------------------------------

class TestExchangeTagging:
    def test_fill_has_exchange_field(self):
        fill = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.55, size=10.0, exchange="polymarket",
        )
        assert fill.exchange == "polymarket"

    def test_fill_default_exchange_empty(self):
        fill = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.55, size=10.0,
        )
        assert fill.exchange == ""

    def test_position_gets_exchange_from_fill(self, engine):
        fill = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.55, size=10.0, exchange="polymarket",
        )
        engine.process_fill(fill)
        positions = engine.positions()
        assert len(positions) == 1
        assert positions[0].exchange == "polymarket"

    def test_order_gets_exchange_from_paper(self, engine):
        order_id = engine.submit_order(buy_order())
        orders = engine.open_orders()
        assert len(orders) >= 1
        # Paper exchange orders should have exchange="paper"
        paper_orders = [o for o in orders if o.exchange == "paper"]
        assert len(paper_orders) >= 1

    def test_paper_fill_tagged_paper(self, engine):
        engine.submit_order(buy_order(price=0.50, size=10.0))
        engine.tick("mkt_1", 0.50)
        fills = engine.recent_fills()
        assert len(fills) >= 1
        assert fills[0].exchange == "paper"


# ---------------------------------------------------------------------------
# Order routing
# ---------------------------------------------------------------------------

class TestOrderRouting:
    def test_submit_order_default_exchange(self, engine):
        """Orders go to primary exchange when no exchange specified."""
        order_id = engine.submit_order(buy_order())
        assert order_id.startswith("p")

    def test_submit_order_explicit_exchange(self, engine):
        """Orders can be routed to a specific exchange."""
        order_id = engine.submit_order(buy_order(), exchange="paper")
        assert order_id.startswith("p")

    def test_submit_order_unknown_exchange_fails(self, engine):
        """Routing to non-existent exchange raises ValueError."""
        with pytest.raises(ValueError, match="unknown exchange"):
            engine.submit_order(buy_order(), exchange="nonexistent")

    def test_submit_quotes_with_exchange(self, engine):
        """submit_quotes routes to specified exchange."""
        ids = engine.submit_quotes(
            "mkt_1", [Quote(bid=0.48, ask=0.52, size=5.0)],
            Side.Yes, exchange="paper",
        )
        assert len(ids) == 2  # bid + ask

    def test_submit_market_order_with_exchange(self, engine):
        """submit_market_order routes to specified exchange."""
        # Market orders on paper need a tick first for matching
        engine.submit_order(buy_order(price=0.50, size=100.0))
        engine.tick("mkt_1", 0.55)
        # Now submit a market sell
        order_id = engine.submit_market_order(
            "mkt_1", Side.Yes, OrderSide.Sell, 5.0, exchange="paper",
        )
        assert isinstance(order_id, str)

    def test_tick_with_exchange(self, engine):
        """tick() routes to specified exchange."""
        engine.submit_order(buy_order(price=0.50, size=10.0))
        fills = engine.tick("mkt_1", 0.50, exchange="paper")
        assert fills >= 1

    def test_tick_unknown_exchange_returns_zero(self, engine):
        """tick() on unknown exchange returns 0 fills."""
        fills = engine.tick("mkt_1", 0.50, exchange="nonexistent")
        assert fills == 0


# ---------------------------------------------------------------------------
# Cross-exchange cancel
# ---------------------------------------------------------------------------

class TestCrossExchangeCancel:
    def test_cancel_all_across_exchanges(self, engine):
        """cancel_all() cancels orders on ALL exchanges."""
        engine.submit_order(buy_order(price=0.50))
        engine.submit_order(buy_order(price=0.51))
        count = engine.cancel_all()
        assert count == 2

    def test_cancel_market_across_exchanges(self, engine):
        """cancel_market() cancels for a market across all exchanges."""
        engine.submit_order(buy_order(market="mkt_1", price=0.50))
        engine.submit_order(buy_order(market="mkt_2", price=0.51))
        count = engine.cancel_market("mkt_1")
        assert count == 1

    def test_cancel_looks_up_order_exchange(self, engine):
        """cancel() should find the order's exchange automatically."""
        order_id = engine.submit_order(buy_order(price=0.50))
        engine.cancel(order_id)
        # Should not raise


# ---------------------------------------------------------------------------
# Poll fills (cross-exchange)
# ---------------------------------------------------------------------------

class TestCrossExchangePoll:
    def test_poll_fills_skips_paper(self, engine):
        """poll_fills() skips paper exchanges (fills come via tick)."""
        count = engine.poll_fills()
        assert count == 0

    def test_poll_fills_returns_count(self, engine):
        """poll_fills() returns 0 for paper-only engine."""
        count = engine.poll_fills()
        assert count == 0


# ---------------------------------------------------------------------------
# Exposure netting
# ---------------------------------------------------------------------------

class TestExposureNetting:
    def test_set_netting_pair(self, engine):
        engine.set_netting_pair("mkt_poly", "mkt_kalshi")
        pairs = engine.netting_pairs()
        assert len(pairs) == 1
        assert pairs[0] == ("mkt_poly", "mkt_kalshi")

    def test_multiple_netting_pairs(self, engine):
        engine.set_netting_pair("a", "b")
        engine.set_netting_pair("c", "d")
        pairs = engine.netting_pairs()
        assert len(pairs) == 2

    def test_netting_reduces_effective_notional(self, config):
        """With netting pairs, hedged positions allow more risk capacity."""
        engine = Engine(risk_config=config)
        engine.set_netting_pair("mkt_poly", "mkt_kalshi")

        # Build opposing positions in the two markets
        fill_a = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_poly",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.55, size=100.0, exchange="polymarket",
        )
        fill_b = Fill(
            fill_id="f2", order_id="o2", market_id="mkt_kalshi",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.55, size=100.0, exchange="kalshi",
        )
        engine.process_fill(fill_a)
        engine.process_fill(fill_b)

        # Both positions exist
        positions = engine.positions()
        assert len(positions) == 2

        # Should still be able to submit more orders because netting reduces notional
        order = buy_order(market="mkt_other", price=0.50, size=10.0)
        order_id = engine.submit_order(order)
        assert isinstance(order_id, str)


# ---------------------------------------------------------------------------
# Sync positions (exchange routing)
# ---------------------------------------------------------------------------

class TestSyncPositions:
    def test_sync_positions_default(self, engine):
        """sync_positions() with no arg uses primary exchange."""
        count = engine.sync_positions()
        assert count == 0  # Paper returns empty

    def test_sync_positions_explicit(self, engine):
        """sync_positions(exchange='paper') works."""
        count = engine.sync_positions(exchange="paper")
        assert count == 0

    def test_sync_positions_unknown_fails(self, engine):
        """sync_positions on unknown exchange raises."""
        with pytest.raises(ValueError, match="unknown exchange"):
            engine.sync_positions(exchange="nonexistent")


# ---------------------------------------------------------------------------
# Evict stale orders (cross-exchange)
# ---------------------------------------------------------------------------

class TestEvictStaleOrders:
    def test_evict_on_multi_paper(self, engine):
        """evict_stale_orders() processes all paper exchanges."""
        count = engine.evict_stale_orders(0.0)
        assert count >= 0  # No crash


# ---------------------------------------------------------------------------
# Python API (hz.run arguments)
# ---------------------------------------------------------------------------

class TestPythonAPI:
    def test_run_exchange_and_exchanges_mutual_exclusion(self):
        """Cannot specify both exchange= and exchanges=."""
        from horizon.exchanges import Polymarket
        from horizon.strategy import run

        with pytest.raises(ValueError, match="Cannot specify both"):
            run(
                name="test",
                exchange=Polymarket(private_key="0x_test"),
                exchanges=[Polymarket(private_key="0x_test")],
                pipeline=[lambda ctx: None],
                mode="paper",
                db_path=None,
            )

    def test_create_engine_paper_mode(self):
        """Paper mode with exchange list still creates paper engine."""
        from horizon.strategy import _create_engine

        engine = _create_engine([], None, "paper", None)
        assert engine.exchange_name() == "paper"
        assert engine.exchange_count() == 1

    def test_create_engine_empty_list_paper(self):
        """Empty exchange list creates paper engine."""
        from horizon.strategy import _create_engine

        engine = _create_engine([], None, "live", None)
        assert engine.exchange_name() == "paper"

    def test_resolve_markets_multi_kalshi(self):
        """_resolve_markets_multi assigns exchange to Kalshi markets."""
        from horizon.exchanges import Kalshi
        from horizon.strategy import _resolve_markets_multi

        markets = [Market(id="btc-100k", exchange="kalshi")]
        _resolve_markets_multi(markets, [Kalshi(api_key="test_key")])
        assert markets[0].exchange == "kalshi"
        assert markets[0].ticker == "BTC-100K"


# ---------------------------------------------------------------------------
# DB persistence with exchange field
# ---------------------------------------------------------------------------

class TestDBExchangeField:
    def test_fill_exchange_persisted(self, config, tmp_path):
        """Fills with exchange field are persisted correctly."""
        db_path = str(tmp_path / "test.db")
        engine = Engine(risk_config=config, db_path=db_path)

        fill = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.55, size=10.0, exchange="polymarket",
        )
        engine.process_fill(fill)

        # Snapshot and recover
        engine.snapshot_positions()

        engine2 = Engine(risk_config=config, db_path=db_path)
        count = engine2.recover_state()
        assert count >= 1

        positions = engine2.positions()
        assert len(positions) >= 1
        assert positions[0].exchange == "polymarket"

    def test_position_snapshot_with_exchange(self, config, tmp_path):
        """Position snapshots include exchange field."""
        db_path = str(tmp_path / "test.db")
        engine = Engine(risk_config=config, db_path=db_path)

        fill = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.55, size=10.0, exchange="kalshi",
        )
        engine.process_fill(fill)
        engine.snapshot_positions()

        # New engine recovers with exchange field intact
        engine2 = Engine(risk_config=config, db_path=db_path)
        engine2.recover_state()
        positions = engine2.positions()
        assert len(positions) == 1
        assert positions[0].exchange == "kalshi"


# ---------------------------------------------------------------------------
# Start run records all exchanges
# ---------------------------------------------------------------------------

class TestStartRun:
    def test_start_run_records_exchange(self, config, tmp_path):
        """start_run() records exchange names in the strategy run."""
        db_path = str(tmp_path / "test.db")
        engine = Engine(risk_config=config, db_path=db_path)
        engine.start_run("test_strategy")
        run_id = engine.db_run_id()
        assert run_id is not None
        engine.end_run()
