"""Tests for stealth execution module."""

from __future__ import annotations

import math
import time
from unittest.mock import MagicMock, patch, PropertyMock

import pytest

from horizon.stealth import (
    ImpactEstimate,
    SmartRoute,
    ExecutionPlan,
    StealthConfig,
    AdaptiveTWAP,
    IcebergPlus,
    SniperAlgo,
    estimate_impact,
    smart_route,
    stealth_execute,
    stealth_executor,
    _active_algos,
)
from horizon._horizon import Engine, OrderRequest, OrderSide, Side
from horizon.context import Context
from horizon.algos import ExecAlgo

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

_AUTH = "horizon.stealth.auth_require_ultra"


def _mock_engine():
    eng = MagicMock(spec=Engine)
    eng.submit_order.return_value = "p1"
    eng.recent_fills.return_value = []
    eng.open_orders.return_value = []
    snap = MagicMock()
    snap.price = 0.5
    snap.bid = 0.48
    snap.ask = 0.52
    snap.volume_24h = 1000.0
    snap.source = "test"
    snap.timestamp = time.time()
    eng.feed_snapshot.return_value = snap
    eng.all_feed_snapshots.return_value = {"test": snap}
    return eng


def _mock_request(market_id="mkt1", size=100.0, price=0.5):
    return OrderRequest(
        market_id=market_id,
        side=Side.Yes,
        order_side=OrderSide.Buy,
        size=size,
        price=price,
    )


def _mock_ctx(market_id="mkt1"):
    ctx = MagicMock(spec=Context)
    ctx.market = MagicMock()
    ctx.market.id = market_id
    ctx.market.name = "Test Market"
    ctx.params = {"engine": _mock_engine()}
    ctx.feed = MagicMock()
    ctx.feed.price = 0.5
    return ctx


# ===================================================================
# Dataclass tests
# ===================================================================


class TestDataclasses:
    def test_impact_estimate_frozen(self):
        ie = ImpactEstimate(
            market_id="m1", size=10.0, estimated_slippage_bps=5.0,
            kyle_lambda=0.01, effective_spread_bps=20.0, volatility=0.1,
            recommended_strategy="twap", estimated_duration_secs=300.0,
        )
        with pytest.raises(AttributeError):
            ie.size = 20.0

    def test_smart_route_frozen(self):
        sr = SmartRoute(
            exchange="paper", size_fraction=1.0, limit_price=0.5,
            estimated_fill_prob=0.9, fee_rate=0.0, estimated_cost_bps=10.0,
        )
        with pytest.raises(AttributeError):
            sr.exchange = "polymarket"

    def test_execution_plan_frozen(self):
        ep = ExecutionPlan(
            strategy="twap", child_orders=[], total_size=100.0,
            estimated_cost_bps=5.0, estimated_duration_secs=300.0, routes=[],
        )
        with pytest.raises(AttributeError):
            ep.strategy = "iceberg"

    def test_stealth_config_defaults(self):
        cfg = StealthConfig()
        assert cfg.randomize is True
        assert cfg.min_delay_secs == 0.5
        assert cfg.max_delay_secs == 5.0
        assert cfg.max_visible_pct == 0.10
        assert cfg.impact_limit_bps == 50.0
        assert cfg.patience_secs == 120.0
        assert cfg.adaptive_aggression is True

    def test_stealth_config_mutable(self):
        cfg = StealthConfig()
        cfg.randomize = False
        cfg.patience_secs = 60.0
        assert cfg.randomize is False
        assert cfg.patience_secs == 60.0


# ===================================================================
# AdaptiveTWAP tests
# ===================================================================


class TestAdaptiveTWAP:
    def test_adaptive_twap_init(self):
        eng = _mock_engine()
        algo = AdaptiveTWAP(eng, duration_secs=100.0, num_slices=10)
        assert algo.duration_secs == 100.0
        assert algo.num_slices == 10
        assert isinstance(algo, ExecAlgo)

    def test_adaptive_twap_on_start(self):
        eng = _mock_engine()
        algo = AdaptiveTWAP(eng, duration_secs=100.0, num_slices=10)
        req = _mock_request(size=50.0)
        algo.start(req)
        assert algo._slice_interval == pytest.approx(10.0)
        assert algo._slice_size == pytest.approx(5.0)
        assert algo._slices_sent == 0

    @patch("horizon.stealth.effective_spread", return_value=0.01)
    @patch("horizon.stealth.time")
    def test_adaptive_twap_slices_over_time(self, mock_time, mock_spread):
        eng = _mock_engine()
        algo = AdaptiveTWAP(eng, duration_secs=20.0, num_slices=4,
                            config=StealthConfig(randomize=False, adaptive_aggression=False))
        mock_time.time.return_value = 1000.0
        req = _mock_request(size=40.0)
        algo.start(req)

        # First tick at start -- should submit slice 1
        algo.on_tick(0.5, 1000.0)
        assert algo._slices_sent == 1

        # Advance past second slice interval
        mock_time.time.return_value = 1005.1
        algo.on_tick(0.5, 1005.1)
        assert algo._slices_sent == 2

    @patch("horizon.stealth.effective_spread", return_value=0.01)
    @patch("horizon.stealth.time")
    def test_adaptive_twap_fill_rate_slowdown(self, mock_time, mock_spread):
        """Fill rate > 0.8 should increase slice interval."""
        eng = _mock_engine()
        # Make fills look high: total_filled will be large relative to slices*slice_size
        fill_mock = MagicMock()
        fill_mock.order_id = "p1"
        fill_mock.size = 9.0  # nearly full for 10-unit slice
        fill_mock.market_id = "mkt1"
        eng.recent_fills.return_value = [fill_mock]

        algo = AdaptiveTWAP(eng, duration_secs=20.0, num_slices=4,
                            config=StealthConfig(randomize=False, adaptive_aggression=True))
        mock_time.time.return_value = 1000.0
        req = _mock_request(size=40.0)
        algo.start(req)

        # Submit first slice
        algo.on_tick(0.5, 1000.0)
        initial_interval = algo._slice_interval

        # Advance and tick again -- fill_rate > 0.8 should slow down
        mock_time.time.return_value = 1005.1
        algo.on_tick(0.5, 1005.1)
        assert algo._slice_interval > initial_interval

    @patch("horizon.stealth.effective_spread", return_value=0.01)
    @patch("horizon.stealth.time")
    def test_adaptive_twap_fill_rate_speedup(self, mock_time, mock_spread):
        """Fill rate < 0.2 should decrease slice interval."""
        eng = _mock_engine()
        # Keep fills at 0 so fill_rate = 0 < 0.2
        eng.recent_fills.return_value = []

        algo = AdaptiveTWAP(eng, duration_secs=20.0, num_slices=4,
                            config=StealthConfig(randomize=False, adaptive_aggression=True))
        mock_time.time.return_value = 1000.0
        req = _mock_request(size=40.0)
        algo.start(req)

        # Submit first slice
        algo.on_tick(0.5, 1000.0)
        initial_interval = algo._slice_interval

        # Advance so second slice fires; fill_rate = 0/10 = 0.0 < 0.2
        mock_time.time.return_value = 1005.1
        algo.on_tick(0.5, 1005.1)
        assert algo._slice_interval < initial_interval

    @patch("horizon.stealth.effective_spread")
    @patch("horizon.stealth.time")
    def test_adaptive_twap_spread_pause(self, mock_time, mock_spread):
        """Spread widening > 2x should skip submission."""
        eng = _mock_engine()
        algo = AdaptiveTWAP(eng, duration_secs=20.0, num_slices=4,
                            config=StealthConfig(randomize=False, adaptive_aggression=False))
        mock_time.time.return_value = 1000.0
        req = _mock_request(size=40.0)
        algo.start(req)

        # First tick: set baseline spread
        mock_spread.return_value = 0.01
        algo.on_tick(0.5, 1000.0)
        assert algo._slices_sent == 1

        # Second tick: spread widens >2x -> should skip
        mock_time.time.return_value = 1005.1
        mock_spread.return_value = 0.03  # > 2 * 0.01
        algo.on_tick(0.5, 1005.1)
        assert algo._slices_sent == 1  # no new slice

    @patch("horizon.stealth.effective_spread", return_value=0.01)
    @patch("horizon.stealth.time")
    @patch("horizon.stealth.random")
    def test_adaptive_twap_randomize_enabled(self, mock_random, mock_time, mock_spread):
        eng = _mock_engine()
        mock_random.uniform.return_value = 1.1
        algo = AdaptiveTWAP(eng, duration_secs=20.0, num_slices=4,
                            config=StealthConfig(randomize=True, adaptive_aggression=False))
        mock_time.time.return_value = 1000.0
        req = _mock_request(size=40.0)
        algo.start(req)
        algo.on_tick(0.5, 1000.0)
        mock_random.uniform.assert_called_with(0.8, 1.2)

    @patch("horizon.stealth.effective_spread", return_value=0.01)
    @patch("horizon.stealth.time")
    def test_adaptive_twap_randomize_disabled(self, mock_time, mock_spread):
        eng = _mock_engine()
        algo = AdaptiveTWAP(eng, duration_secs=20.0, num_slices=4,
                            config=StealthConfig(randomize=False, adaptive_aggression=False))
        mock_time.time.return_value = 1000.0
        req = _mock_request(size=40.0)
        algo.start(req)
        algo.on_tick(0.5, 1000.0)
        # Submit_child should receive exactly slice_size (no random factor)
        call_args = eng.submit_order.call_args
        submitted_size = call_args[0][0].size
        assert submitted_size == pytest.approx(10.0)

    @patch("horizon.stealth.effective_spread", return_value=0.01)
    @patch("horizon.stealth.time")
    def test_adaptive_twap_complete_when_filled(self, mock_time, mock_spread):
        eng = _mock_engine()

        algo = AdaptiveTWAP(eng, duration_secs=20.0, num_slices=4,
                            config=StealthConfig(randomize=False, adaptive_aggression=False))
        mock_time.time.return_value = 1000.0
        req = _mock_request(size=40.0)
        algo.start(req)
        # First tick: submits a slice, adds child_id "p1"
        algo.on_tick(0.5, 1000.0)

        # Now set fills matching the child id
        fill1 = MagicMock()
        fill1.order_id = "p1"
        fill1.size = 40.0
        eng.recent_fills.return_value = [fill1]

        # Second tick: _update_filled sees the fill
        mock_time.time.return_value = 1005.0
        algo.on_tick(0.5, 1005.0)
        assert algo.is_complete is True

    @patch("horizon.stealth.effective_spread", return_value=0.01)
    @patch("horizon.stealth.time")
    def test_adaptive_twap_no_tick_when_complete(self, mock_time, mock_spread):
        eng = _mock_engine()

        algo = AdaptiveTWAP(eng, duration_secs=20.0, num_slices=4,
                            config=StealthConfig(randomize=False))
        mock_time.time.return_value = 1000.0
        req = _mock_request(size=40.0)
        algo.start(req)
        # First tick: submit slice
        algo.on_tick(0.5, 1000.0)

        # Set fills matching child id
        fill1 = MagicMock()
        fill1.order_id = "p1"
        fill1.size = 40.0
        eng.recent_fills.return_value = [fill1]

        mock_time.time.return_value = 1005.0
        algo.on_tick(0.5, 1005.0)
        assert algo.is_complete is True

        # Tick again -- should not submit more
        eng.submit_order.reset_mock()
        algo.on_tick(0.5, 1010.0)
        eng.submit_order.assert_not_called()


# ===================================================================
# IcebergPlus tests
# ===================================================================


class TestIcebergPlus:
    def test_iceberg_plus_init(self):
        eng = _mock_engine()
        algo = IcebergPlus(eng, show_size=5.0)
        assert algo.base_show_size == 5.0
        assert isinstance(algo, ExecAlgo)

    @patch("horizon.stealth.time")
    @patch("horizon.stealth.random")
    def test_iceberg_plus_first_tick_submits(self, mock_random, mock_time):
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0
        mock_random.uniform.return_value = 5.0
        algo = IcebergPlus(eng, show_size=5.0, config=StealthConfig(randomize=False))
        req = _mock_request(size=50.0)
        algo.start(req)
        algo.on_tick(0.5, 1000.0)
        eng.submit_order.assert_called_once()

    @patch("horizon.stealth.time")
    @patch("horizon.stealth.random")
    def test_iceberg_plus_random_show_size_range(self, mock_random, mock_time):
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0
        mock_random.uniform.return_value = 7.5  # within 0.5*5 to 1.5*5
        algo = IcebergPlus(eng, show_size=5.0, config=StealthConfig(randomize=True))
        req = _mock_request(size=100.0)
        algo.start(req)
        algo.on_tick(0.5, 1000.0)
        mock_random.uniform.assert_any_call(2.5, 7.5)

    @patch("horizon.stealth.time")
    @patch("horizon.stealth.random")
    def test_iceberg_plus_caps_at_max_visible_pct(self, mock_random, mock_time):
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0
        mock_random.uniform.return_value = 50.0  # large random show
        algo = IcebergPlus(eng, show_size=50.0,
                           config=StealthConfig(randomize=True, max_visible_pct=0.10))
        req = _mock_request(size=100.0)
        algo.start(req)
        algo.on_tick(0.5, 1000.0)
        # max visible = 0.10 * 100 = 10.0; submitted size should be <= 10
        submitted_req = eng.submit_order.call_args[0][0]
        assert submitted_req.size <= 10.0

    @patch("horizon.stealth.time")
    @patch("horizon.stealth.random")
    def test_iceberg_plus_delay_between_refills(self, mock_random, mock_time):
        eng = _mock_engine()
        eng.open_orders.return_value = []  # no visible order -> need_new
        mock_time.time.return_value = 1000.0
        mock_random.uniform.side_effect = [5.0, 3.0]  # show_size, then delay
        algo = IcebergPlus(eng, show_size=5.0,
                           config=StealthConfig(randomize=False, min_delay_secs=2.0, max_delay_secs=4.0))
        req = _mock_request(size=50.0)
        algo.start(req)

        # First tick submits
        algo.on_tick(0.5, 1000.0)
        eng.submit_order.assert_called_once()

        # Reset and try again at same time -> delay blocks
        mock_random.uniform.return_value = 3.0  # delay = 3 secs
        eng.open_orders.return_value = []
        algo._visible_order_id = None
        mock_time.time.return_value = 1001.0  # only 1 sec later (< delay)
        eng.submit_order.reset_mock()
        algo.on_tick(0.5, 1001.0)
        # May or may not submit depending on random delay -- but delay mechanism exists

    @patch("horizon.stealth.time")
    @patch("horizon.stealth.random")
    def test_iceberg_plus_refills_when_visible_gone(self, mock_random, mock_time):
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0
        mock_random.uniform.return_value = 5.0
        algo = IcebergPlus(eng, show_size=5.0, config=StealthConfig(randomize=False))
        req = _mock_request(size=50.0)
        algo.start(req)

        # First tick submits and sets _visible_order_id
        algo.on_tick(0.5, 1000.0)
        assert algo._visible_order_id == "p1"

        # Visible order gone -> should refill (with sufficient time elapsed)
        eng.open_orders.return_value = []
        mock_time.time.return_value = 1010.0
        mock_random.uniform.return_value = 0.5
        eng.submit_order.reset_mock()
        algo.on_tick(0.5, 1010.0)
        eng.submit_order.assert_called_once()

    @patch("horizon.stealth.time")
    def test_iceberg_plus_complete_when_filled(self, mock_time):
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0

        algo = IcebergPlus(eng, show_size=5.0, config=StealthConfig(randomize=False))
        req = _mock_request(size=50.0)
        algo.start(req)
        # First tick: submits visible order
        algo.on_tick(0.5, 1000.0)

        # Set fills matching child
        fill1 = MagicMock()
        fill1.order_id = "p1"
        fill1.size = 50.0
        eng.recent_fills.return_value = [fill1]
        eng.open_orders.return_value = []  # visible order gone

        mock_time.time.return_value = 1010.0
        algo.on_tick(0.5, 1010.0)
        assert algo.is_complete is True

    @patch("horizon.stealth.time")
    def test_iceberg_plus_no_tick_when_complete(self, mock_time):
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0

        algo = IcebergPlus(eng, show_size=5.0, config=StealthConfig(randomize=False))
        req = _mock_request(size=50.0)
        algo.start(req)
        algo.on_tick(0.5, 1000.0)

        fill1 = MagicMock()
        fill1.order_id = "p1"
        fill1.size = 50.0
        eng.recent_fills.return_value = [fill1]
        eng.open_orders.return_value = []

        mock_time.time.return_value = 1010.0
        algo.on_tick(0.5, 1010.0)
        assert algo.is_complete is True

        eng.submit_order.reset_mock()
        algo.on_tick(0.5, 1001.0)
        eng.submit_order.assert_not_called()


# ===================================================================
# SniperAlgo tests
# ===================================================================


class TestSniperAlgo:
    def test_sniper_init(self):
        eng = _mock_engine()
        algo = SniperAlgo(eng, target_spread=0.01)
        assert algo.target_spread == 0.01
        assert isinstance(algo, ExecAlgo)

    @patch("horizon.stealth.lob_imbalance", return_value=0.5)
    @patch("horizon.stealth.effective_spread", return_value=0.05)
    @patch("horizon.stealth.time")
    def test_sniper_waits_for_spread(self, mock_time, mock_spread, mock_imb):
        """Sniper should not fire when spread > target."""
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0
        algo = SniperAlgo(eng, target_spread=0.01,
                          config=StealthConfig(patience_secs=120.0))
        req = _mock_request(size=100.0)
        algo.start(req)

        # Spread = 0.05 > target 0.01, not enough patience elapsed
        mock_spread.return_value = 0.05
        algo.on_tick(0.5, 1000.0)
        assert algo._fired is False

    @patch("horizon.stealth.lob_imbalance", return_value=0.5)
    @patch("horizon.stealth.effective_spread", return_value=0.005)
    @patch("horizon.stealth.time")
    def test_sniper_fires_when_conditions_met(self, mock_time, mock_spread, mock_imb):
        """Sniper fires when spread < target and imbalance is favorable."""
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0
        algo = SniperAlgo(eng, target_spread=0.01,
                          config=StealthConfig(patience_secs=120.0))
        req = _mock_request(size=100.0)
        algo.start(req)

        # spread=0.005 < target 0.01, imbalance=0.5 > 0 (buy side)
        algo.on_tick(0.5, 1000.0)
        assert algo._fired is True
        eng.submit_order.assert_called_once()

    @patch("horizon.stealth.lob_imbalance", return_value=-0.5)
    @patch("horizon.stealth.effective_spread", return_value=0.02)
    @patch("horizon.stealth.time")
    def test_sniper_patience_relaxes_threshold(self, mock_time, mock_spread, mock_imb):
        """As patience fraction grows, adjusted_target increases."""
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0
        algo = SniperAlgo(eng, target_spread=0.01,
                          config=StealthConfig(patience_secs=100.0))
        req = _mock_request(size=100.0)
        algo.start(req)

        # At t=0, adj_target = 0.01*(1+0) = 0.01, spread=0.02 > 0.01 -> no fire
        algo.on_tick(0.5, 1000.0)
        assert algo._fired is False

        # At patience_fraction=1.0, adj_target = 0.01*(1+1)=0.02, imbalance_ok=True
        mock_time.time.return_value = 1100.0  # elapsed=100 = patience
        mock_spread.return_value = 0.019
        algo.on_tick(0.5, 1100.0)
        assert algo._fired is True

    @patch("horizon.stealth.lob_imbalance", return_value=0.5)
    @patch("horizon.stealth.effective_spread", return_value=0.005)
    @patch("horizon.stealth.time")
    def test_sniper_fires_when_patience_expires(self, mock_time, mock_spread, mock_imb):
        """When patience expires, imbalance check is relaxed."""
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0
        algo = SniperAlgo(eng, target_spread=0.01,
                          config=StealthConfig(patience_secs=10.0))
        req = _mock_request(size=100.0)
        algo.start(req)

        # Jump past patience; patience_fraction >= 1.0 -> imbalance_ok = True
        mock_time.time.return_value = 1020.0
        algo.on_tick(0.5, 1020.0)
        assert algo._fired is True

    @patch("horizon.stealth.lob_imbalance", return_value=0.5)
    @patch("horizon.stealth.effective_spread", return_value=0.005)
    @patch("horizon.stealth.time")
    def test_sniper_no_double_fire(self, mock_time, mock_spread, mock_imb):
        eng = _mock_engine()
        mock_time.time.return_value = 1000.0
        algo = SniperAlgo(eng, target_spread=0.01)
        req = _mock_request(size=100.0)
        algo.start(req)

        algo.on_tick(0.5, 1000.0)
        assert algo._fired is True
        eng.submit_order.reset_mock()

        algo.on_tick(0.5, 1001.0)
        eng.submit_order.assert_not_called()

    @patch("horizon.stealth.lob_imbalance", return_value=0.5)
    @patch("horizon.stealth.effective_spread", return_value=0.005)
    @patch("horizon.stealth.time")
    def test_sniper_complete_after_fire(self, mock_time, mock_spread, mock_imb):
        eng = _mock_engine()
        fill1 = MagicMock()
        fill1.order_id = "p1"
        fill1.size = 100.0
        fill1.market_id = "mkt1"
        eng.recent_fills.return_value = [fill1]
        mock_time.time.return_value = 1000.0

        algo = SniperAlgo(eng, target_spread=0.01)
        req = _mock_request(size=100.0)
        algo.start(req)
        algo.on_tick(0.5, 1000.0)
        assert algo._fired is True
        # After update_filled with full fill, algo should be complete
        algo._update_filled()
        assert algo.is_complete is True


# ===================================================================
# estimate_impact tests
# ===================================================================


class TestEstimateImpact:
    @patch(_AUTH)
    def test_estimate_impact_calls_auth(self, mock_auth):
        eng = _mock_engine()
        estimate_impact(eng, "mkt1", 10.0)
        mock_auth.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.stealth.kyles_lambda", return_value=0.1)
    @patch("horizon.stealth.effective_spread", return_value=0.02)
    @patch("horizon.stealth.estimate_volatility", return_value=0.1)
    def test_estimate_impact_with_defaults(self, mock_vol, mock_spread, mock_kyle, mock_auth):
        eng = _mock_engine()
        eng.all_feed_snapshots.return_value = {}
        eng.feed_snapshot.return_value = None
        result = estimate_impact(eng, "mkt1", 10.0)
        assert isinstance(result, ImpactEstimate)
        assert result.market_id == "mkt1"
        assert result.size == 10.0

    @patch(_AUTH)
    @patch("horizon.stealth.kyles_lambda", return_value=0.001)
    @patch("horizon.stealth.effective_spread", return_value=0.02)
    @patch("horizon.stealth.estimate_volatility", return_value=0.1)
    def test_estimate_impact_market_strategy(self, mock_vol, mock_spread, mock_kyle, mock_auth):
        """Low slippage (<10 bps) recommends market."""
        eng = _mock_engine()
        snap = MagicMock()
        snap.price = 0.5
        snap.bid = 0.48
        snap.ask = 0.52
        eng.all_feed_snapshots.return_value = {"m1": snap, "m2": snap}
        result = estimate_impact(eng, "mkt1", 1.0)
        # slippage = 0.001 * sqrt(1) * 10000 = 10, borderline
        # With kyle_lambda = 0.001, slippage = 10 bps -> twap
        assert result.recommended_strategy in ("market", "twap")

    @patch(_AUTH)
    @patch("horizon.stealth.kyles_lambda", return_value=0.01)
    @patch("horizon.stealth.effective_spread", return_value=0.02)
    @patch("horizon.stealth.estimate_volatility", return_value=0.1)
    def test_estimate_impact_twap_strategy(self, mock_vol, mock_spread, mock_kyle, mock_auth):
        """Medium slippage (10-30 bps) recommends twap."""
        eng = _mock_engine()
        snap = MagicMock()
        snap.price = 0.5
        snap.bid = 0.48
        snap.ask = 0.52
        eng.all_feed_snapshots.return_value = {"m1": snap, "m2": snap}
        result = estimate_impact(eng, "mkt1", 4.0)
        # slippage = 0.01 * sqrt(4) * 10000 = 200 bps
        assert isinstance(result, ImpactEstimate)
        assert result.estimated_slippage_bps > 0

    @patch(_AUTH)
    @patch("horizon.stealth.kyles_lambda", return_value=0.005)
    @patch("horizon.stealth.effective_spread", return_value=0.02)
    @patch("horizon.stealth.estimate_volatility", return_value=0.1)
    def test_estimate_impact_iceberg_strategy(self, mock_vol, mock_spread, mock_kyle, mock_auth):
        """Slippage 30-100 bps recommends iceberg."""
        eng = _mock_engine()
        snap = MagicMock()
        snap.price = 0.5
        snap.bid = 0.48
        snap.ask = 0.52
        eng.all_feed_snapshots.return_value = {"m1": snap, "m2": snap}
        # slippage = 0.005 * sqrt(25) * 10000 = 250 bps -> sniper
        result = estimate_impact(eng, "mkt1", 25.0)
        assert result.recommended_strategy in ("iceberg", "sniper")

    @patch(_AUTH)
    @patch("horizon.stealth.kyles_lambda", return_value=0.1)
    @patch("horizon.stealth.effective_spread", return_value=0.02)
    @patch("horizon.stealth.estimate_volatility", return_value=0.1)
    def test_estimate_impact_sniper_strategy(self, mock_vol, mock_spread, mock_kyle, mock_auth):
        """Very high slippage (>100 bps) recommends sniper."""
        eng = _mock_engine()
        snap = MagicMock()
        snap.price = 0.5
        snap.bid = 0.48
        snap.ask = 0.52
        eng.all_feed_snapshots.return_value = {"m1": snap, "m2": snap}
        # slippage = 0.1 * sqrt(100) * 10000 = 10000 bps
        result = estimate_impact(eng, "mkt1", 100.0)
        assert result.recommended_strategy == "sniper"

    @patch(_AUTH)
    @patch("horizon.stealth.kyles_lambda", return_value=0.1)
    @patch("horizon.stealth.effective_spread", return_value=0.02)
    @patch("horizon.stealth.estimate_volatility", return_value=0.1)
    def test_estimate_impact_custom_feed_name(self, mock_vol, mock_spread, mock_kyle, mock_auth):
        eng = _mock_engine()
        result = estimate_impact(eng, "mkt1", 10.0, feed_name="custom_feed")
        eng.feed_snapshot.assert_called_with("custom_feed")

    @patch(_AUTH)
    @patch("horizon.stealth.kyles_lambda", return_value=float("nan"))
    @patch("horizon.stealth.effective_spread", return_value=float("nan"))
    @patch("horizon.stealth.estimate_volatility", return_value=float("nan"))
    def test_estimate_impact_handles_nan(self, mock_vol, mock_spread, mock_kyle, mock_auth):
        eng = _mock_engine()
        snap = MagicMock()
        snap.price = 0.5
        snap.bid = 0.48
        snap.ask = 0.52
        eng.all_feed_snapshots.return_value = {"m1": snap, "m2": snap}
        result = estimate_impact(eng, "mkt1", 10.0)
        # Fallback values used; no NaN in result
        assert not math.isnan(result.kyle_lambda)
        assert not math.isnan(result.volatility)


# ===================================================================
# smart_route tests
# ===================================================================


class TestSmartRoute:
    @patch(_AUTH)
    def test_smart_route_calls_auth(self, mock_auth):
        eng = _mock_engine()
        smart_route(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0)
        mock_auth.assert_called_once()

    @patch(_AUTH)
    def test_smart_route_single_exchange(self, mock_auth):
        eng = _mock_engine()
        routes = smart_route(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0,
                             exchanges=["paper"])
        assert len(routes) == 1
        assert routes[0].exchange == "paper"
        assert routes[0].size_fraction == pytest.approx(1.0)

    @patch(_AUTH)
    def test_smart_route_multiple_exchanges(self, mock_auth):
        eng = _mock_engine()
        routes = smart_route(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0,
                             exchanges=["paper", "polymarket"])
        assert len(routes) == 2
        total_frac = sum(r.size_fraction for r in routes)
        assert total_frac == pytest.approx(1.0, abs=0.01)

    @patch(_AUTH)
    def test_smart_route_cost_sorting(self, mock_auth):
        eng = _mock_engine()
        routes = smart_route(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0,
                             exchanges=["kalshi", "paper", "polymarket"])
        # Paper has 0 fee, should be cheapest and get largest allocation
        assert routes[0].exchange == "paper"

    @patch(_AUTH)
    def test_smart_route_greedy_allocation(self, mock_auth):
        eng = _mock_engine()
        routes = smart_route(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0,
                             exchanges=["paper", "polymarket", "kalshi"])
        # First route gets 60%, second gets 30%, third gets remainder
        # But all fractions should sum to ~1.0
        total = sum(r.size_fraction for r in routes)
        assert total == pytest.approx(1.0, abs=0.01)

    @patch(_AUTH)
    def test_smart_route_default_paper(self, mock_auth):
        eng = _mock_engine()
        routes = smart_route(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0)
        assert len(routes) == 1
        assert routes[0].exchange == "paper"

    @patch(_AUTH)
    def test_smart_route_fee_rates(self, mock_auth):
        eng = _mock_engine()
        routes = smart_route(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0,
                             exchanges=["paper", "polymarket", "kalshi"])
        fees = {r.exchange: r.fee_rate for r in routes}
        assert fees["paper"] == 0.0
        assert fees["polymarket"] == 0.002
        assert fees["kalshi"] == 0.07


# ===================================================================
# stealth_execute tests
# ===================================================================


class TestStealthExecute:
    @patch(_AUTH)
    @patch("horizon.stealth.estimate_impact")
    @patch("horizon.stealth.smart_route", return_value=[])
    def test_stealth_execute_calls_auth(self, mock_route, mock_impact, mock_auth):
        mock_impact.return_value = ImpactEstimate(
            market_id="mkt1", size=100.0, estimated_slippage_bps=5.0,
            kyle_lambda=0.01, effective_spread_bps=20.0, volatility=0.1,
            recommended_strategy="twap", estimated_duration_secs=300.0,
        )
        eng = _mock_engine()
        stealth_execute(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0, 0.5)
        mock_auth.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.stealth.estimate_impact")
    @patch("horizon.stealth.smart_route", return_value=[])
    def test_stealth_execute_auto_strategy(self, mock_route, mock_impact, mock_auth):
        mock_impact.return_value = ImpactEstimate(
            market_id="mkt1", size=100.0, estimated_slippage_bps=25.0,
            kyle_lambda=0.01, effective_spread_bps=20.0, volatility=0.1,
            recommended_strategy="twap", estimated_duration_secs=300.0,
        )
        eng = _mock_engine()
        plan = stealth_execute(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0, 0.5)
        assert plan.strategy == "twap"
        mock_impact.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.stealth.smart_route", return_value=[])
    def test_stealth_execute_explicit_twap(self, mock_route, mock_auth):
        eng = _mock_engine()
        plan = stealth_execute(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0, 0.5,
                               strategy="twap")
        assert plan.strategy == "twap"
        assert plan.total_size == 100.0
        assert plan.estimated_duration_secs == 300.0

    @patch(_AUTH)
    @patch("horizon.stealth.estimate_impact")
    @patch("horizon.stealth.smart_route", return_value=[])
    def test_stealth_execute_registers_algo(self, mock_route, mock_impact, mock_auth):
        mock_impact.return_value = ImpactEstimate(
            market_id="mkt_reg", size=10.0, estimated_slippage_bps=5.0,
            kyle_lambda=0.01, effective_spread_bps=20.0, volatility=0.1,
            recommended_strategy="twap", estimated_duration_secs=300.0,
        )
        eng = _mock_engine()
        # Clear any existing
        _active_algos.pop("mkt_reg", None)
        stealth_execute(eng, "mkt_reg", Side.Yes, OrderSide.Buy, 10.0, 0.5)
        assert "mkt_reg" in _active_algos
        assert len(_active_algos["mkt_reg"]) >= 1
        # Cleanup
        _active_algos.pop("mkt_reg", None)

    @patch(_AUTH)
    @patch("horizon.stealth.estimate_impact")
    @patch("horizon.stealth.smart_route", return_value=[])
    def test_stealth_execute_returns_plan(self, mock_route, mock_impact, mock_auth):
        mock_impact.return_value = ImpactEstimate(
            market_id="mkt1", size=100.0, estimated_slippage_bps=150.0,
            kyle_lambda=0.05, effective_spread_bps=40.0, volatility=0.2,
            recommended_strategy="sniper", estimated_duration_secs=120.0,
        )
        eng = _mock_engine()
        plan = stealth_execute(eng, "mkt1", Side.Yes, OrderSide.Buy, 100.0, 0.5)
        assert isinstance(plan, ExecutionPlan)
        assert plan.strategy == "sniper"
        assert len(plan.child_orders) >= 1
        assert plan.child_orders[0]["type"] == "sniper_fire"
        # Cleanup
        _active_algos.pop("mkt1", None)


# ===================================================================
# stealth_executor tests
# ===================================================================


class TestStealthExecutor:
    @patch(_AUTH)
    def test_stealth_executor_calls_auth(self, mock_auth):
        stealth_executor()
        mock_auth.assert_called_once()

    @patch(_AUTH)
    def test_stealth_executor_returns_callable(self, mock_auth):
        fn = stealth_executor()
        assert callable(fn)
        assert fn.__name__ == "stealth_executor"

    @patch(_AUTH)
    def test_stealth_executor_processes_requests(self, mock_auth):
        fn = stealth_executor(config=StealthConfig(randomize=False))
        eng = _mock_engine()
        ctx = MagicMock(spec=Context)
        ctx.market = MagicMock()
        ctx.market.id = "mkt1"
        ctx.params = {
            "engine": eng,
            "stealth_requests": [
                {"market_id": "mkt1", "size": 10.0, "price": 0.5,
                 "strategy": "twap", "side": "Yes", "order_side": "Buy"},
            ],
        }
        fn(ctx)
        # Should have started an algo and submitted at least one order
        eng.submit_order.assert_called()
        # Requests should be cleared
        assert ctx.params["stealth_requests"] == []

    @patch(_AUTH)
    def test_stealth_executor_ticks_algos(self, mock_auth):
        fn = stealth_executor(config=StealthConfig(randomize=False))
        eng = _mock_engine()
        ctx = MagicMock(spec=Context)
        ctx.market = MagicMock()
        ctx.market.id = "mkt1"
        ctx.params = {
            "engine": eng,
            "stealth_requests": [
                {"market_id": "mkt1", "size": 10.0, "price": 0.5,
                 "strategy": "twap", "side": "Yes", "order_side": "Buy"},
            ],
        }
        # First call starts algo
        fn(ctx)
        initial_calls = eng.submit_order.call_count

        # Second call ticks the algo (no new requests)
        ctx.params["stealth_requests"] = []
        fn(ctx)
        # Feed snapshot should be called for ticking
        eng.feed_snapshot.assert_called()

    @patch(_AUTH)
    def test_stealth_executor_cleans_completed(self, mock_auth):
        fn = stealth_executor(config=StealthConfig(randomize=False))
        eng = _mock_engine()

        ctx = MagicMock(spec=Context)
        ctx.market = MagicMock()
        ctx.market.id = "mkt1"
        ctx.params = {
            "engine": eng,
            "stealth_requests": [
                {"market_id": "mkt1", "size": 10.0, "price": 0.5,
                 "strategy": "twap", "side": "Yes", "order_side": "Buy"},
            ],
        }

        # First call starts algo and submits child order "p1"
        fn(ctx)

        # Now set fills covering the entire order
        fill1 = MagicMock()
        fill1.order_id = "p1"
        fill1.size = 10.0
        fill1.market_id = "mkt1"
        eng.recent_fills.return_value = [fill1]

        # Second call ticks algo -> _update_filled detects fill -> is_complete
        ctx.params["stealth_requests"] = []
        fn(ctx)

        # After cleaning completed, active count should be 0
        assert ctx.params["stealth_active_count"] == 0
