"""Unit tests for services.signal_engine.exit_engine — Exit Engine.

Tests stop-loss triggers, target-1 partial exits, target-2 full exits,
trailing stop activation/ratchet behavior, priority ordering, empty
positions, and fallback to position.current_price when ticker is absent
from current_prices.

Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7
"""
from __future__ import annotations

from services.signal_engine.config import ExitConfig
from services.signal_engine.exit_engine import evaluate_exits
from services.signal_engine.models import (
    ExitType,
    OpenPositionState,
)

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


def _default_config() -> ExitConfig:
    return ExitConfig(trailing_stop_atr_multiplier=2.0)


def _position(
    *,
    position_id: str = "pos-1",
    ticker: str = "AAPL",
    entry_price: float = 100.0,
    current_price: float = 110.0,
    stop_loss: float = 90.0,
    target_1: float = 115.0,
    target_2: float = 130.0,
    trailing_stop: float | None = None,
    partial_exit_done: bool = False,
    atr: float | None = 5.0,
) -> OpenPositionState:
    return OpenPositionState(
        position_id=position_id,
        ticker=ticker,
        entry_price=entry_price,
        current_price=current_price,
        stop_loss=stop_loss,
        target_1=target_1,
        target_2=target_2,
        trailing_stop=trailing_stop,
        partial_exit_done=partial_exit_done,
        atr=atr,
    )


# ===========================================================================
# 1. Stop-loss trigger (Requirement 8.1)
# ===========================================================================


class TestStopLoss:
    """Stop-loss hit → EXIT_FULL with reason 'stop_hit'."""

    def test_stop_loss_exact_hit(self) -> None:
        """Price exactly at stop_loss triggers exit."""
        pos = _position(stop_loss=90.0)
        signals = evaluate_exits([pos], {"AAPL": 90.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].exit_type == ExitType.EXIT_FULL
        assert signals[0].reason == "stop_hit"
        assert signals[0].price == 90.0
        assert signals[0].position_id == "pos-1"
        assert signals[0].ticker == "AAPL"

    def test_stop_loss_below(self) -> None:
        """Price below stop_loss triggers exit."""
        pos = _position(stop_loss=90.0)
        signals = evaluate_exits([pos], {"AAPL": 85.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].exit_type == ExitType.EXIT_FULL
        assert signals[0].reason == "stop_hit"
        assert signals[0].price == 85.0

    def test_stop_loss_has_highest_priority(self) -> None:
        """Stop-loss takes priority even when target_2 is also hit.

        This can happen if stop_loss >= target_2 due to misconfiguration,
        or if the price gaps through both levels.
        """
        # Contrived: stop_loss at 130, target_2 at 120 (misconfigured)
        pos = _position(stop_loss=130.0, target_2=120.0)
        signals = evaluate_exits([pos], {"AAPL": 125.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].reason == "stop_hit"


# ===========================================================================
# 2. Target-1 partial exit (Requirement 8.2)
# ===========================================================================


class TestTarget1:
    """Target-1 hit → EXIT_HALF with reason 'target_1_hit'."""

    def test_target_1_exact_hit(self) -> None:
        """Price exactly at target_1 triggers partial exit."""
        pos = _position(target_1=115.0)
        signals = evaluate_exits([pos], {"AAPL": 115.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].exit_type == ExitType.EXIT_HALF
        assert signals[0].reason == "target_1_hit"
        assert signals[0].price == 115.0

    def test_target_1_above(self) -> None:
        """Price above target_1 (but below target_2) triggers partial exit."""
        pos = _position(target_1=115.0, target_2=130.0)
        signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].exit_type == ExitType.EXIT_HALF
        assert signals[0].reason == "target_1_hit"

    def test_target_1_not_triggered_when_partial_exit_done(self) -> None:
        """Target-1 is skipped when partial_exit_done is True."""
        pos = _position(target_1=115.0, target_2=130.0, partial_exit_done=True, atr=5.0)
        # Price above target_1 but below target_2, trailing stop not hit
        signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())

        # No target_1_hit signal; trailing stop is 120 - 5*2 = 110, not hit
        assert len(signals) == 0


# ===========================================================================
# 3. Target-2 full exit (Requirement 8.3)
# ===========================================================================


class TestTarget2:
    """Target-2 hit → EXIT_FULL with reason 'target_2_hit'."""

    def test_target_2_exact_hit(self) -> None:
        """Price exactly at target_2 triggers full exit."""
        pos = _position(target_2=130.0)
        signals = evaluate_exits([pos], {"AAPL": 130.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].exit_type == ExitType.EXIT_FULL
        assert signals[0].reason == "target_2_hit"
        assert signals[0].price == 130.0

    def test_target_2_above(self) -> None:
        """Price above target_2 triggers full exit."""
        pos = _position(target_2=130.0)
        signals = evaluate_exits([pos], {"AAPL": 140.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].exit_type == ExitType.EXIT_FULL
        assert signals[0].reason == "target_2_hit"

    def test_target_2_priority_over_target_1(self) -> None:
        """When price hits both target_1 and target_2, target_2 wins."""
        pos = _position(target_1=115.0, target_2=130.0)
        signals = evaluate_exits([pos], {"AAPL": 135.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].reason == "target_2_hit"
        assert signals[0].exit_type == ExitType.EXIT_FULL


# ===========================================================================
# 4. Trailing stop activation and ratchet (Requirement 8.4)
# ===========================================================================


class TestTrailingStop:
    """Trailing stop activates after partial exit and ratchets upward."""

    def test_trailing_stop_not_active_before_partial_exit(self) -> None:
        """Trailing stop does not trigger when partial_exit_done is False."""
        pos = _position(
            partial_exit_done=False,
            trailing_stop=108.0,
            atr=5.0,
            target_1=115.0,
            target_2=130.0,
            stop_loss=90.0,
        )
        # Price at 107 is below trailing_stop=108, but trailing is not active
        signals = evaluate_exits([pos], {"AAPL": 107.0}, _default_config())

        # No trailing stop signal; price is above stop_loss and below targets
        assert len(signals) == 0

    def test_trailing_stop_computed_from_atr(self) -> None:
        """Trailing stop = price - ATR * multiplier when no existing stop."""
        pos = _position(
            partial_exit_done=True,
            trailing_stop=None,
            atr=5.0,
            target_2=150.0,
        )
        # Price = 120, trailing = 120 - 5*2 = 110, price > 110 → no exit
        signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
        assert len(signals) == 0

    def test_trailing_stop_ratchets_upward(self) -> None:
        """New trailing stop level is used only if higher than existing."""
        pos = _position(
            partial_exit_done=True,
            trailing_stop=112.0,  # existing high trailing stop
            atr=5.0,
            target_2=150.0,
        )
        # Price = 120, new trailing = 120 - 10 = 110 < existing 112
        # Effective trailing = 112 (ratchet keeps higher value)
        # Price 120 > 112 → no exit
        signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
        assert len(signals) == 0

    def test_trailing_stop_updates_when_price_advances(self) -> None:
        """Higher price produces higher trailing stop level."""
        pos = _position(
            partial_exit_done=True,
            trailing_stop=105.0,  # old trailing stop
            atr=5.0,
            target_2=200.0,
        )
        # Price = 130, new trailing = 130 - 10 = 120 > existing 105
        # Effective trailing = 120, price 130 > 120 → no exit
        signals = evaluate_exits([pos], {"AAPL": 130.0}, _default_config())
        assert len(signals) == 0

    def test_trailing_stop_no_atr_uses_existing(self) -> None:
        """When ATR is None, existing trailing_stop is used as-is."""
        pos = _position(
            partial_exit_done=True,
            trailing_stop=115.0,
            atr=None,
            target_2=150.0,
        )
        # Price = 120 > trailing 115 → no exit
        signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
        assert len(signals) == 0

    def test_trailing_stop_no_atr_no_existing_returns_zero(self) -> None:
        """When ATR is None and trailing_stop is None, effective stop is 0."""
        pos = _position(
            partial_exit_done=True,
            trailing_stop=None,
            atr=None,
            target_2=150.0,
        )
        # Effective trailing = 0.0, price 120 > 0 → no exit
        signals = evaluate_exits([pos], {"AAPL": 120.0}, _default_config())
        assert len(signals) == 0


# ===========================================================================
# 5. Trailing stop hit (Requirement 8.5)
# ===========================================================================


class TestTrailingStopHit:
    """Trailing stop hit → EXIT_FULL with reason 'trailing_stop_hit'."""

    def test_trailing_stop_hit_exact(self) -> None:
        """Price exactly at trailing stop triggers exit."""
        pos = _position(
            partial_exit_done=True,
            trailing_stop=115.0,
            atr=5.0,
            target_2=150.0,
        )
        # Price = 115, new trailing = 115 - 10 = 105 < existing 115
        # Effective trailing = 115, price 115 <= 115 → exit
        signals = evaluate_exits([pos], {"AAPL": 115.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].exit_type == ExitType.EXIT_FULL
        assert signals[0].reason == "trailing_stop_hit"
        assert signals[0].price == 115.0

    def test_trailing_stop_hit_below(self) -> None:
        """Price below trailing stop triggers exit."""
        pos = _position(
            partial_exit_done=True,
            trailing_stop=115.0,
            atr=5.0,
            target_2=150.0,
        )
        # Price = 110, new trailing = 110 - 10 = 100 < existing 115
        # Effective trailing = 115, price 110 <= 115 → exit
        signals = evaluate_exits([pos], {"AAPL": 110.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].reason == "trailing_stop_hit"

    def test_trailing_stop_hit_computed_from_atr(self) -> None:
        """Trailing stop computed from ATR triggers exit when price drops."""
        pos = _position(
            partial_exit_done=True,
            trailing_stop=None,  # no existing trailing stop
            atr=3.0,
            target_2=150.0,
        )
        # Price = 100, trailing = 100 - 3*2 = 94, max(0, 94) = 94
        # Price 100 > 94 → no exit
        signals = evaluate_exits([pos], {"AAPL": 100.0}, _default_config())
        assert len(signals) == 0

        # Now price drops to 93 → trailing = 93 - 6 = 87, max(0, 87) = 87
        # Price 93 > 87 → still no exit (trailing recomputed each call)
        signals2 = evaluate_exits([pos], {"AAPL": 93.0}, _default_config())
        assert len(signals2) == 0

    def test_trailing_stop_hit_with_high_existing_stop(self) -> None:
        """Existing high trailing stop triggers exit when price drops to it."""
        pos = _position(
            partial_exit_done=True,
            trailing_stop=118.0,  # previously ratcheted up
            atr=5.0,
            target_2=150.0,
        )
        # Price = 117, new trailing = 117 - 10 = 107 < existing 118
        # Effective trailing = 118, price 117 <= 118 → exit
        signals = evaluate_exits([pos], {"AAPL": 117.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].reason == "trailing_stop_hit"


# ===========================================================================
# 6. No exit when price is between stop and targets
# ===========================================================================


class TestNoExit:
    """No exit signal when price is in the safe zone."""

    def test_price_between_stop_and_target_1(self) -> None:
        """Price above stop_loss and below target_1 → no exit."""
        pos = _position(stop_loss=90.0, target_1=115.0, target_2=130.0)
        signals = evaluate_exits([pos], {"AAPL": 105.0}, _default_config())
        assert len(signals) == 0

    def test_price_just_above_stop_loss(self) -> None:
        """Price barely above stop_loss → no exit."""
        pos = _position(stop_loss=90.0)
        signals = evaluate_exits([pos], {"AAPL": 90.01}, _default_config())
        assert len(signals) == 0

    def test_price_just_below_target_1(self) -> None:
        """Price barely below target_1 → no exit."""
        pos = _position(target_1=115.0)
        signals = evaluate_exits([pos], {"AAPL": 114.99}, _default_config())
        assert len(signals) == 0


# ===========================================================================
# 7. Empty positions list
# ===========================================================================


class TestEmptyPositions:
    """Empty positions list returns empty signals list."""

    def test_empty_positions(self) -> None:
        signals = evaluate_exits([], {"AAPL": 100.0}, _default_config())
        assert signals == []

    def test_empty_positions_empty_prices(self) -> None:
        signals = evaluate_exits([], {}, _default_config())
        assert signals == []


# ===========================================================================
# 8. Fallback to position.current_price when ticker not in current_prices
# ===========================================================================


class TestPriceFallback:
    """When ticker is absent from current_prices, use position.current_price."""

    def test_uses_position_current_price_as_fallback(self) -> None:
        """Ticker not in current_prices → falls back to position.current_price."""
        pos = _position(
            ticker="MSFT",
            current_price=85.0,  # below stop_loss
            stop_loss=90.0,
        )
        # "MSFT" not in current_prices → uses 85.0
        signals = evaluate_exits([pos], {"AAPL": 200.0}, _default_config())

        assert len(signals) == 1
        assert signals[0].reason == "stop_hit"
        assert signals[0].price == 85.0

    def test_uses_current_prices_when_available(self) -> None:
        """Ticker in current_prices → uses that price, not position.current_price."""
        pos = _position(
            ticker="AAPL",
            current_price=85.0,  # would trigger stop
            stop_loss=90.0,
        )
        # current_prices has AAPL at 105 → above stop, no exit
        signals = evaluate_exits([pos], {"AAPL": 105.0}, _default_config())
        assert len(signals) == 0

    def test_fallback_triggers_target(self) -> None:
        """Fallback price can trigger target exits too."""
        pos = _position(
            ticker="TSLA",
            current_price=135.0,  # above target_2
            target_2=130.0,
        )
        signals = evaluate_exits([pos], {}, _default_config())

        assert len(signals) == 1
        assert signals[0].reason == "target_2_hit"
        assert signals[0].price == 135.0


# ===========================================================================
# 9. Multiple positions
# ===========================================================================


class TestMultiplePositions:
    """Multiple positions evaluated independently."""

    def test_multiple_positions_different_exits(self) -> None:
        """Each position evaluated independently; different exit types."""
        pos1 = _position(position_id="p1", ticker="AAPL", stop_loss=90.0)
        pos2 = _position(position_id="p2", ticker="MSFT", stop_loss=40.0, target_1=50.0, target_2=130.0)
        pos3 = _position(position_id="p3", ticker="GOOG")

        prices = {"AAPL": 85.0, "MSFT": 55.0, "GOOG": 105.0}
        signals = evaluate_exits([pos1, pos2, pos3], prices, _default_config())

        assert len(signals) == 2  # AAPL stop hit, MSFT target_1 hit, GOOG no exit

        by_id = {s.position_id: s for s in signals}
        assert by_id["p1"].reason == "stop_hit"
        assert by_id["p2"].reason == "target_1_hit"
        assert "p3" not in by_id

    def test_all_positions_no_exit(self) -> None:
        """All positions in safe zone → empty signals."""
        pos1 = _position(position_id="p1", stop_loss=80.0, target_1=120.0)
        pos2 = _position(position_id="p2", stop_loss=80.0, target_1=120.0)

        signals = evaluate_exits(
            [pos1, pos2],
            {"AAPL": 100.0},
            _default_config(),
        )
        assert len(signals) == 0


# ===========================================================================
# 10. Custom config — trailing_stop_atr_multiplier
# ===========================================================================


class TestCustomExitConfig:
    """Custom ATR multiplier affects trailing stop computation."""

    def test_higher_multiplier_wider_trailing_stop(self) -> None:
        """Higher multiplier → wider trailing stop → less likely to trigger."""
        # Use a pre-set trailing_stop that was ratcheted up previously.
        # With tight config the existing trailing stop triggers; with wide
        # config we use a lower existing stop that doesn't trigger.
        pos_tight = _position(
            partial_exit_done=True,
            trailing_stop=110.0,  # previously ratcheted high
            atr=5.0,
            target_2=200.0,
        )
        pos_wide = _position(
            partial_exit_done=True,
            trailing_stop=100.0,  # lower trailing stop
            atr=5.0,
            target_2=200.0,
        )
        config = _default_config()

        # Price at 108: tight trailing=max(110, 108-10)=110 → hit
        signals_tight = evaluate_exits([pos_tight], {"AAPL": 108.0}, config)
        # Price at 108: wide trailing=max(100, 108-10)=100 → not hit (108 > 100)
        signals_wide = evaluate_exits([pos_wide], {"AAPL": 108.0}, config)

        assert len(signals_tight) == 1
        assert signals_tight[0].reason == "trailing_stop_hit"
        assert len(signals_wide) == 0
