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

import datetime as dt
from zoneinfo import ZoneInfo

import pytest

from libs.backtest.domain import (
    Candidate,
    ExecutionConfig,
    ExitReason,
    OpenPosition,
    PlannedOrder,
    PositionStatus,
)

_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)
_DAY3 = dt.date(2026, 1, 7)


def _make_candidate(**kwargs) -> Candidate:
    return Candidate(
        event_id="EVT::TEST",
        symbol="AAPL",
        score=0.8,
        sector="Technology",
        event_type="earnings",
        event_timestamp=_NOW,
        event_date=_TODAY,
        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",
        **kwargs,
    )


def _make_plan(entry_price=100.0, stop=95.0, target=110.0, shares=100) -> PlannedOrder:
    return PlannedOrder(
        candidate=_make_candidate(),
        shares=shares,
        entry_price_limit=entry_price,
        stop_price=stop,
        target_price=target,
        risk_dollars=500.0,
        event_date=_TODAY,
        timing_class="same_day",
        engine_id="engine_1",
        entry_timing_policy="next_open",
        shadow_only=False,
    )


def _make_bar(open=101.0, high=108.0, low=98.0, close=105.0, date=None) -> dict:
    return {
        "date": date or _TOMORROW,
        "open": open,
        "high": high,
        "low": low,
        "close": close,
        "volume": 1_000_000,
    }


def _make_exec_config(**kwargs) -> ExecutionConfig:
    defaults = dict(
        entry_fill_model="next_open",
        exit_fill_model="daily_bar_approximation",
        slippage_bps_base=10.0,
        commission_per_share=0.005,
        same_bar_priority="stop_first_conservative",
        max_holding_days=10,
    )
    defaults.update(kwargs)
    return ExecutionConfig(**defaults)


def _make_open_position(entry_price=101.0, stop=95.0, target=110.0, days=0, shares=100) -> OpenPosition:
    plan = _make_plan(entry_price=100.0, stop=stop, target=target, shares=shares)
    return OpenPosition(
        position_id="p1",
        plan=plan,
        entry_date=_TODAY,
        entry_price=entry_price,
        entry_fill_slippage_bps=10.0,
        current_stop=stop,
        target_price=target,
        peak_price=entry_price,
        shares_open=shares,
        shares_total=shares,
        days_held=days,
    )


class TestSimulateEntry:
    def test_basic_entry(self):
        from libs.backtest.execution import simulate_entry

        plan = _make_plan()
        bar = _make_bar(open=100.0)
        cfg = _make_exec_config(slippage_bps_base=10.0)
        pos = simulate_entry(plan, bar, cfg)
        assert pos is not None
        # Entry fill = open * (1 + 10/10000)
        expected = 100.0 * (1 + 10 / 10_000)
        assert pos.entry_price == pytest.approx(expected)

    def test_missing_bar_returns_none(self):
        from libs.backtest.execution import simulate_entry

        plan = _make_plan()
        assert simulate_entry(plan, None, _make_exec_config()) is None

    def test_zero_open_returns_none(self):
        from libs.backtest.execution import simulate_entry

        plan = _make_plan()
        bar = _make_bar(open=0.0)
        assert simulate_entry(plan, bar, _make_exec_config()) is None

    def test_rejected_plan_returns_none(self):
        from libs.backtest.execution import simulate_entry

        plan = PlannedOrder(
            candidate=_make_candidate(),
            shares=100,
            entry_price_limit=100.0,
            stop_price=95.0,
            target_price=110.0,
            risk_dollars=500.0,
            skip_reason="max_positions_reached",
        )
        bar = _make_bar()
        assert simulate_entry(plan, bar, _make_exec_config()) is None

    def test_entry_date_from_bar(self):
        from libs.backtest.execution import simulate_entry

        plan = _make_plan()
        bar = _make_bar(date=_TOMORROW)
        pos = simulate_entry(plan, bar, _make_exec_config())
        assert pos.entry_date == _TOMORROW

    def test_reaction_close_entry_uses_close(self):
        from libs.backtest.execution import simulate_entry

        plan = _make_plan()
        plan = plan.model_copy(update={"entry_timing_policy": "reaction_close"})
        bar = _make_bar(open=100.0, close=102.0, date=_TODAY)
        pos = simulate_entry(plan, bar, _make_exec_config(slippage_bps_base=10.0))
        assert pos is not None
        expected = 102.0 * (1 + 10 / 10_000)
        assert pos.entry_price == pytest.approx(expected)
        assert pos.entry_date == _TODAY


class TestSimulateExit:
    def test_stop_exit(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=115.0)
        bar = _make_bar(low=90.0, high=100.0)  # low < stop
        cfg = _make_exec_config()
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)
        assert trade is not None
        assert trade.exit_reason == ExitReason.STOP
        # Fill at stop * (1 - slippage)
        expected = 95.0 * (1 - 10 / 10_000)
        assert trade.exit_price == pytest.approx(expected)

    def test_target_exit(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0)
        bar = _make_bar(low=102.0, high=115.0)  # high > target
        trade = simulate_exit(pos, bar, _make_exec_config(), _TOMORROW)
        assert trade is not None
        assert trade.exit_reason == ExitReason.TARGET
        expected = 110.0 * (1 - 10 / 10_000)
        assert trade.exit_price == pytest.approx(expected)

    def test_same_bar_stop_first_conservative(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0)
        bar = _make_bar(low=90.0, high=115.0)  # both stop AND target hit
        cfg = _make_exec_config(same_bar_priority="stop_first_conservative")
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)
        assert trade.exit_reason == ExitReason.STOP

    def test_same_bar_target_first_aggressive(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0)
        bar = _make_bar(low=90.0, high=115.0)
        cfg = _make_exec_config(same_bar_priority="target_first_aggressive")
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)
        assert trade.exit_reason == ExitReason.TARGET

    def test_time_exit(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=120.0, days=10)
        bar = _make_bar(low=100.0, high=105.0, close=103.0)  # no stop or target hit
        cfg = _make_exec_config(max_holding_days=10)
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)
        assert trade is not None
        assert trade.exit_reason == ExitReason.TIME

    def test_no_exit_when_bar_in_range(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=120.0, days=3)
        bar = _make_bar(low=98.0, high=108.0)
        trade = simulate_exit(pos, bar, _make_exec_config(), _TOMORROW)
        assert trade is None

    def test_expected_decay_exit_triggers_at_close(self):
        from libs.backtest.execution import simulate_exit

        plan = _make_plan(entry_price=100.0, stop=95.0, target=120.0, shares=100)
        plan = plan.model_copy(
            update={"candidate": plan.candidate.model_copy(update={"score": 0.45})}
        )
        pos = OpenPosition(
            position_id="p1",
            plan=plan,
            entry_date=_TODAY,
            entry_price=101.0,
            entry_fill_slippage_bps=10.0,
            current_stop=95.0,
            target_price=120.0,
            peak_price=104.0,
            shares_open=100,
            shares_total=100,
            days_held=9,
        )
        bar = _make_bar(low=99.0, high=104.0, close=102.0)
        cfg = _make_exec_config(
            max_holding_days=20,
            expected_decay_exit_enabled=True,
            expected_decay_lambda=0.15,
            expected_decay_score_floor=0.12,
            expected_decay_min_days_held=4,
        )
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)
        assert trade is not None
        assert trade.exit_reason == ExitReason.DECAY

    def test_expected_decay_exit_respects_min_days_and_floor(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=120.0, days=3)
        bar = _make_bar(low=98.0, high=108.0, close=103.0)
        cfg = _make_exec_config(
            max_holding_days=20,
            expected_decay_exit_enabled=True,
            expected_decay_lambda=0.15,
            expected_decay_score_floor=0.12,
            expected_decay_min_days_held=4,
        )
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)
        assert trade is None

    def test_missing_bar_no_exit(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position()
        assert simulate_exit(pos, None, _make_exec_config(), _TOMORROW) is None

    def test_recycle_close_exit_uses_close(self):
        from libs.backtest.execution import simulate_recycle_close_exit

        pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100)
        bar = _make_bar(close=107.0)
        trade = simulate_recycle_close_exit(pos, bar, _TOMORROW, _make_exec_config(slippage_bps_base=10.0))
        assert trade is not None
        assert trade.exit_reason == ExitReason.RECYCLE
        expected = 107.0 * (1 - 10 / 10_000)
        assert trade.exit_price == pytest.approx(expected)

    def test_r_multiple_uses_actual_fill(self):
        from libs.backtest.execution import simulate_exit

        # entry=101, stop=95 → risk per share = 6
        pos = _make_open_position(entry_price=101.0, stop=95.0, target=113.0)
        bar = _make_bar(low=98.0, high=115.0)  # target hit
        trade = simulate_exit(pos, bar, _make_exec_config(slippage_bps_base=0.0), _TOMORROW)
        # R-multiple = (exit - entry) / (entry - stop) = (113 - 101) / (101 - 95) = 12/6 = 2.0
        assert trade.r_multiple == pytest.approx(2.0, rel=0.01)

    def test_pnl_includes_commission(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100)
        bar = _make_bar(low=98.0, high=115.0)  # target hit
        cfg = _make_exec_config(slippage_bps_base=0.0, commission_per_share=0.01)
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)
        # gross = (110 - 100) * 100 = 1000
        # commission = 100 * 0.01 * 2 = 2.0
        assert trade.gross_pnl == pytest.approx(1000.0)
        assert trade.net_pnl == pytest.approx(998.0)

    def test_trade_metadata_propagated(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100)
        bar = _make_bar(low=98.0, high=115.0)
        trade = simulate_exit(pos, bar, _make_exec_config(slippage_bps_base=0.0), _TOMORROW)
        assert trade.engine_id == "engine_1"
        assert trade.timing_class == "same_day"
        assert trade.event_date == _TODAY
        assert trade.shadow_only is False


class TestPartialExit:
    def test_partial_exit_at_target(self):
        """When target_1_fraction < 1.0 and target is hit, partial exit occurs."""
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0, shares=100)
        bar = _make_bar(low=102.0, high=115.0)  # target hit
        cfg = _make_exec_config(target_1_fraction=0.5)
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)

        assert trade is not None
        assert trade.exit_reason == ExitReason.TARGET
        assert trade.shares == 50  # 50% of 100
        assert pos.shares_open == 50  # remaining
        assert pos.current_stop == pytest.approx(101.0)  # breakeven
        assert pos.status == PositionStatus.PARTIALLY_EXITED

    def test_partial_exit_records_in_partial_fills(self):
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0, shares=100)
        bar = _make_bar(low=102.0, high=115.0)
        cfg = _make_exec_config(target_1_fraction=0.5)
        simulate_exit(pos, bar, cfg, _TOMORROW)
        assert len(pos.partial_fills) == 1

    def test_second_target_hit_closes_remainder(self):
        """After partial exit, second target hit closes remaining shares fully."""
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0, shares=100)
        pos.status = PositionStatus.PARTIALLY_EXITED  # already partially exited
        pos.shares_open = 50
        bar = _make_bar(low=102.0, high=115.0)
        cfg = _make_exec_config(target_1_fraction=0.5)
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)
        # Should do full exit on second target hit (PARTIALLY_EXITED status)
        assert trade is not None
        assert trade.shares == 50  # remaining shares

    def test_full_exit_when_fraction_is_1(self):
        """When target_1_fraction == 1.0, full exit as before."""
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=110.0, shares=100)
        bar = _make_bar(low=102.0, high=115.0)
        cfg = _make_exec_config(target_1_fraction=1.0)
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)
        assert trade is not None
        assert trade.shares == 100

    def test_stop_exit_ignores_partial_fraction(self):
        """Stop exits always close fully (partial only on TARGET)."""
        from libs.backtest.execution import simulate_exit

        pos = _make_open_position(entry_price=101.0, stop=95.0, target=130.0, shares=100)
        bar = _make_bar(low=90.0, high=100.0)  # stop hit
        cfg = _make_exec_config(target_1_fraction=0.5)
        trade = simulate_exit(pos, bar, cfg, _TOMORROW)
        assert trade is not None
        assert trade.exit_reason == ExitReason.STOP
        assert trade.shares == 100  # full close


class TestUpdateTrailingStop:
    def test_ratchets_up(self):
        from libs.backtest.execution import update_trailing_stop

        pos = _make_open_position(stop=95.0)
        update_trailing_stop(pos, _make_bar(low=97.0))
        assert pos.current_stop == pytest.approx(97.0)

    def test_never_moves_down(self):
        from libs.backtest.execution import update_trailing_stop

        pos = _make_open_position(stop=95.0)
        update_trailing_stop(pos, _make_bar(low=92.0))
        assert pos.current_stop == pytest.approx(95.0)

    def test_updates_peak_price(self):
        from libs.backtest.execution import update_trailing_stop

        pos = _make_open_position(entry_price=100.0)
        pos.peak_price = 100.0
        update_trailing_stop(pos, _make_bar(high=115.0, low=100.0))
        assert pos.peak_price == pytest.approx(115.0)

    def test_pct_trailing_ratchets_up(self):
        from libs.backtest.execution import update_trailing_stop

        pos = _make_open_position(entry_price=100.0, stop=95.0)
        pos.peak_price = 100.0
        # Peak goes to 110, trail at 3% → stop = 110 * 0.97 = 106.7
        update_trailing_stop(pos, _make_bar(high=110.0, low=105.0), trailing_model="pct_3")
        assert pos.current_stop == pytest.approx(110.0 * 0.97)
        assert pos.peak_price == pytest.approx(110.0)


class TestScheduledOpenExit:
    def test_full_exit_executes_at_next_open(self):
        from libs.backtest.execution import simulate_scheduled_open_exit

        pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100)
        bar = _make_bar(open=97.0, high=100.0, low=96.0, close=98.0, date=_TOMORROW)
        trade = simulate_scheduled_open_exit(
            position=pos,
            bar=bar,
            config=_make_exec_config(slippage_bps_base=0.0),
            current_date=_TOMORROW,
            reason="EARLY_FAILURE",
            fraction=1.0,
        )
        assert trade is not None
        assert trade.exit_reason == ExitReason.EARLY_FAILURE
        assert trade.exit_price == pytest.approx(97.0)
        assert trade.shares == 100

    def test_partial_no_progress_exit_leaves_position_open(self):
        from libs.backtest.execution import simulate_scheduled_open_exit

        pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100)
        bar = _make_bar(open=101.0, high=103.0, low=99.0, close=102.0, date=_TOMORROW)
        trade = simulate_scheduled_open_exit(
            position=pos,
            bar=bar,
            config=_make_exec_config(slippage_bps_base=0.0),
            current_date=_TOMORROW,
            reason="NO_PROGRESS",
            fraction=0.5,
        )
        assert trade is not None
        assert trade.exit_reason == ExitReason.NO_PROGRESS
        assert trade.shares == 50
        assert pos.shares_open == 50
        assert pos.status == PositionStatus.PARTIALLY_EXITED

    def test_giveback_exit_uses_giveback_reason(self):
        from libs.backtest.execution import simulate_scheduled_open_exit

        pos = _make_open_position(entry_price=100.0, stop=95.0, target=110.0, shares=100)
        bar = _make_bar(open=104.0, high=105.0, low=103.0, close=104.0, date=_TOMORROW)
        trade = simulate_scheduled_open_exit(
            position=pos,
            bar=bar,
            config=_make_exec_config(slippage_bps_base=0.0),
            current_date=_TOMORROW,
            reason="GIVEBACK",
            fraction=1.0,
        )
        assert trade is not None
        assert trade.exit_reason == ExitReason.GIVEBACK
        assert trade.exit_price == pytest.approx(104.0)
        assert trade.shares == 100

    def test_pct_trailing_never_moves_down(self):
        from libs.backtest.execution import update_trailing_stop

        pos = _make_open_position(entry_price=100.0, stop=98.0)
        pos.peak_price = 100.0
        # Peak stays at 100, trail at 3% → stop = 97. But current_stop=98 > 97, so no change
        update_trailing_stop(pos, _make_bar(high=99.0, low=96.0), trailing_model="pct_3")
        assert pos.current_stop == pytest.approx(98.0)

    def test_pct_5_trailing(self):
        from libs.backtest.execution import update_trailing_stop

        pos = _make_open_position(entry_price=100.0, stop=90.0)
        pos.peak_price = 100.0
        # Peak goes to 120, trail at 5% → stop = 120 * 0.95 = 114.0
        update_trailing_stop(pos, _make_bar(high=120.0, low=115.0), trailing_model="pct_5")
        assert pos.current_stop == pytest.approx(114.0)

    def test_warmup_skips_trailing(self):
        """Trailing stop should not activate during warmup period."""
        from libs.backtest.execution import update_trailing_stop

        pos = _make_open_position(entry_price=100.0, stop=95.0)
        pos.peak_price = 100.0
        pos.days_held = 1  # below warmup
        # Bar low is 98 which would normally ratchet stop up
        update_trailing_stop(pos, _make_bar(high=105.0, low=98.0), warmup_days=2)
        assert pos.current_stop == pytest.approx(95.0)  # unchanged
        assert pos.peak_price == pytest.approx(105.0)   # peak still tracked

    def test_warmup_activates_after_period(self):
        """Trailing stop activates once warmup period is reached."""
        from libs.backtest.execution import update_trailing_stop

        pos = _make_open_position(entry_price=100.0, stop=95.0)
        pos.peak_price = 100.0
        pos.days_held = 2  # equals warmup → active
        update_trailing_stop(pos, _make_bar(high=105.0, low=98.0), warmup_days=2)
        assert pos.current_stop == pytest.approx(98.0)  # ratcheted up

    def test_pct_warmup_combined(self):
        """pct_3 trailing with warmup: no trailing during warmup, then activates."""
        from libs.backtest.execution import update_trailing_stop

        pos = _make_open_position(entry_price=100.0, stop=94.0)
        pos.peak_price = 100.0
        pos.days_held = 1
        # Day 1: warmup, peak tracks but stop unchanged
        update_trailing_stop(pos, _make_bar(high=108.0, low=102.0), trailing_model="pct_3", warmup_days=2)
        assert pos.current_stop == pytest.approx(94.0)
        assert pos.peak_price == pytest.approx(108.0)

        # Day 2: warmup over, trailing activates with accumulated peak
        pos.days_held = 2
        update_trailing_stop(pos, _make_bar(high=110.0, low=106.0), trailing_model="pct_3", warmup_days=2)
        # peak=110, trail=110*0.97=106.7
        assert pos.current_stop == pytest.approx(110.0 * 0.97)
