"""Unit tests for libs/backtest/allocator.py."""
from __future__ import annotations

import datetime as dt
import math
from zoneinfo import ZoneInfo

import pytest

from libs.backtest.domain import (
    BacktestConfig,
    Candidate,
    DailyPortfolioState,
    ExecutionConfig,
    OpenPosition,
    PlannedOrder,
    PositionStatus,
    RiskConfig,
    SignalConfig,
    UniverseConfig,
)

_UTC = ZoneInfo("UTC")
_NOW = dt.datetime(2026, 1, 5, 21, 0, tzinfo=_UTC)
_TODAY = dt.date(2026, 1, 5)
_TOMORROW = dt.date(2026, 1, 6)


def _make_candidate(**kwargs) -> Candidate:
    defaults = dict(
        event_id="EVT::TEST",
        symbol="AAPL",
        issuer_id=None,
        score=0.8,
        sector="Technology",
        event_type="earnings",
        event_timestamp=_NOW,
        filing_time_bucket="post_market",
        reaction_date=_TODAY,
        execution_date=_TOMORROW,
        entry_price_est=100.0,
        avg_dollar_volume=5_000_000.0,
        atr_14=2.0,
        score_bucket="high",
    )
    defaults.update(kwargs)
    return Candidate(**defaults)


def _make_portfolio_state(**kwargs) -> DailyPortfolioState:
    defaults = dict(
        date=_TOMORROW,
        equity=100_000.0,
        cash_available=100_000.0,
        gross_exposure=0.0,
        net_exposure=0.0,
        reserved_risk_budget=0.0,
        unrealized_pnl=0.0,
        realized_pnl=0.0,
        open_positions=[],
        daily_new_risk_used=0.0,
        peak_equity=100_000.0,
        current_drawdown_pct=0.0,
    )
    defaults.update(kwargs)
    return DailyPortfolioState(**defaults)


def _make_config(**kwargs) -> BacktestConfig:
    defaults = dict(strategy_name="test", dataset_snapshot_id="snap_001")
    defaults.update(kwargs)
    return BacktestConfig(**defaults)


class TestComputeStopPrice:
    def test_atr_stop(self):
        from libs.backtest.allocator import compute_stop_price

        c = _make_candidate(entry_price_est=100.0, atr_14=2.0)
        stop = compute_stop_price(c, RiskConfig(
            per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
            max_positions=10, max_positions_per_sector=3
        ))
        # 1.5 * ATR below price (default multiplier)
        assert stop == pytest.approx(100.0 - 1.5 * 2.0)

    def test_atr_stop_custom_multiplier(self):
        from libs.backtest.allocator import compute_stop_price

        c = _make_candidate(entry_price_est=100.0, atr_14=2.0)
        stop = compute_stop_price(c, RiskConfig(
            per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
            max_positions=10, max_positions_per_sector=3,
            stop_atr_multiplier=2.0,
        ))
        # 2.0 * ATR below price
        assert stop == pytest.approx(100.0 - 2.0 * 2.0)

    def test_fallback_stop_when_no_atr(self):
        from libs.backtest.allocator import compute_stop_price

        c = _make_candidate(entry_price_est=100.0, atr_14=None)
        stop = compute_stop_price(c, RiskConfig(
            per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
            max_positions=10, max_positions_per_sector=3
        ))
        # 2% fallback
        assert stop == pytest.approx(98.0)

    def test_stop_never_negative(self):
        from libs.backtest.allocator import compute_stop_price

        c = _make_candidate(entry_price_est=1.0, atr_14=5.0)
        stop = compute_stop_price(c, RiskConfig(
            per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
            max_positions=10, max_positions_per_sector=3
        ))
        assert stop >= 0.01


class TestComputeShares:
    def test_basic(self):
        from libs.backtest.allocator import compute_shares

        # 1% of 100k = 1000 risk, 100-95=5 stop distance → 200 shares
        shares = compute_shares(
            100_000.0, 100.0, 95.0,
            RiskConfig(per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
                      max_positions=10, max_positions_per_sector=3)
        )
        assert shares == 200

    def test_always_floor(self):
        from libs.backtest.allocator import compute_shares

        # Result should always be floor
        shares = compute_shares(
            100_000.0, 100.0, 96.7,
            RiskConfig(per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
                      max_positions=10, max_positions_per_sector=3)
        )
        # raw = 1000 / 3.3 ≈ 303.03 → floor = 303
        assert shares == math.floor(1000.0 / 3.3)

    def test_stop_above_entry_valid_for_short(self):
        """Stop above entry is valid for short positions -- compute_shares uses abs distance."""
        from libs.backtest.allocator import compute_shares

        shares = compute_shares(100_000.0, 95.0, 100.0, RiskConfig(
            per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
            max_positions=10, max_positions_per_sector=3
        ))
        # stop_distance = abs(95 - 100) = 5.0, risk = 1000, shares = floor(1000/5) = 200
        assert shares == 200


class TestRunEntryGates:
    def test_pass_all_gates(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        ps = _make_portfolio_state()
        cfg = _make_config()
        assert run_entry_gates(c, ps, [], cfg) is None

    def test_gate1_kill_switch(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        ps = _make_portfolio_state(current_drawdown_pct=30.0)
        assert run_entry_gates(c, ps, [], _make_config()) == "kill_switch_drawdown"

    def test_gate2_max_positions(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.risk.max_positions = 0  # impossible to add

        # Mock 0 positions but max is 0
        result = run_entry_gates(c, ps, [], cfg)
        assert result == "max_positions_reached"

    def test_gate4_sector_limit(self):
        from libs.backtest.allocator import run_entry_gates

        # Create a position in the same sector
        plan = PlannedOrder(
            candidate=_make_candidate(symbol="MSFT", sector="Technology"),
            shares=10, entry_price_limit=100.0, stop_price=95.0,
            target_price=110.0, risk_dollars=50.0,
        )
        existing_pos = OpenPosition(
            position_id="p1", plan=plan, entry_date=_TODAY,
            entry_price=100.0, entry_fill_slippage_bps=10.0,
            current_stop=95.0, target_price=110.0, peak_price=100.0,
            shares_open=10, shares_total=10,
        )
        cfg = _make_config()
        cfg.risk.max_positions_per_sector = 1  # only 1 per sector

        c = _make_candidate(symbol="AAPL", sector="Technology")
        result = run_entry_gates(c, _make_portfolio_state(), [existing_pos], cfg)
        assert result == "sector_limit"

    def test_gate3_duplicate_symbol(self):
        from libs.backtest.allocator import run_entry_gates

        plan = PlannedOrder(
            candidate=_make_candidate(symbol="AAPL"),
            shares=10, entry_price_limit=100.0, stop_price=95.0,
            target_price=110.0, risk_dollars=50.0,
        )
        existing = OpenPosition(
            position_id="p1", plan=plan, entry_date=_TODAY,
            entry_price=100.0, entry_fill_slippage_bps=10.0,
            current_stop=95.0, target_price=110.0, peak_price=100.0,
            shares_open=10, shares_total=10,
        )
        c = _make_candidate(symbol="AAPL")
        result = run_entry_gates(c, _make_portfolio_state(), [existing], _make_config())
        assert result == "duplicate_symbol"

    def test_gate7_cooldown(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        result = run_entry_gates(c, _make_portfolio_state(), [], _make_config(), cooldown_remaining=2)
        assert result == "cooldown"


class TestMacroRegimeGate:
    """Macro regime filter gate tests."""

    def test_blocks_when_spy_below_sma_and_scaler_gte_1(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.risk.macro_regime_enabled = True
        cfg.risk.macro_regime_size_scaler = 1.0  # default — hard block
        macro = {"spy_close": 490.0, "spy_sma_20": 500.0}  # SPY below SMA
        result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
        assert result == "macro_regime_unfavorable"

    def test_passes_when_spy_below_sma_and_scaler_lt_1(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.risk.macro_regime_enabled = True
        cfg.risk.macro_regime_size_scaler = 0.5  # size scaler — don't hard block
        macro = {"spy_close": 490.0, "spy_sma_20": 500.0}
        result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
        assert result is None  # passes gate, size scaler applied in build_planned_order

    def test_macro_size_scaler_reduces_shares(self):
        from libs.backtest.allocator import build_planned_order

        c = _make_candidate(entry_price_est=100.0, atr_14=2.0)
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.risk.macro_regime_enabled = True
        cfg.risk.macro_regime_size_scaler = 0.5
        macro = {"spy_close": 490.0, "spy_sma_20": 500.0}

        # Without macro scaler
        order_normal = build_planned_order(c, ps, [], cfg, macro_data=None)
        # With macro scaler
        order_scaled = build_planned_order(c, ps, [], cfg, macro_data=macro)

        assert order_normal.skip_reason is None
        assert order_scaled.skip_reason is None
        assert order_scaled.shares < order_normal.shares
        assert order_scaled.shares >= 1

    def test_passes_when_spy_above_sma(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.risk.macro_regime_enabled = True
        macro = {"spy_close": 510.0, "spy_sma_20": 500.0}  # SPY above SMA
        result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
        assert result is None

    def test_passes_when_spy_equals_sma(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.risk.macro_regime_enabled = True
        macro = {"spy_close": 500.0, "spy_sma_20": 500.0}  # Equal — not unfavorable
        result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
        assert result is None

    def test_disabled_by_default(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        ps = _make_portfolio_state()
        cfg = _make_config()
        # macro_regime_enabled defaults to False
        macro = {"spy_close": 490.0, "spy_sma_20": 500.0}
        result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
        assert result is None  # Gate is disabled, should pass

    def test_passes_when_no_macro_data(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.risk.macro_regime_enabled = True
        result = run_entry_gates(c, ps, [], cfg, macro_data=None)
        assert result is None  # No data available, don't block

    def test_passes_when_sma_not_computed(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate()
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.risk.macro_regime_enabled = True
        macro = {"spy_close": 490.0, "spy_sma_20": None}  # SMA not yet computed
        result = run_entry_gates(c, ps, [], cfg, macro_data=macro)
        assert result is None  # Can't evaluate, don't block

    def test_build_planned_order_with_macro_hard_block(self):
        from libs.backtest.allocator import build_planned_order

        c = _make_candidate()
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.risk.macro_regime_enabled = True
        cfg.risk.macro_regime_size_scaler = 1.0  # hard block mode
        macro = {"spy_close": 490.0, "spy_sma_20": 500.0}
        order = build_planned_order(c, ps, [], cfg, macro_data=macro)
        assert order.skip_reason == "macro_regime_unfavorable"


class TestComputeTargetPrice:
    def test_fixed_r_default(self):
        from libs.backtest.allocator import compute_target_price

        target = compute_target_price(100.0, 95.0, 2.0)
        assert target == pytest.approx(110.0)  # 100 + (100-95)*2

    def test_atr_multiple_model(self):
        from libs.backtest.allocator import compute_target_price

        target = compute_target_price(
            100.0, 95.0, 2.0,
            target_model="atr_multiple",
            target_atr_multiplier=1.5,
            atr_14=3.0,
        )
        assert target == pytest.approx(104.5)  # 100 + 3.0*1.5

    def test_atr_multiple_falls_back_when_no_atr(self):
        from libs.backtest.allocator import compute_target_price

        target = compute_target_price(
            100.0, 95.0, 2.0,
            target_model="atr_multiple",
            atr_14=None,
        )
        assert target == pytest.approx(110.0)  # falls back to fixed_r


class TestDirectionFilter:
    def test_bullish_only_blocks_bearish(self):
        from libs.backtest.allocator import run_entry_gates
        from libs.backtest.domain import EventTypeProfile

        c = _make_candidate(
            event_type="earnings_release",
            features={"reaction_day_return": -0.02, "eps_growth_qoq": 0.10},
        )
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.event_type_profiles = {
            "earnings_release": EventTypeProfile(direction_filter="bullish_only"),
        }
        result = run_entry_gates(c, ps, [], cfg)
        assert result == "direction_filter_bearish"

    def test_bullish_only_passes_positive(self):
        from libs.backtest.allocator import run_entry_gates
        from libs.backtest.domain import EventTypeProfile

        c = _make_candidate(
            event_type="earnings_release",
            features={"reaction_day_return": 0.02, "eps_growth_qoq": 0.10},
        )
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.event_type_profiles = {
            "earnings_release": EventTypeProfile(direction_filter="bullish_only"),
        }
        result = run_entry_gates(c, ps, [], cfg)
        assert result is None


class TestVetoGates:
    """Veto gate tests for document quality hard filters (gates 10-13)."""

    def test_high_oneoff_blocked(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate(features={"oneoff_penalty": 0.6})
        cfg = _make_config()
        cfg.risk.veto_oneoff_penalty = 0.5
        result = run_entry_gates(c, _make_portfolio_state(), [], cfg)
        assert result == "high_oneoff_risk"

    def test_low_oneoff_passes(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate(features={"oneoff_penalty": 0.3})
        cfg = _make_config()
        cfg.risk.veto_oneoff_penalty = 0.5
        result = run_entry_gates(c, _make_portfolio_state(), [], cfg)
        assert result is None

    def test_low_parse_confidence_blocked(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate(features={"parse_confidence_overall": 0.3})
        cfg = _make_config()
        cfg.risk.veto_parse_confidence_min = 0.4
        result = run_entry_gates(c, _make_portfolio_state(), [], cfg)
        assert result == "low_parse_confidence"

    def test_adequate_parse_confidence_passes(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate(features={"parse_confidence_overall": 0.6})
        cfg = _make_config()
        cfg.risk.veto_parse_confidence_min = 0.4
        result = run_entry_gates(c, _make_portfolio_state(), [], cfg)
        assert result is None

    def test_unknown_direction_blocked(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate(features={"event_direction": "unknown"})
        cfg = _make_config()
        cfg.risk.veto_unknown_direction = True
        result = run_entry_gates(c, _make_portfolio_state(), [], cfg)
        assert result == "unknown_direction"

    def test_bearish_direction_blocked(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate(features={"event_direction": "bearish"})
        cfg = _make_config()
        cfg.risk.veto_bearish_direction = True
        result = run_entry_gates(c, _make_portfolio_state(), [], cfg)
        assert result == "bearish_direction"

    def test_bullish_direction_passes(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate(features={"event_direction": "bullish"})
        cfg = _make_config()
        cfg.risk.veto_unknown_direction = True
        cfg.risk.veto_bearish_direction = True
        result = run_entry_gates(c, _make_portfolio_state(), [], cfg)
        assert result is None

    def test_missing_features_pass_veto(self):
        from libs.backtest.allocator import run_entry_gates

        c = _make_candidate(features={})
        cfg = _make_config()
        cfg.risk.veto_unknown_direction = True
        cfg.risk.veto_bearish_direction = True
        result = run_entry_gates(c, _make_portfolio_state(), [], cfg)
        assert result is None


class TestBuildPlannedOrder:
    def test_valid_order(self):
        from libs.backtest.allocator import build_planned_order

        c = _make_candidate(entry_price_est=100.0, atr_14=2.0)
        ps = _make_portfolio_state()
        order = build_planned_order(c, ps, [], _make_config())
        assert order.skip_reason is None
        assert order.shares > 0
        assert order.stop_price < 100.0
        assert order.target_price > 100.0

    def test_rejected_order_has_skip_reason(self):
        from libs.backtest.allocator import build_planned_order

        c = _make_candidate()
        ps = _make_portfolio_state(current_drawdown_pct=30.0)
        order = build_planned_order(c, ps, [], _make_config())
        assert order.skip_reason == "kill_switch_drawdown"
        assert order.shares == 0

    def test_atr_target_model_in_order(self):
        from libs.backtest.allocator import build_planned_order

        c = _make_candidate(entry_price_est=100.0, atr_14=3.0)
        ps = _make_portfolio_state()
        cfg = _make_config()
        cfg.execution.target_model = "atr_multiple"
        cfg.execution.target_atr_multiplier = 1.5
        order = build_planned_order(c, ps, [], cfg)
        assert order.skip_reason is None
        assert order.target_price == pytest.approx(104.5)  # 100 + 3.0*1.5
