"""Tests for production readiness fixes."""

import time

import pytest
from unittest.mock import MagicMock, patch

from horizon._horizon import Engine, Fill, Market, OrderSide, Position, RiskConfig, Side
from horizon.context import FeedData, InventorySnapshot
from horizon.exchanges import Polymarket, Kalshi


class TestMarketResolution:
    """FIX 1: Market metadata resolution."""

    def test_resolve_polymarket_markets_success(self):
        """Mock Gamma API response, verify Market gets token_id."""
        from horizon.strategy import _resolve_polymarket_markets

        market = Market(id="will-btc-hit-100k", slug="will-btc-hit-100k")
        exchange = Polymarket(private_key="0xdead")

        mock_response = MagicMock()
        mock_response.ok = True
        mock_response.json.return_value = [
            {
                "tokens": [
                    {"outcome": "Yes", "token_id": "12345"},
                    {"outcome": "No", "token_id": "67890"},
                ],
                "condition_id": "cond_abc",
                "neg_risk": True,
            }
        ]

        with patch("requests.get", return_value=mock_response):
            _resolve_polymarket_markets([market], exchange)

        assert market.yes_token_id == "12345"
        assert market.no_token_id == "67890"
        assert market.condition_id == "cond_abc"
        assert market.neg_risk is True

    def test_resolve_polymarket_markets_already_resolved(self):
        """Skip resolution if token_id already set."""
        from horizon.strategy import _resolve_polymarket_markets

        market = Market(
            id="test", slug="test", yes_token_id="existing_token"
        )
        exchange = Polymarket(private_key="0xdead")

        with patch("requests.get") as mock_get:
            _resolve_polymarket_markets([market], exchange)
            mock_get.assert_not_called()

    def test_resolve_polymarket_markets_api_error(self):
        """Gracefully handle API errors without crashing."""
        from horizon.strategy import _resolve_polymarket_markets

        market = Market(id="nonexistent", slug="nonexistent")
        exchange = Polymarket(private_key="0xdead")

        mock_response = MagicMock()
        mock_response.ok = False
        mock_response.status_code = 404
        mock_response.text = "not found"

        with patch("requests.get", return_value=mock_response):
            # Should not raise
            _resolve_polymarket_markets([market], exchange)

        assert market.yes_token_id is None

    def test_resolve_polymarket_empty_response(self):
        """Handle empty API response."""
        from horizon.strategy import _resolve_polymarket_markets

        market = Market(id="empty", slug="empty")
        exchange = Polymarket(private_key="0xdead")

        mock_response = MagicMock()
        mock_response.ok = True
        mock_response.json.return_value = []

        with patch("requests.get", return_value=mock_response):
            _resolve_polymarket_markets([market], exchange)

        assert market.yes_token_id is None

    def test_resolve_kalshi_markets(self):
        """Verify Kalshi market resolution sets ticker."""
        from horizon.strategy import _resolve_kalshi_markets

        market = Market(id="kxbtc-100k")
        _resolve_kalshi_markets([market])

        assert market.ticker == "KXBTC-100K"
        assert market.exchange == "kalshi"


class TestCredentialFailure:
    """FIX 2: Credential failure is fatal."""

    def test_credential_failure_raises_runtime_error(self):
        """Verify RuntimeError raised on failed derivation in live mode."""
        from horizon.strategy import run

        exchange = Polymarket(private_key="0xinvalid_key")

        with pytest.raises(RuntimeError, match="Failed to derive Polymarket API credentials"):
            run(
                name="test",
                exchange=exchange,
                markets=["test-market"],
                pipeline=[lambda ctx: None],
                mode="live",
            )

    def test_credential_not_needed_paper_mode(self):
        """Paper mode should not attempt credential derivation."""
        from horizon.strategy import run

        exchange = Polymarket(private_key="0xinvalid_key")

        # Paper mode should not try to derive credentials, so it should proceed
        # We'll patch the loop to prevent hanging
        with patch("horizon.strategy._run_loop") as mock_loop:
            run(
                name="test",
                exchange=exchange,
                markets=["test-market"],
                pipeline=[lambda ctx: None],
                mode="paper",
            )
            # Should reach the run loop without error
            mock_loop.assert_called_once()


class TestDashboardFillPolling:
    """FIX 3: Dashboard fill polling for live exchanges."""

    def test_tick_calls_poll_fills_for_live(self):
        """Verify poll_fills is called in TUI _tick for live exchanges."""
        # We test the logic indirectly: the engine must have poll_fills called
        # when exchange is not paper
        engine = Engine(risk_config=RiskConfig())

        # Paper engine: poll_fills should work but not be required
        count = engine.poll_fills()
        assert count == 0  # Paper has no pending fills


class TestFeedDataCleanup:
    """FIX 4: FeedData no longer has trades/book fields."""

    def test_feeddata_no_trades(self):
        """FeedData should not have trades field."""
        fd = FeedData()
        assert not hasattr(fd, "trades")

    def test_feeddata_no_book(self):
        """FeedData should not have book field."""
        fd = FeedData()
        assert not hasattr(fd, "book")

    def test_feeddata_has_core_fields(self):
        """FeedData should still have price, timestamp, bid, ask."""
        fd = FeedData(price=0.55, bid=0.54, ask=0.56, timestamp=1.0)
        assert fd.price == 0.55
        assert fd.bid == 0.54
        assert fd.ask == 0.56
        assert fd.timestamp == 1.0


class TestPhantomImportRemoval:
    """FIX 12: Remove phantom _derive_key import."""

    def test_no_derive_key_import(self):
        """Verify exchanges.py doesn't try to import _derive_key."""
        import inspect
        import horizon.exchanges as ex

        source = inspect.getsource(ex)
        assert "_derive_key" not in source


# ==================== Round 2 Production Fixes ====================


class TestCancelForMarketPaper:
    """FIX A: cancel_for_market only cancels target market on paper exchange."""

    def test_cancel_for_market_only_cancels_target(self):
        engine = Engine(risk_config=RiskConfig())
        from horizon._horizon import Quote

        # Submit quotes for two markets
        engine.submit_quotes("mkt_A", [Quote(bid=0.40, ask=0.60, size=5.0)], Side.Yes)
        engine.submit_quotes("mkt_B", [Quote(bid=0.45, ask=0.55, size=5.0)], Side.Yes)
        assert engine.status().open_orders == 4  # 2 per market

        # Cancel only mkt_A
        engine.cancel_market("mkt_A")

        # mkt_B orders should still be open
        assert len(engine.open_orders_for_market("mkt_B")) == 2
        assert len(engine.open_orders_for_market("mkt_A")) == 0


class TestInventoryNetRespectsSide:
    """FIX D: InventorySnapshot.net accounts for YES vs NO."""

    def _engine_with_positions(self, fills):
        """Create an engine and process fills to build positions."""
        engine = Engine(risk_config=RiskConfig())
        for fill in fills:
            engine.process_fill(fill)
        return engine

    def _make_fill(self, market_id, side, order_side, size, price):
        return Fill(
            fill_id=f"test_{market_id}_{side}",
            order_id="ord_1",
            market_id=market_id,
            side=side,
            order_side=order_side,
            price=price,
            size=size,
            fee=0.0,
            timestamp=0.0,
        )

    def test_net_yes_only(self):
        engine = Engine(risk_config=RiskConfig())
        fill = self._make_fill("mkt", Side.Yes, OrderSide.Buy, 10.0, 0.5)
        engine.process_fill(fill)
        inv = InventorySnapshot(positions=engine.positions())
        assert abs(inv.net - 10.0) < 1e-10

    def test_net_no_only(self):
        engine = Engine(risk_config=RiskConfig())
        fill = self._make_fill("mkt", Side.No, OrderSide.Buy, 5.0, 0.5)
        engine.process_fill(fill)
        inv = InventorySnapshot(positions=engine.positions())
        assert abs(inv.net - (-5.0)) < 1e-10

    def test_net_yes_minus_no(self):
        engine = Engine(risk_config=RiskConfig())
        engine.process_fill(self._make_fill("mkt", Side.Yes, OrderSide.Buy, 10.0, 0.5))
        engine.process_fill(self._make_fill("mkt", Side.No, OrderSide.Buy, 5.0, 0.5))
        inv = InventorySnapshot(positions=engine.positions())
        assert abs(inv.net - 5.0) < 1e-10


class TestRunInputValidation:
    """FIX J: hz.run() validates pipeline and interval."""

    def test_empty_pipeline_raises(self):
        from horizon.strategy import run
        with pytest.raises(ValueError, match="pipeline must contain at least one function"):
            run(name="test", pipeline=[], markets=["m"])

    def test_negative_interval_raises(self):
        from horizon.strategy import run
        with pytest.raises(ValueError, match="interval must be positive"):
            run(name="test", pipeline=[lambda ctx: None], interval=-1.0)

    def test_zero_interval_raises(self):
        from horizon.strategy import run
        with pytest.raises(ValueError, match="interval must be positive"):
            run(name="test", pipeline=[lambda ctx: None], interval=0)


class TestFeedStaleness:
    """FIX K: FeedData.is_stale() detects stale data."""

    def test_zero_timestamp_is_stale(self):
        fd = FeedData(timestamp=0.0)
        assert fd.is_stale() is True

    def test_fresh_data_not_stale(self):
        fd = FeedData(timestamp=time.time())
        assert fd.is_stale(max_age_secs=30.0) is False

    def test_old_data_is_stale(self):
        fd = FeedData(timestamp=time.time() - 60)
        assert fd.is_stale(max_age_secs=30.0) is True

    def test_custom_max_age(self):
        fd = FeedData(timestamp=time.time() - 5)
        assert fd.is_stale(max_age_secs=10.0) is False
        assert fd.is_stale(max_age_secs=3.0) is True


class TestMarketResolutionValidation:
    """FIX H: Incomplete market resolution raises RuntimeError."""

    def test_missing_token_id_raises(self):
        from horizon.strategy import _resolve_polymarket_markets

        market = Market(id="bad-market", slug="bad-market")
        exchange = Polymarket(private_key="0xdead")

        mock_response = MagicMock()
        mock_response.ok = True
        mock_response.json.return_value = [
            {
                "tokens": [],  # No tokens
                "condition_id": "cond_abc",
            }
        ]

        with patch("requests.get", return_value=mock_response):
            with pytest.raises(RuntimeError, match="Could not resolve token_id"):
                _resolve_polymarket_markets([market], exchange)


class TestVersion:
    """FIX M: __version__ is accessible."""

    def test_version_exists(self):
        import horizon
        assert hasattr(horizon, "__version__")
        assert horizon.__version__ == "0.5.0"

    def test_version_in_all(self):
        import horizon
        assert "__version__" in horizon.__all__


# ==================== Round 3 Production Fixes ====================


class TestOrderManagerLinkage:
    """FIX 1: OrderManager links tracking_id to exchange_id."""

    def test_submit_order_links_ids(self):
        """Submit order, verify open_count decrements after fill."""
        engine = Engine(risk_config=RiskConfig())
        from horizon._horizon import OrderRequest, OrderType, Quote, TimeInForce

        oid = engine.submit_order(OrderRequest(
            market_id="mkt_1", side=Side.Yes, order_side=OrderSide.Buy,
            size=10.0, price=0.55, order_type=OrderType.Limit,
            time_in_force=TimeInForce.GTC, post_only=True,
        ))
        assert engine.status().open_orders == 1

        # Tick at a price that fills the buy (mid below bid)
        engine.tick("mkt_1", 0.50)
        assert engine.status().open_orders == 0

    def test_submit_quotes_decrements_on_fill(self):
        """Submit quotes, verify fills reduce open_count."""
        engine = Engine(risk_config=RiskConfig())
        from horizon._horizon import Quote

        engine.submit_quotes("mkt_1", [Quote(bid=0.40, ask=0.60, size=5.0)], Side.Yes)
        assert engine.status().open_orders == 2

        # Tick at price that fills the bid
        engine.tick("mkt_1", 0.35)
        # At least the bid should fill
        assert engine.status().open_orders < 2


class TestSellOrderPassesRiskNearLimit:
    """FIX 2: Sell orders should pass risk checks near position limit."""

    def test_sell_at_95_pct_exposure_passes(self):
        """A sell at 95/100 exposure should pass (reduces exposure)."""
        engine = Engine(risk_config=RiskConfig(max_position_per_market=100.0))
        from horizon._horizon import OrderRequest, OrderType, TimeInForce

        # Build up a position of 95 contracts
        fill = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.50, size=95.0, fee=0.0, timestamp=0.0,
        )
        engine.process_fill(fill)

        # A sell should pass even at 95 exposure (reduces it to ~85)
        oid = engine.submit_order(OrderRequest(
            market_id="mkt_1", side=Side.Yes, order_side=OrderSide.Sell,
            size=10.0, price=0.55, order_type=OrderType.Limit,
            time_in_force=TimeInForce.GTC, post_only=True,
        ))
        assert oid is not None

    def test_buy_at_95_pct_exposure_blocked(self):
        """A buy of 10 at 95/100 exposure should be blocked."""
        engine = Engine(risk_config=RiskConfig(max_position_per_market=100.0))
        from horizon._horizon import OrderRequest, OrderType, TimeInForce

        fill = Fill(
            fill_id="f1", order_id="o1", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.50, size=95.0, fee=0.0, timestamp=0.0,
        )
        engine.process_fill(fill)

        with pytest.raises((RuntimeError, ValueError), match="[Pp]osition"):
            engine.submit_order(OrderRequest(
                market_id="mkt_1", side=Side.Yes, order_side=OrderSide.Buy,
                size=10.0, price=0.55, order_type=OrderType.Limit,
                time_in_force=TimeInForce.GTC, post_only=True,
            ))


class TestKalshiApiUrl:
    """FIX 3: Kalshi api_url includes /trade-api/v2."""

    def test_default_url_has_path(self):
        kalshi = Kalshi()
        assert "/trade-api/v2" in kalshi.api_url

    def test_env_override_preserved(self):
        import os
        old = os.environ.get("KALSHI_API_URL")
        try:
            os.environ["KALSHI_API_URL"] = "https://custom.example.com/trade-api/v2"
            kalshi = Kalshi()
            assert kalshi.api_url == "https://custom.example.com/trade-api/v2"
        finally:
            if old:
                os.environ["KALSHI_API_URL"] = old
            else:
                os.environ.pop("KALSHI_API_URL", None)


class TestPipelineExceptionContinues:
    """FIX 5: Pipeline exception for one market doesn't crash others."""

    def test_pipeline_error_doesnt_crash(self):
        """Pipeline error for market A shouldn't stop market B."""
        from horizon.strategy import _run_loop

        engine = Engine(risk_config=RiskConfig())
        market_a = Market(id="mkt_a", name="A")
        market_b = Market(id="mkt_b", name="B")

        call_count = {"a": 0, "b": 0}

        def failing_pipeline(ctx):
            if ctx.market.id == "mkt_a":
                call_count["a"] += 1
                raise ZeroDivisionError("boom")
            call_count["b"] += 1
            return None

        # Patch to only run 1 cycle
        import signal
        original_sleep = time.sleep

        cycle = [0]
        def mock_sleep(t):
            cycle[0] += 1
            if cycle[0] >= 1:
                raise KeyboardInterrupt

        with patch("time.sleep", side_effect=mock_sleep):
            with patch("signal.signal"):
                try:
                    _run_loop("test", engine, [market_a, market_b], {}, [failing_pipeline], 1.0, {})
                except KeyboardInterrupt:
                    pass

        # Both markets should have been called even though A raised
        assert call_count["a"] >= 1
        assert call_count["b"] >= 1


class TestProcessResultSingleQuote:
    """FIX 12: _process_result accepts a single Quote."""

    def test_single_quote_accepted(self):
        """A single Quote (not wrapped in list) should be accepted."""
        from horizon._horizon import Quote
        from horizon.strategy import _process_result

        engine = Engine(risk_config=RiskConfig())
        market = Market(id="mkt_1")

        # Submit a single Quote (not a list)
        _process_result(engine, market, Quote(bid=0.40, ask=0.60, size=5.0))

        # Should have placed orders (2 = bid + ask)
        assert engine.status().open_orders == 2


class TestSellLargerThanPositionPnlCapped:
    """FIX 10: PnL capped at actual closeable size."""

    def test_oversized_sell_pnl_not_overstated(self):
        """Sell 15 with position 10: PnL on 10, not 15."""
        engine = Engine(risk_config=RiskConfig())

        # Buy 10
        buy_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, fee=0.0, timestamp=0.0,
        )
        engine.process_fill(buy_fill)

        # Sell 15 at 0.60
        sell_fill = Fill(
            fill_id="f2", order_id="o2", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Sell,
            price=0.60, size=15.0, fee=0.0, timestamp=0.0,
        )
        engine.process_fill(sell_fill)

        # PnL should be on 10 (the actual position), not 15
        # (0.60 - 0.50) * 10.0 = 1.0
        pnl = engine.status().total_realized_pnl
        assert abs(pnl - 1.0) < 1e-10


class TestReconcilePreservesPnl:
    """FIX 11: reconcile() doesn't wipe realized PnL."""

    def test_reconcile_keeps_pnl(self):
        engine = Engine(risk_config=RiskConfig())

        # Build up PnL
        engine.process_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, fee=0.0, timestamp=0.0,
        ))
        engine.process_fill(Fill(
            fill_id="f2", order_id="o2", market_id="mkt_1",
            side=Side.Yes, order_side=OrderSide.Sell,
            price=0.60, size=5.0, fee=0.0, timestamp=0.0,
        ))

        pnl_before = engine.status().total_realized_pnl
        assert pnl_before > 0

        # Paper sync returns empty, which is skipped (preserves local)
        engine.sync_positions()

        # PnL should be unchanged
        assert engine.status().total_realized_pnl == pnl_before


class TestCredentialValidationLive:
    """FIX 17: Missing credentials raise ValueError for live trading."""

    def test_polymarket_no_creds_raises(self):
        """Polymarket with no api_key and no private_key raises."""
        from horizon.strategy import run

        exchange = Polymarket()
        # Clear any env-derived credentials
        exchange.api_key = None
        exchange.private_key = None

        with pytest.raises(ValueError, match="Polymarket requires"):
            run(
                name="test", exchange=exchange,
                markets=["m"], pipeline=[lambda ctx: None], mode="live",
            )

    def test_kalshi_no_creds_raises(self):
        """Kalshi with no api_key and no email/password raises."""
        from horizon.strategy import run

        exchange = Kalshi()
        exchange.api_key = None
        exchange.email = None
        exchange.password = None

        with pytest.raises(ValueError, match="Kalshi requires"):
            run(
                name="test", exchange=exchange,
                markets=["m"], pipeline=[lambda ctx: None], mode="live",
            )


class TestInventoryNetForMarket:
    """FIX 18: InventorySnapshot.net_for_market() per-market netting."""

    def test_net_for_market_single(self):
        engine = Engine(risk_config=RiskConfig())
        engine.process_fill(Fill(
            fill_id="f1", order_id="o1", market_id="mkt_a",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.5, size=10.0, fee=0.0, timestamp=0.0,
        ))
        engine.process_fill(Fill(
            fill_id="f2", order_id="o2", market_id="mkt_b",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.5, size=5.0, fee=0.0, timestamp=0.0,
        ))
        inv = InventorySnapshot(positions=engine.positions())

        assert abs(inv.net_for_market("mkt_a") - 10.0) < 1e-10
        assert abs(inv.net_for_market("mkt_b") - 5.0) < 1e-10
        assert abs(inv.net_for_market("mkt_c")) < 1e-10  # nonexistent

    def test_net_for_market_yes_minus_no(self):
        engine = Engine(risk_config=RiskConfig())
        engine.process_fill(Fill(
            fill_id="f1", order_id="o1", market_id="mkt_a",
            side=Side.Yes, order_side=OrderSide.Buy,
            price=0.5, size=10.0, fee=0.0, timestamp=0.0,
        ))
        engine.process_fill(Fill(
            fill_id="f2", order_id="o2", market_id="mkt_a",
            side=Side.No, order_side=OrderSide.Buy,
            price=0.5, size=3.0, fee=0.0, timestamp=0.0,
        ))
        inv = InventorySnapshot(positions=engine.positions())
        assert abs(inv.net_for_market("mkt_a") - 7.0) < 1e-10
