"""Tests for sentinel portfolio risk monitoring module."""

from __future__ import annotations

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

import pytest

from horizon.sentinel import (
    CorrelationAlert,
    DrawdownLevel,
    HedgeSuggestion,
    RiskBudget,
    SentinelReport,
    SentinelConfig,
    sentinel_report,
    suggest_hedges,
    sentinel,
    _portfolio_value,
    _position_returns,
    _detect_regime,
    _check_drawdowns,
)
from horizon._horizon import Engine, Side
from horizon.context import Context

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

_AUTH = "horizon.sentinel.auth_require_ultra"


def _mock_engine(positions=None, feeds=None):
    eng = MagicMock(spec=Engine)
    eng.positions.return_value = positions or []
    eng.recent_fills.return_value = []
    eng.open_orders.return_value = []
    eng.cancel_all.return_value = 0

    snap = MagicMock()
    snap.price = 0.5
    snap.bid = 0.48
    snap.ask = 0.52
    snap.volume_24h = 1000.0
    snap.timestamp = time.time()

    if feeds:
        eng.all_feed_snapshots.return_value = feeds
        eng.feed_snapshot.side_effect = lambda name: feeds.get(name)
    else:
        eng.all_feed_snapshots.return_value = {"test": snap}
        eng.feed_snapshot.return_value = snap

    return eng


def _mock_position(market_id="mkt1", side=Side.Yes, size=10.0, avg_entry=0.5):
    pos = MagicMock()
    pos.market_id = market_id
    pos.side = side
    pos.size = size
    pos.avg_entry_price = avg_entry
    pos.realized_pnl = 0.0
    pos.unrealized_pnl = 0.0
    return pos


def _mock_ctx(market_id="mkt1", engine=None):
    ctx = MagicMock(spec=Context)
    ctx.market = MagicMock()
    ctx.market.id = market_id
    ctx.params = {"engine": engine or _mock_engine()}
    return ctx


def _mock_feed(price=0.5, bid=0.48, ask=0.52):
    snap = MagicMock()
    snap.price = price
    snap.bid = bid
    snap.ask = ask
    snap.volume_24h = 1000.0
    snap.timestamp = time.time()
    return snap


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


class TestDataclasses:
    def test_correlation_alert_frozen(self):
        ca = CorrelationAlert(
            market_a="m1", market_b="m2", current_corr=0.8,
            baseline_corr=0.3, spike_magnitude=0.5,
            risk_implication="high correlation",
        )
        with pytest.raises(AttributeError):
            ca.current_corr = 0.9

    def test_drawdown_level_frozen(self):
        dl = DrawdownLevel(
            scope="portfolio", scope_id="portfolio",
            current_dd=-0.05, peak_value=100.0, current_value=95.0,
            threshold=-0.05, action="alert", triggered=True,
        )
        with pytest.raises(AttributeError):
            dl.current_dd = -0.10

    def test_hedge_suggestion_frozen(self):
        hs = HedgeSuggestion(
            market_id="m1", market_title="Test", side="yes",
            order_side="sell", size=5.0, price=0.49,
            risk_reduction_pct=20.0, cost_estimate_usdc=2.45,
            rationale="reduce delta",
        )
        with pytest.raises(AttributeError):
            hs.size = 10.0

    def test_risk_budget_frozen(self):
        rb = RiskBudget(
            total_budget=10000.0, used_budget=5000.0, usage_pct=50.0,
            regime="normal", regime_multiplier=1.0,
        )
        with pytest.raises(AttributeError):
            rb.total_budget = 20000.0

    def test_sentinel_report_frozen(self):
        sr = SentinelReport(
            correlation_alerts=[], drawdown_levels=[], hedges=[],
            risk_budget=RiskBudget(
                total_budget=10000.0, used_budget=0.0, usage_pct=0.0,
                regime="normal", regime_multiplier=1.0,
            ),
            var_95=0.0, cvar_95=0.0, max_position_pct=0.0,
            regime="normal", portfolio_value=0.0, timestamp=time.time(),
        )
        with pytest.raises(AttributeError):
            sr.var_95 = 0.5

    def test_sentinel_config_defaults(self):
        cfg = SentinelConfig()
        assert cfg.correlation_spike_threshold == 0.3
        assert cfg.auto_hedge is False
        assert cfg.auto_deleverage is True
        assert cfg.regime_sensitivity == 1.0
        assert cfg.var_confidence == 0.95
        assert cfg.lookback_returns == 100
        assert cfg.risk_budget_total == 10000.0
        assert len(cfg.drawdown_thresholds) == 4

    def test_sentinel_config_custom_thresholds(self):
        cfg = SentinelConfig(
            drawdown_thresholds=[(-0.02, "alert"), (-0.05, "exit")],
            correlation_spike_threshold=0.5,
            risk_budget_total=50000.0,
        )
        assert len(cfg.drawdown_thresholds) == 2
        assert cfg.correlation_spike_threshold == 0.5
        assert cfg.risk_budget_total == 50000.0


# ===================================================================
# _portfolio_value tests
# ===================================================================


class TestPortfolioValue:
    def test_portfolio_value_no_positions(self):
        eng = _mock_engine(positions=[])
        assert _portfolio_value(eng) == 0.0

    def test_portfolio_value_yes_side(self):
        pos = _mock_position(side=Side.Yes, size=10.0, avg_entry=0.5)
        snap = _mock_feed(price=0.6)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})
        val = _portfolio_value(eng)
        # 10 * 0.6 = 6.0
        assert val == pytest.approx(6.0)

    def test_portfolio_value_no_side(self):
        pos = _mock_position(side=Side.No, size=10.0, avg_entry=0.5)
        snap = _mock_feed(price=0.4)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})
        val = _portfolio_value(eng)
        # 10 * (1.0 - 0.4) = 6.0
        assert val == pytest.approx(6.0)

    def test_portfolio_value_mixed_sides(self):
        pos1 = _mock_position(market_id="m1", side=Side.Yes, size=10.0)
        pos2 = _mock_position(market_id="m2", side=Side.No, size=5.0)
        snap1 = _mock_feed(price=0.6)
        snap2 = _mock_feed(price=0.3)
        eng = _mock_engine(positions=[pos1, pos2], feeds={"m1": snap1, "m2": snap2})
        val = _portfolio_value(eng)
        # 10 * 0.6 + 5 * (1.0 - 0.3) = 6.0 + 3.5 = 9.5
        assert val == pytest.approx(9.5)

    def test_portfolio_value_no_feed(self):
        pos = _mock_position(side=Side.Yes, size=10.0, avg_entry=0.4)
        eng = _mock_engine(positions=[pos], feeds={})
        val = _portfolio_value(eng)
        # No feed -> uses avg_entry_price 0.4; 10 * 0.4 = 4.0
        assert val == pytest.approx(4.0)


# ===================================================================
# _position_returns tests
# ===================================================================


class TestPositionReturns:
    def test_position_returns_empty(self):
        eng = _mock_engine()
        eng.recent_fills.return_value = []
        assert _position_returns(eng, 100) == []

    def test_position_returns_from_fills(self):
        eng = _mock_engine()
        pos = _mock_position(market_id="m1", avg_entry=0.5)
        eng.positions.return_value = [pos]

        fill = MagicMock()
        fill.market_id = "m1"
        fill.price = 0.6
        eng.recent_fills.return_value = [fill]

        rets = _position_returns(eng, 100)
        # (0.6 - 0.5) / 0.5 = 0.2
        assert len(rets) == 1
        assert rets[0] == pytest.approx(0.2)

    def test_position_returns_capped_lookback(self):
        eng = _mock_engine()
        pos = _mock_position(market_id="m1", avg_entry=0.5)
        eng.positions.return_value = [pos]

        fills = []
        for i in range(10):
            f = MagicMock()
            f.market_id = "m1"
            f.price = 0.5 + i * 0.01
            fills.append(f)
        eng.recent_fills.return_value = fills

        rets = _position_returns(eng, 3)
        assert len(rets) == 3

    def test_position_returns_handles_zero_price(self):
        eng = _mock_engine()
        pos = _mock_position(market_id="m1", avg_entry=0.0)
        eng.positions.return_value = [pos]

        fill = MagicMock()
        fill.market_id = "m1"
        fill.price = 0.5
        eng.recent_fills.return_value = [fill]

        rets = _position_returns(eng, 100)
        # entry = 0.0 -> skipped
        assert len(rets) == 0


# ===================================================================
# _detect_regime tests
# ===================================================================


class TestDetectRegime:
    def test_detect_regime_low_vol(self):
        snap = _mock_feed(price=0.5, bid=0.499, ask=0.501)
        eng = _mock_engine(feeds={"m1": snap})
        regime, mult = _detect_regime(eng)
        # spread = 0.002, mid = 0.5, vol_est = 0.004 < 0.05
        assert regime == "low_vol"
        assert mult == 1.5

    def test_detect_regime_normal(self):
        snap = _mock_feed(price=0.5, bid=0.47, ask=0.53)
        eng = _mock_engine(feeds={"m1": snap})
        regime, mult = _detect_regime(eng)
        # spread = 0.06, mid = 0.5, vol_est = 0.12 < 0.15
        assert regime == "normal"
        assert mult == 1.0

    def test_detect_regime_high_vol(self):
        snap = _mock_feed(price=0.5, bid=0.42, ask=0.58)
        eng = _mock_engine(feeds={"m1": snap})
        regime, mult = _detect_regime(eng)
        # spread = 0.16, mid = 0.5, vol_est = 0.32 >= 0.30 -> crisis
        # Actually 0.16/0.5 = 0.32 >= 0.30 -> crisis
        assert regime in ("high_vol", "crisis")

    def test_detect_regime_crisis(self):
        snap = _mock_feed(price=0.5, bid=0.35, ask=0.65)
        eng = _mock_engine(feeds={"m1": snap})
        regime, mult = _detect_regime(eng)
        # spread = 0.30, mid = 0.5, vol_est = 0.60 >= 0.30
        assert regime == "crisis"
        assert mult == 0.25

    def test_detect_regime_no_feeds(self):
        eng = _mock_engine(feeds={})
        regime, mult = _detect_regime(eng)
        assert regime == "normal"
        assert mult == 1.0


# ===================================================================
# _check_drawdowns tests
# ===================================================================


class TestCheckDrawdowns:
    def test_drawdowns_no_drawdown(self):
        eng = _mock_engine(positions=[])
        cfg = SentinelConfig()
        peaks = {}
        levels = _check_drawdowns(eng, cfg, peaks)
        # Portfolio has 0 value, 0 peak -> dd = 0, none triggered
        triggered = [d for d in levels if d.triggered]
        assert len(triggered) == 0

    def test_drawdowns_alert_threshold(self):
        pos = _mock_position(side=Side.Yes, size=100.0, avg_entry=0.5)
        snap = _mock_feed(price=0.5)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})
        cfg = SentinelConfig()
        # Set peak to 60, current = 100*0.5 = 50, dd = (50-60)/60 = -0.1667
        peaks = {"portfolio": 60.0}
        levels = _check_drawdowns(eng, cfg, peaks)
        triggered = [d for d in levels if d.triggered and d.scope == "portfolio"]
        # dd = -0.1667 triggers alert(-0.05), reduce(-0.10), but not pause(-0.20)
        actions = {d.action for d in triggered}
        assert "alert" in actions
        assert "reduce" in actions

    def test_drawdowns_reduce_threshold(self):
        pos = _mock_position(side=Side.Yes, size=100.0, avg_entry=0.5)
        snap = _mock_feed(price=0.45)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})
        cfg = SentinelConfig()
        # current = 100*0.45 = 45, peak = 55, dd = (45-55)/55 = -0.1818
        peaks = {"portfolio": 55.0}
        levels = _check_drawdowns(eng, cfg, peaks)
        triggered = [d for d in levels if d.triggered and d.scope == "portfolio"]
        actions = {d.action for d in triggered}
        assert "reduce" in actions

    def test_drawdowns_pause_threshold(self):
        pos = _mock_position(side=Side.Yes, size=100.0, avg_entry=0.5)
        snap = _mock_feed(price=0.4)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})
        cfg = SentinelConfig()
        # current = 100*0.4 = 40, peak = 55, dd = (40-55)/55 = -0.2727
        peaks = {"portfolio": 55.0}
        levels = _check_drawdowns(eng, cfg, peaks)
        triggered = [d for d in levels if d.triggered and d.scope == "portfolio"]
        actions = {d.action for d in triggered}
        assert "pause" in actions

    def test_drawdowns_exit_threshold(self):
        pos = _mock_position(side=Side.Yes, size=100.0, avg_entry=0.5)
        snap = _mock_feed(price=0.3)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})
        cfg = SentinelConfig()
        # current = 100*0.3 = 30, peak = 50, dd = (30-50)/50 = -0.40
        peaks = {"portfolio": 50.0}
        levels = _check_drawdowns(eng, cfg, peaks)
        triggered = [d for d in levels if d.triggered and d.scope == "portfolio"]
        actions = {d.action for d in triggered}
        assert "exit" in actions

    def test_drawdowns_per_market(self):
        pos = _mock_position(market_id="mkt1", side=Side.Yes, size=100.0, avg_entry=0.5)
        snap = _mock_feed(price=0.3)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})
        cfg = SentinelConfig()
        # Market peak set to 50, current = 100*0.3 = 30, dd = -0.4
        peaks = {"portfolio": 50.0, "mkt1": 50.0}
        levels = _check_drawdowns(eng, cfg, peaks)
        market_triggered = [d for d in levels if d.triggered and d.scope == "market"]
        assert len(market_triggered) > 0
        assert market_triggered[0].scope_id == "mkt1"


# ===================================================================
# sentinel_report tests
# ===================================================================


class TestSentinelReport:
    @patch(_AUTH)
    @patch("horizon.sentinel._build_report")
    @patch("horizon.sentinel._check_drawdowns", return_value=[])
    @patch("horizon.sentinel._position_returns", return_value=[])
    @patch("horizon.sentinel._portfolio_value", return_value=0.0)
    def test_sentinel_report_calls_auth(self, mock_pv, mock_pr, mock_dd, mock_br, mock_auth):
        mock_br.return_value = MagicMock(spec=SentinelReport)
        eng = _mock_engine()
        sentinel_report(eng)
        mock_auth.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.sentinel._build_report")
    @patch("horizon.sentinel._check_drawdowns", return_value=[])
    @patch("horizon.sentinel._position_returns", return_value=[])
    @patch("horizon.sentinel._portfolio_value", return_value=0.0)
    def test_sentinel_report_no_positions(self, mock_pv, mock_pr, mock_dd, mock_br, mock_auth):
        mock_br.return_value = MagicMock(spec=SentinelReport)
        eng = _mock_engine(positions=[])
        result = sentinel_report(eng)
        assert result is not None

    @patch(_AUTH)
    @patch("horizon.sentinel._build_report")
    @patch("horizon.sentinel._check_drawdowns", return_value=[])
    @patch("horizon.sentinel._position_returns", return_value=[0.01, -0.02])
    @patch("horizon.sentinel._portfolio_value", return_value=100.0)
    def test_sentinel_report_with_positions(self, mock_pv, mock_pr, mock_dd, mock_br, mock_auth):
        report = SentinelReport(
            correlation_alerts=[], drawdown_levels=[], hedges=[],
            risk_budget=RiskBudget(
                total_budget=10000.0, used_budget=100.0, usage_pct=1.0,
                regime="normal", regime_multiplier=1.0,
            ),
            var_95=-0.05, cvar_95=-0.08, max_position_pct=100.0,
            regime="normal", portfolio_value=100.0, timestamp=time.time(),
        )
        mock_br.return_value = report
        eng = _mock_engine(positions=[_mock_position()])
        result = sentinel_report(eng)
        assert result.portfolio_value == 100.0

    @patch(_AUTH)
    @patch("horizon.sentinel.cornish_fisher_var", return_value=-0.05)
    @patch("horizon.sentinel.cornish_fisher_cvar", return_value=-0.08)
    @patch("horizon.sentinel.ledoit_wolf_shrinkage", return_value=([[0.01]], 0.5))
    @patch("horizon.sentinel.prediction_greeks")
    def test_sentinel_report_var_cvar(self, mock_greeks, mock_lw, mock_cvar, mock_var, mock_auth):
        pos = _mock_position(side=Side.Yes, size=10.0, avg_entry=0.5)
        snap = _mock_feed(price=0.5)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})

        # Provide fills for returns
        fills = []
        for i in range(10):
            f = MagicMock()
            f.market_id = "mkt1"
            f.price = 0.5 + (i - 5) * 0.01
            fills.append(f)
        eng.recent_fills.return_value = fills

        greeks_result = MagicMock()
        greeks_result.delta = 5.0
        mock_greeks.return_value = greeks_result

        result = sentinel_report(eng)
        assert isinstance(result, SentinelReport)
        assert result.var_95 == pytest.approx(-0.05)
        assert result.cvar_95 == pytest.approx(-0.08)

    @patch(_AUTH)
    @patch("horizon.sentinel.ledoit_wolf_shrinkage")
    @patch("horizon.sentinel.cornish_fisher_var", return_value=0.0)
    @patch("horizon.sentinel.cornish_fisher_cvar", return_value=0.0)
    @patch("horizon.sentinel.prediction_greeks")
    def test_sentinel_report_correlation_alerts(self, mock_greeks, mock_cvar, mock_var, mock_lw, mock_auth):
        # Need >=2 markets with >=3 fills each for correlation
        pos1 = _mock_position(market_id="m1", side=Side.Yes, size=10.0)
        pos2 = _mock_position(market_id="m2", side=Side.Yes, size=10.0)
        snap1 = _mock_feed(price=0.5)
        snap2 = _mock_feed(price=0.6)
        eng = _mock_engine(
            positions=[pos1, pos2],
            feeds={"m1": snap1, "m2": snap2},
        )

        fills = []
        for i in range(5):
            f1 = MagicMock()
            f1.market_id = "m1"
            f1.price = 0.5 + i * 0.01
            fills.append(f1)
            f2 = MagicMock()
            f2.market_id = "m2"
            f2.price = 0.6 + i * 0.01
            fills.append(f2)
        eng.recent_fills.return_value = fills

        # Covariance matrix for 2 markets
        mock_lw.return_value = ([[0.01, 0.008], [0.008, 0.01]], 0.5)

        greeks_result = MagicMock()
        greeks_result.delta = 5.0
        mock_greeks.return_value = greeks_result

        result = sentinel_report(eng)
        assert isinstance(result, SentinelReport)

    @patch(_AUTH)
    @patch("horizon.sentinel.prediction_greeks")
    @patch("horizon.sentinel.cornish_fisher_var", return_value=0.0)
    @patch("horizon.sentinel.cornish_fisher_cvar", return_value=0.0)
    def test_sentinel_report_regime_detection(self, mock_cvar, mock_var, mock_greeks, mock_auth):
        pos = _mock_position(side=Side.Yes, size=10.0)
        snap = _mock_feed(price=0.5, bid=0.499, ask=0.501)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})

        greeks_result = MagicMock()
        greeks_result.delta = 5.0
        mock_greeks.return_value = greeks_result

        result = sentinel_report(eng)
        assert result.regime == "low_vol"

    @patch(_AUTH)
    @patch("horizon.sentinel.prediction_greeks")
    @patch("horizon.sentinel.cornish_fisher_var", return_value=0.0)
    @patch("horizon.sentinel.cornish_fisher_cvar", return_value=0.0)
    def test_sentinel_report_risk_budget(self, mock_cvar, mock_var, mock_greeks, mock_auth):
        pos = _mock_position(side=Side.Yes, size=100.0, avg_entry=0.5)
        snap = _mock_feed(price=0.5)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})

        greeks_result = MagicMock()
        greeks_result.delta = 5.0
        mock_greeks.return_value = greeks_result

        result = sentinel_report(eng)
        assert result.risk_budget.used_budget > 0
        assert result.risk_budget.regime in ("low_vol", "normal", "high_vol", "crisis")

    @patch(_AUTH)
    @patch("horizon.sentinel.prediction_greeks")
    @patch("horizon.sentinel.cornish_fisher_var", return_value=0.0)
    @patch("horizon.sentinel.cornish_fisher_cvar", return_value=0.0)
    def test_sentinel_report_hedge_suggestions(self, mock_cvar, mock_var, mock_greeks, mock_auth):
        pos = _mock_position(side=Side.Yes, size=100.0, avg_entry=0.5)
        snap = _mock_feed(price=0.5)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})

        greeks_result = MagicMock()
        greeks_result.delta = 50.0
        mock_greeks.return_value = greeks_result

        result = sentinel_report(eng)
        assert isinstance(result.hedges, list)


# ===================================================================
# suggest_hedges tests
# ===================================================================


class TestSuggestHedges:
    @patch(_AUTH)
    @patch("horizon.sentinel.prediction_greeks")
    def test_suggest_hedges_calls_auth(self, mock_greeks, mock_auth):
        eng = _mock_engine(positions=[])
        suggest_hedges(eng)
        mock_auth.assert_called_once()

    @patch(_AUTH)
    def test_suggest_hedges_no_positions(self, mock_auth):
        eng = _mock_engine(positions=[])
        hedges = suggest_hedges(eng)
        assert hedges == []

    @patch(_AUTH)
    @patch("horizon.sentinel.prediction_greeks")
    def test_suggest_hedges_with_positions(self, mock_greeks, mock_auth):
        pos = _mock_position(side=Side.Yes, size=100.0, avg_entry=0.5)
        snap = _mock_feed(price=0.5)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})

        greeks_result = MagicMock()
        greeks_result.delta = 50.0
        mock_greeks.return_value = greeks_result

        hedges = suggest_hedges(eng)
        assert len(hedges) >= 1
        assert hedges[0].market_id == "mkt1"

    @patch(_AUTH)
    @patch("horizon.sentinel.prediction_greeks")
    def test_suggest_hedges_max_hedges(self, mock_greeks, mock_auth):
        positions = [_mock_position(market_id=f"m{i}", size=50.0) for i in range(10)]
        feeds = {f"m{i}": _mock_feed(price=0.5) for i in range(10)}
        eng = _mock_engine(positions=positions, feeds=feeds)

        greeks_result = MagicMock()
        greeks_result.delta = 25.0
        mock_greeks.return_value = greeks_result

        hedges = suggest_hedges(eng, max_hedges=3)
        assert len(hedges) <= 3

    @patch(_AUTH)
    @patch("horizon.sentinel.prediction_greeks")
    def test_suggest_hedges_min_reduction(self, mock_greeks, mock_auth):
        pos = _mock_position(side=Side.Yes, size=10.0, avg_entry=0.5)
        snap = _mock_feed(price=0.5)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})

        greeks_result = MagicMock()
        greeks_result.delta = 5.0
        mock_greeks.return_value = greeks_result

        # With only one position, its reduction is 100% so it passes any min
        hedges = suggest_hedges(eng, min_reduction_pct=5.0)
        assert isinstance(hedges, list)


# ===================================================================
# sentinel pipeline tests
# ===================================================================


class TestSentinelPipeline:
    @patch(_AUTH)
    def test_sentinel_calls_auth(self, mock_auth):
        sentinel()
        mock_auth.assert_called_once()

    @patch(_AUTH)
    def test_sentinel_returns_callable(self, mock_auth):
        fn = sentinel()
        assert callable(fn)

    @patch(_AUTH)
    def test_sentinel_callable_name(self, mock_auth):
        fn = sentinel()
        assert fn.__name__ == "sentinel"

    @patch(_AUTH)
    def test_sentinel_tracks_peak_value(self, mock_auth):
        fn = sentinel(config=SentinelConfig(auto_deleverage=False))
        pos = _mock_position(side=Side.Yes, size=100.0, avg_entry=0.5)
        snap = _mock_feed(price=0.6)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})
        ctx = _mock_ctx(engine=eng)

        fn(ctx)
        # After first call, portfolio value = 100*0.6 = 60, becomes peak
        # Second call with lower price
        snap2 = _mock_feed(price=0.5)
        eng.all_feed_snapshots.return_value = {"mkt1": snap2}
        fn(ctx)
        # Peak should remain at 60, no error
        assert ctx.params.get("sentinel_report") is not None or ctx.params.get("sentinel_report") is None

    @patch(_AUTH)
    def test_sentinel_updates_returns(self, mock_auth):
        fn = sentinel(config=SentinelConfig(auto_deleverage=False))
        pos = _mock_position(side=Side.Yes, size=100.0, avg_entry=0.5)
        snap = _mock_feed(price=0.5)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})
        ctx = _mock_ctx(engine=eng)

        # Call twice to generate a return (second call computes cycle return)
        fn(ctx)
        snap2 = _mock_feed(price=0.55)
        eng.all_feed_snapshots.return_value = {"mkt1": snap2}
        fn(ctx)
        # No exception means returns tracking works

    @patch(_AUTH)
    @patch("horizon.sentinel._check_drawdowns")
    def test_sentinel_checks_drawdowns(self, mock_dd, mock_auth):
        mock_dd.return_value = []
        fn = sentinel(config=SentinelConfig(auto_deleverage=False))
        eng = _mock_engine()
        ctx = _mock_ctx(engine=eng)
        fn(ctx)
        mock_dd.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.sentinel._check_drawdowns")
    @patch("horizon.sentinel._execute_reduce")
    def test_sentinel_auto_deleverage_reduce(self, mock_reduce, mock_dd, mock_auth):
        dd_level = DrawdownLevel(
            scope="portfolio", scope_id="portfolio",
            current_dd=-0.12, peak_value=100.0, current_value=88.0,
            threshold=-0.10, action="reduce", triggered=True,
        )
        mock_dd.return_value = [dd_level]
        fn = sentinel(config=SentinelConfig(auto_deleverage=True))
        eng = _mock_engine()
        ctx = _mock_ctx(engine=eng)
        fn(ctx)
        mock_reduce.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.sentinel._check_drawdowns")
    @patch("horizon.sentinel._execute_pause")
    def test_sentinel_auto_deleverage_pause(self, mock_pause, mock_dd, mock_auth):
        dd_level = DrawdownLevel(
            scope="portfolio", scope_id="portfolio",
            current_dd=-0.22, peak_value=100.0, current_value=78.0,
            threshold=-0.20, action="pause", triggered=True,
        )
        mock_dd.return_value = [dd_level]
        fn = sentinel(config=SentinelConfig(auto_deleverage=True))
        eng = _mock_engine()
        ctx = _mock_ctx(engine=eng)
        fn(ctx)
        mock_pause.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.sentinel._check_drawdowns")
    @patch("horizon.sentinel._execute_exit")
    def test_sentinel_auto_deleverage_exit(self, mock_exit, mock_dd, mock_auth):
        dd_level = DrawdownLevel(
            scope="portfolio", scope_id="portfolio",
            current_dd=-0.35, peak_value=100.0, current_value=65.0,
            threshold=-0.30, action="exit", triggered=True,
        )
        mock_dd.return_value = [dd_level]
        fn = sentinel(config=SentinelConfig(auto_deleverage=True))
        eng = _mock_engine()
        ctx = _mock_ctx(engine=eng)
        fn(ctx)
        mock_exit.assert_called_once()

    @patch(_AUTH)
    @patch("horizon.sentinel._build_report")
    @patch("horizon.sentinel._check_drawdowns", return_value=[])
    def test_sentinel_injects_report(self, mock_dd, mock_br, mock_auth):
        report = SentinelReport(
            correlation_alerts=[], drawdown_levels=[], hedges=[],
            risk_budget=RiskBudget(
                total_budget=10000.0, used_budget=0.0, usage_pct=0.0,
                regime="normal", regime_multiplier=1.0,
            ),
            var_95=0.0, cvar_95=0.0, max_position_pct=0.0,
            regime="normal", portfolio_value=0.0, timestamp=time.time(),
        )
        mock_br.return_value = report

        fn = sentinel(
            config=SentinelConfig(auto_deleverage=False),
            report_interval_cycles=1,
        )
        eng = _mock_engine()
        ctx = _mock_ctx(engine=eng)
        fn(ctx)
        assert ctx.params["sentinel_report"] is report


# ===================================================================
# Edge case tests
# ===================================================================


class TestEdgeCases:
    @patch(_AUTH)
    def test_sentinel_no_engine_in_params(self, mock_auth):
        fn = sentinel()
        ctx = MagicMock(spec=Context)
        ctx.params = {}
        fn(ctx)
        # Should not raise; sentinel_report should be None
        assert ctx.params.get("sentinel_report") is None

    @patch(_AUTH)
    @patch("horizon.sentinel._portfolio_value", side_effect=RuntimeError("boom"))
    def test_sentinel_handles_exceptions(self, mock_pv, mock_auth):
        fn = sentinel(config=SentinelConfig(auto_deleverage=False))
        eng = _mock_engine()
        ctx = _mock_ctx(engine=eng)
        # Should not raise even if _portfolio_value fails
        fn(ctx)
        assert "sentinel_report" in ctx.params

    @patch(_AUTH)
    @patch("horizon.sentinel._check_drawdowns", return_value=[])
    @patch("horizon.sentinel._build_report")
    def test_sentinel_report_interval(self, mock_br, mock_dd, mock_auth):
        report = SentinelReport(
            correlation_alerts=[], drawdown_levels=[], hedges=[],
            risk_budget=RiskBudget(
                total_budget=10000.0, used_budget=0.0, usage_pct=0.0,
                regime="normal", regime_multiplier=1.0,
            ),
            var_95=0.0, cvar_95=0.0, max_position_pct=0.0,
            regime="normal", portfolio_value=0.0, timestamp=time.time(),
        )
        mock_br.return_value = report

        fn = sentinel(
            config=SentinelConfig(auto_deleverage=False),
            report_interval_cycles=5,
        )
        eng = _mock_engine()
        ctx = _mock_ctx(engine=eng)

        # Cycles 1-4: no report built
        for _ in range(4):
            fn(ctx)
        assert mock_br.call_count == 0

        # Cycle 5: report built
        fn(ctx)
        assert mock_br.call_count == 1

    @patch(_AUTH)
    def test_sentinel_config_custom_lookback(self, mock_auth):
        cfg = SentinelConfig(lookback_returns=20)
        fn = sentinel(config=cfg)
        assert callable(fn)
        # The lookback is used internally; just verify config accepted
        assert cfg.lookback_returns == 20

    @patch(_AUTH)
    @patch("horizon.sentinel._check_drawdowns", return_value=[])
    def test_sentinel_regime_multiplier_applied(self, mock_dd, mock_auth):
        fn = sentinel(
            config=SentinelConfig(auto_deleverage=False),
            report_interval_cycles=1,
        )
        pos = _mock_position(side=Side.Yes, size=100.0, avg_entry=0.5)
        # Low vol regime -> multiplier 1.5
        snap = _mock_feed(price=0.5, bid=0.499, ask=0.501)
        eng = _mock_engine(positions=[pos], feeds={"mkt1": snap})

        fills = []
        for i in range(10):
            f = MagicMock()
            f.market_id = "mkt1"
            f.price = 0.5 + (i - 5) * 0.001
            fills.append(f)
        eng.recent_fills.return_value = fills

        ctx = _mock_ctx(engine=eng)
        with patch("horizon.sentinel.cornish_fisher_var", return_value=0.0), \
             patch("horizon.sentinel.cornish_fisher_cvar", return_value=0.0), \
             patch("horizon.sentinel.prediction_greeks") as mock_greeks:
            greeks_result = MagicMock()
            greeks_result.delta = 5.0
            mock_greeks.return_value = greeks_result
            fn(ctx)

        report = ctx.params.get("sentinel_report")
        if report is not None:
            # In low_vol regime, budget multiplier is 1.5
            assert report.risk_budget.regime_multiplier == 1.5
