"""Unit tests for ORBTradingEngine guard conditions.

Covers early-exit paths in run_orb_detection (rolling_loss, circuit_breaker)
and the max_simultaneous_entries guard in run_breakout_check.
"""
from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import pytest

from apps.orb_trader.engine import ORBTradingEngine

_DETECTION_PATCHES = (
    "apps.orb_trader.engine.load_universe",
    "apps.orb_trader.engine.enrich_daily_bars",
    "apps.orb_trader.engine.compute_orb_candidates",
)


def _make_engine(
    *,
    initial_equity: float = 10_000.0,
    rolling_loss_days: int | None = None,
    rolling_loss_threshold: float | None = None,
    max_simultaneous_entries: int | None = None,
) -> ORBTradingEngine:
    params = SimpleNamespace(
        daily_budget_reset=True,
        rolling_loss_days=rolling_loss_days,
        rolling_loss_threshold=rolling_loss_threshold,
        max_simultaneous_entries=max_simultaneous_entries,
        drawdown_governor_threshold=None,
        drawdown_governor_min_scale=0.30,
        streak_sizing_win_bonus=None,
        streak_sizing_loss_penalty=None,
        streak_sizing_max=2.5,
        streak_sizing_min=0.5,
        orb_minutes=5,
        market_regime_spy_threshold=None,
        min_candidate_breadth=None,
        atr_stop_multiplier=0.75,
        risk_per_trade_pct=0.05,
        max_position_pct=0.70,
    )
    session = SimpleNamespace(
        session_id="test-session",
        session_name="test",
        initial_equity=initial_equity,
    )
    state = MagicMock()
    state.get_equity.return_value = initial_equity
    state.get_peak_equity.return_value = initial_equity
    state.list_snapshots.return_value = []
    state.list_trades.return_value = []

    engine = object.__new__(ORBTradingEngine)
    engine._session = session
    engine._params = params
    engine._state = state
    engine._broker = MagicMock()
    engine._broker.get_bars.return_value = {}
    engine._broker.get_intraday_bars.return_value = {}
    engine._log_callback = None
    engine._enrichment = {}
    engine._daily_bars = {}
    engine._candidates = []
    engine._pending_cands = []
    engine._pre_screened_tickers = None
    engine._date_str = ""
    return engine


def _run_detection_no_pipeline(eng: ORBTradingEngine, date_str: str) -> dict:
    """Run run_orb_detection with heavy library calls patched out."""
    with patch("apps.orb_trader.engine.load_universe", return_value=[]), \
         patch("apps.orb_trader.engine.enrich_daily_bars", return_value={}), \
         patch("apps.orb_trader.engine.compute_orb_candidates", return_value=[]):
        return eng.run_orb_detection(date_str)


# ── Rolling loss filter ───────────────────────────────────────────────────────

class TestRollingLossFilter:
    def _snapshot(self, date: str, daily_pnl: float) -> dict:
        return {"date": date, "daily_pnl": daily_pnl}

    def test_not_enough_history_no_skip(self):
        # Only 2 snapshots but roll_days=3 → filter does not trigger
        eng = _make_engine(rolling_loss_days=3, rolling_loss_threshold=-0.05)
        eng._state.list_snapshots.return_value = [
            self._snapshot("2026-01-01", -300.0),
            self._snapshot("2026-01-02", -300.0),
        ]
        result = _run_detection_no_pipeline(eng, "2026-01-05")
        assert result.get("skip_reason") != "rolling_loss"

    def test_loss_below_threshold_triggers_skip(self):
        # 3 days of -200 = -600 total; -600/10000 = -6% < -5% threshold → skip
        eng = _make_engine(rolling_loss_days=3, rolling_loss_threshold=-0.05)
        eng._state.list_snapshots.return_value = [
            self._snapshot("2026-01-01", -200.0),
            self._snapshot("2026-01-02", -200.0),
            self._snapshot("2026-01-03", -200.0),
        ]
        result = eng.run_orb_detection("2026-01-05")
        assert result["skip_reason"] == "rolling_loss"
        assert result["orb_candidates"] == 0

    def test_loss_exactly_at_threshold_no_skip(self):
        # -100 + -200 + -200 = -500; -500/10000 = -5.0% = threshold → NOT below → no skip
        eng = _make_engine(rolling_loss_days=3, rolling_loss_threshold=-0.05)
        eng._state.list_snapshots.return_value = [
            self._snapshot("2026-01-01", -100.0),
            self._snapshot("2026-01-02", -200.0),
            self._snapshot("2026-01-03", -200.0),
        ]
        result = _run_detection_no_pipeline(eng, "2026-01-05")
        assert result.get("skip_reason") != "rolling_loss"

    def test_future_snapshots_excluded_from_window(self):
        # Snapshot for date_str itself must not be counted (filter: date < date_str)
        # 2 past snapshots < roll_days=3 → no skip
        eng = _make_engine(rolling_loss_days=3, rolling_loss_threshold=-0.05)
        eng._state.list_snapshots.return_value = [
            self._snapshot("2026-01-01", -400.0),
            self._snapshot("2026-01-02", -400.0),
            self._snapshot("2026-01-05", -400.0),  # same-day: must not count
        ]
        result = _run_detection_no_pipeline(eng, "2026-01-05")
        assert result.get("skip_reason") != "rolling_loss"

    def test_rolling_window_uses_last_n_days(self):
        # 5 past snapshots, roll_days=3 → only last 3 used
        # Last 3 are +100 each → sum=+300 → no trigger
        eng = _make_engine(rolling_loss_days=3, rolling_loss_threshold=-0.05)
        eng._state.list_snapshots.return_value = [
            self._snapshot("2025-12-30", -400.0),
            self._snapshot("2025-12-31", -400.0),
            self._snapshot("2026-01-01", 100.0),
            self._snapshot("2026-01-02", 100.0),
            self._snapshot("2026-01-03", 100.0),
        ]
        result = _run_detection_no_pipeline(eng, "2026-01-05")
        assert result.get("skip_reason") != "rolling_loss"


# ── Circuit breaker ───────────────────────────────────────────────────────────

class TestCircuitBreaker:
    def test_triggers_at_25pct_drawdown(self):
        # Peak=10000, current=7400 → DD=26% ≥ 25% → halt
        eng = _make_engine(initial_equity=10_000)
        eng._state.get_equity.return_value = 7_400.0
        eng._state.get_peak_equity.return_value = 10_000.0

        result = eng.run_orb_detection("2026-01-05")
        assert result["skip_reason"] == "circuit_breaker"
        eng._state.set_session_status.assert_called_once_with("test-session", "paused")

    def test_does_not_trigger_below_threshold(self):
        # Peak=10000, current=7600 → DD=24% < 25% → continues
        eng = _make_engine(initial_equity=10_000)
        eng._state.get_equity.return_value = 7_600.0
        eng._state.get_peak_equity.return_value = 10_000.0

        result = _run_detection_no_pipeline(eng, "2026-01-05")
        assert result.get("skip_reason") != "circuit_breaker"
        eng._state.set_session_status.assert_not_called()


# ── max_simultaneous_entries guard ───────────────────────────────────────────

def _make_cand(ticker: str, price: float = 100.0) -> dict:
    return {
        "ticker": ticker,
        "direction": "bullish",
        "orb_bar": {"high": price, "low": price * 0.98, "open": price * 0.99, "close": price},
        "atr": 1.0,
        "score": 0.8,
        "rvol": 2.0,
        "gap_pct": 0.03,
    }


def _mock_position(ticker: str) -> MagicMock:
    pos = MagicMock()
    pos.ticker = ticker
    return pos


class TestMaxSimultaneousEntries:
    def _make_breakout_engine(self, *, max_sim: int | None, open_positions: list) -> ORBTradingEngine:
        eng = _make_engine(max_simultaneous_entries=max_sim)
        daily_state = MagicMock()
        daily_state.kill_switch = False
        eng._state.get_daily_state.return_value = daily_state
        eng._state.get_open_positions.return_value = open_positions
        return eng

    def test_at_max_candidate_stays_pending(self):
        # 3 positions open, max_sim=3 → new candidate must NOT enter
        eng = self._make_breakout_engine(
            max_sim=3,
            open_positions=[_mock_position("A"), _mock_position("B"), _mock_position("C")],
        )
        eng._pending_cands = [_make_cand("AAPL", price=150.0)]

        snap = MagicMock()
        snap.price = 155.0  # would trigger breakout if guard not active
        with patch("apps.orb_trader.engine.get_snapshots", return_value={"AAPL": snap}):
            result = eng.run_breakout_check("2026-01-05")

        assert result["filled"] == 0
        assert result["remaining"] == 1

    def test_below_max_proceeds_past_guard(self):
        # 2 positions open, max_sim=3 → not blocked by guard; snapshot check runs
        eng = self._make_breakout_engine(
            max_sim=3,
            open_positions=[_mock_position("A"), _mock_position("B")],
        )
        eng._pending_cands = [_make_cand("AAPL", price=150.0)]

        # Return no snapshot → candidate stays pending for snapshot reason (not max_sim)
        with patch("apps.orb_trader.engine.get_snapshots", return_value={}) as mock_snaps:
            result = eng.run_breakout_check("2026-01-05")

        mock_snaps.assert_called_once()  # proceeded past max_sim guard
        assert result["remaining"] == 1   # pending, but due to missing snapshot

    def test_no_limit_proceeds_past_guard(self):
        # max_simultaneous_entries=None → no position cap enforced
        eng = self._make_breakout_engine(
            max_sim=None,
            open_positions=[
                _mock_position("A"), _mock_position("B"),
                _mock_position("C"), _mock_position("D"),
            ],
        )
        eng._pending_cands = [_make_cand("AAPL", price=150.0)]

        with patch("apps.orb_trader.engine.get_snapshots", return_value={}) as mock_snaps:
            result = eng.run_breakout_check("2026-01-05")

        mock_snaps.assert_called_once()
        assert result["remaining"] == 1


# ── EOD DB sweep ──────────────────────────────────────────────────────────────

class TestEodDbSweep:
    def test_sweep_runs_with_no_open_positions(self):
        # Regression: before fix, DB sweep was inside the "has positions" branch;
        # after a server restart with no open positions, stale pending records leaked.
        eng = _make_engine()
        eng._state.get_open_positions.return_value = []
        eng._state.list_candidates.return_value = [
            {"ticker": "AAPL", "status": "pending"},
            {"ticker": "NVDA", "status": "pending"},
            {"ticker": "TSLA", "status": "filled"},  # already filled, must not be touched
        ]

        result = eng.run_eod_exit("2026-01-05")

        assert result["closed"] == 0
        # Both pending records should be timed out
        calls = [
            call.args for call in eng._state.update_candidate_status.call_args_list
        ]
        assert ("test-session", "2026-01-05", "AAPL", "timeout") in calls
        assert ("test-session", "2026-01-05", "NVDA", "timeout") in calls
        # Filled record must not be touched
        assert ("test-session", "2026-01-05", "TSLA", "timeout") not in calls

    def test_in_memory_pending_swept_with_no_open_positions(self):
        # In-memory pending candidates must also be swept unconditionally
        eng = _make_engine()
        eng._state.get_open_positions.return_value = []
        eng._state.list_candidates.return_value = []
        eng._pending_cands = [
            {"ticker": "MSFT", "direction": "bullish", "orb_bar": {}, "atr": 1.0,
             "score": 0.5, "rvol": 1.5, "gap_pct": 0.02},
        ]

        eng.run_eod_exit("2026-01-05")

        assert eng._pending_cands == []  # cleared
        eng._state.update_candidate_status.assert_called_once_with(
            "test-session", "2026-01-05", "MSFT", "timeout"
        )
