"""Unit tests for the trade setup outcome evaluation service."""

from __future__ import annotations

import json
from datetime import date, datetime, timedelta, timezone

import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.services.outcome_service import (
    OUTCOME_AMBIGUOUS,
    OUTCOME_EXPIRED,
    OUTCOME_STOP_HIT,
    OUTCOME_TARGET_HIT,
    Bar,
    evaluate_pending_setups,
    evaluate_setup_against_bars,
    get_performance_stats,
)


@pytest.fixture
async def outcome_session() -> AsyncSession:
    """DB session compatible with evaluate_pending_setups (which commits)."""
    from tests.conftest import _test_session_factory

    async with _test_session_factory() as session:
        yield session


def _bars(*hl: tuple[float, float], start: date = date(2026, 1, 5)) -> list[Bar]:
    return [
        Bar(date=start + timedelta(days=i), high=high, low=low)
        for i, (high, low) in enumerate(hl)
    ]


# ---------------------------------------------------------------------------
# evaluate_setup_against_bars — pure logic
# ---------------------------------------------------------------------------


class TestEvaluateSetupAgainstBars:
    def test_long_target_hit(self):
        # entry ~100, stop 95, target 110
        bars = _bars((105, 99), (111, 104))
        outcome, outcome_date = evaluate_setup_against_bars("long", 95, 110, bars)
        assert outcome == OUTCOME_TARGET_HIT
        assert outcome_date == bars[1].date

    def test_long_stop_hit(self):
        bars = _bars((105, 99), (103, 94))
        outcome, outcome_date = evaluate_setup_against_bars("long", 95, 110, bars)
        assert outcome == OUTCOME_STOP_HIT
        assert outcome_date == bars[1].date

    def test_long_stop_before_target_across_bars(self):
        # Stop hit on bar 0, target would hit on bar 1 — stop wins (first bar decides)
        bars = _bars((100, 94), (112, 100))
        outcome, _ = evaluate_setup_against_bars("long", 95, 110, bars)
        assert outcome == OUTCOME_STOP_HIT

    def test_short_target_hit(self):
        # short: entry ~100, stop 105, target 90
        bars = _bars((102, 96), (98, 89))
        outcome, outcome_date = evaluate_setup_against_bars("short", 105, 90, bars)
        assert outcome == OUTCOME_TARGET_HIT
        assert outcome_date == bars[1].date

    def test_short_stop_hit(self):
        bars = _bars((102, 96), (106, 98))
        outcome, _ = evaluate_setup_against_bars("short", 105, 90, bars)
        assert outcome == OUTCOME_STOP_HIT

    def test_ambiguous_when_both_levels_in_same_bar(self):
        # one giant bar spans both stop (95) and target (110)
        bars = _bars((112, 94))
        outcome, _ = evaluate_setup_against_bars("long", 95, 110, bars)
        assert outcome == OUTCOME_AMBIGUOUS

    def test_pending_when_not_enough_bars(self):
        bars = _bars((105, 99), (104, 98))
        outcome, outcome_date = evaluate_setup_against_bars("long", 95, 110, bars, max_bars=30)
        assert outcome is None
        assert outcome_date is None

    def test_expired_after_max_bars(self):
        bars = _bars(*[(105, 99)] * 10)
        outcome, outcome_date = evaluate_setup_against_bars("long", 95, 110, bars, max_bars=10)
        assert outcome == OUTCOME_EXPIRED
        assert outcome_date == bars[9].date

    def test_hit_beyond_max_bars_is_ignored(self):
        # target hit on bar 11 but window is 10 — expired
        bars = _bars(*[(105, 99)] * 10, (115, 105))
        outcome, _ = evaluate_setup_against_bars("long", 95, 110, bars, max_bars=10)
        assert outcome == OUTCOME_EXPIRED

    def test_no_bars_is_pending(self):
        outcome, _ = evaluate_setup_against_bars("long", 95, 110, [])
        assert outcome is None


# ---------------------------------------------------------------------------
# evaluate_pending_setups — DB integration
# ---------------------------------------------------------------------------


async def _make_ticker(db: AsyncSession, symbol: str = "AAPL") -> Ticker:
    ticker = Ticker(symbol=symbol)
    db.add(ticker)
    await db.flush()
    return ticker


def _make_setup(
    ticker: Ticker,
    direction: str = "long",
    entry: float = 100.0,
    stop: float = 95.0,
    target: float = 110.0,
    rr: float = 2.0,
    detected: datetime | None = None,
    **kwargs,
) -> TradeSetup:
    targets_json = kwargs.pop(
        "targets_json",
        json.dumps([{
            "price": target,
            "rr_ratio": rr,
            "probability": 50.0,
            "is_primary": True,
        }]),
    )
    return TradeSetup(
        ticker_id=ticker.id,
        direction=direction,
        entry_price=entry,
        stop_loss=stop,
        target=target,
        rr_ratio=rr,
        composite_score=50.0,
        detected_at=detected or datetime(2026, 1, 2, 21, 0, tzinfo=timezone.utc),
        targets_json=targets_json,
        **kwargs,
    )


def _add_bars(db: AsyncSession, ticker: Ticker, *hl: tuple[float, float], start: date = date(2026, 1, 5)):
    for i, (high, low) in enumerate(hl):
        db.add(OHLCVRecord(
            ticker_id=ticker.id,
            date=start + timedelta(days=i),
            open=(high + low) / 2,
            high=high,
            low=low,
            close=(high + low) / 2,
            volume=1_000_000,
        ))


class TestEvaluatePendingSetups:
    async def test_writes_outcome_and_metadata(self, outcome_session: AsyncSession):
        ticker = await _make_ticker(outcome_session)
        setup = _make_setup(ticker)
        outcome_session.add(setup)
        _add_bars(outcome_session, ticker, (105, 99), (111, 104))
        await outcome_session.flush()

        summary = await evaluate_pending_setups(outcome_session)

        assert summary["evaluated"] == 1
        assert summary["by_outcome"] == {OUTCOME_TARGET_HIT: 1}
        result = await outcome_session.execute(select(TradeSetup))
        stored = result.scalar_one()
        assert stored.actual_outcome == OUTCOME_TARGET_HIT
        assert stored.outcome_date == date(2026, 1, 6)
        assert stored.evaluated_at is not None

    async def test_undecided_setup_stays_pending(self, outcome_session: AsyncSession):
        ticker = await _make_ticker(outcome_session)
        outcome_session.add(_make_setup(ticker))
        _add_bars(outcome_session, ticker, (105, 99))  # no level hit, < max_bars
        await outcome_session.flush()

        summary = await evaluate_pending_setups(outcome_session)

        assert summary["evaluated"] == 0
        assert summary["still_pending"] == 1
        result = await outcome_session.execute(select(TradeSetup))
        assert result.scalar_one().actual_outcome is None

    async def test_only_bars_after_detection_are_used(self, outcome_session: AsyncSession):
        ticker = await _make_ticker(outcome_session)
        # bar on the detection date itself would hit the stop — must be ignored
        _add_bars(outcome_session, ticker, (100, 90), start=date(2026, 1, 2))
        _add_bars(outcome_session, ticker, (111, 104), start=date(2026, 1, 5))
        outcome_session.add(_make_setup(ticker))
        await outcome_session.flush()

        await evaluate_pending_setups(outcome_session)

        result = await outcome_session.execute(select(TradeSetup))
        assert result.scalar_one().actual_outcome == OUTCOME_TARGET_HIT

    async def test_already_evaluated_setups_are_skipped(self, outcome_session: AsyncSession):
        ticker = await _make_ticker(outcome_session)
        outcome_session.add(_make_setup(ticker, actual_outcome=OUTCOME_STOP_HIT))
        await outcome_session.flush()

        summary = await evaluate_pending_setups(outcome_session)

        assert summary["evaluated"] == 0
        assert summary["still_pending"] == 0


# ---------------------------------------------------------------------------
# get_performance_stats
# ---------------------------------------------------------------------------


class TestGetPerformanceStats:
    async def test_empty_database(self, db_session: AsyncSession):
        stats = await get_performance_stats(db_session)
        assert stats["overall"]["total"] == 0
        assert stats["overall"]["hit_rate"] is None
        assert stats["pending"] == 0

    async def test_aggregation(self, db_session: AsyncSession):
        ticker = await _make_ticker(db_session)
        db_session.add(_make_setup(
            ticker, direction="long", rr=3.0,
            actual_outcome=OUTCOME_TARGET_HIT, confidence_score=80.0,
            recommended_action="LONG_HIGH",
        ))
        db_session.add(_make_setup(
            ticker, direction="long", rr=2.0,
            actual_outcome=OUTCOME_STOP_HIT, confidence_score=55.0,
            recommended_action="LONG_MODERATE",
        ))
        db_session.add(_make_setup(
            ticker, direction="short", rr=2.5,
            actual_outcome=OUTCOME_EXPIRED, confidence_score=40.0,
            recommended_action="NEUTRAL",
        ))
        db_session.add(_make_setup(ticker, direction="short"))  # pending
        await db_session.flush()

        stats = await get_performance_stats(db_session)

        overall = stats["overall"]
        assert overall["total"] == 3
        assert overall["wins"] == 1
        assert overall["losses"] == 1
        assert overall["expired"] == 1
        assert overall["hit_rate"] == 50.0
        # realized: +3.0 (win), -1.0 (loss), 0.0 (expired) → avg 0.667
        assert overall["avg_r"] == pytest.approx(0.667, abs=0.001)
        assert overall["total_r"] == pytest.approx(2.0)
        assert stats["pending"] == 1

        assert stats["by_direction"]["long"]["total"] == 2
        assert stats["by_direction"]["short"]["total"] == 1
        assert stats["by_action"]["LONG_HIGH"]["wins"] == 1
        assert stats["by_confidence"]["≥70%"]["wins"] == 1
        assert stats["by_confidence"]["50-70%"]["losses"] == 1
        assert stats["by_confidence"]["<50%"]["expired"] == 1

    async def test_ambiguous_counts_as_loss(self, db_session: AsyncSession):
        ticker = await _make_ticker(db_session)
        db_session.add(_make_setup(ticker, actual_outcome=OUTCOME_AMBIGUOUS))
        await db_session.flush()

        stats = await get_performance_stats(db_session)

        assert stats["overall"]["losses"] == 1
        assert stats["overall"]["hit_rate"] == 0.0
        assert stats["overall"]["avg_r"] == -1.0

    async def test_activation_filters_apply_to_overall_but_not_confidence(
        self, db_session: AsyncSession
    ):
        ticker = await _make_ticker(db_session)
        # Qualified: high confidence, high R:R
        db_session.add(_make_setup(
            ticker, rr=3.0, confidence_score=80.0, actual_outcome=OUTCOME_TARGET_HIT,
        ))
        # Unqualified: low confidence
        db_session.add(_make_setup(
            ticker, rr=3.0, confidence_score=40.0, actual_outcome=OUTCOME_STOP_HIT,
        ))
        # Unqualified: low R:R
        db_session.add(_make_setup(
            ticker, rr=1.2, confidence_score=90.0, actual_outcome=OUTCOME_STOP_HIT,
        ))
        await db_session.flush()

        # Gate on R:R + confidence only (conviction filters off for this test)
        config = {
            "min_rr": 2.0,
            "min_confidence": 70.0,
            "min_target_probability": 0.0,
            "require_high_conviction": False,
            "exclude_conflicts": False,
        }
        stats = await get_performance_stats(db_session, config=config)

        # Overall covers only the qualified setup
        assert stats["overall"]["total"] == 1
        assert stats["overall"]["wins"] == 1
        assert stats["overall"]["hit_rate"] == 100.0

        # Confidence breakdown still covers the full population
        total_in_confidence = sum(
            bucket["total"] for bucket in stats["by_confidence"].values()
        )
        assert total_in_confidence == 3

    async def test_no_filters_returns_full_population(self, db_session: AsyncSession):
        ticker = await _make_ticker(db_session)
        db_session.add(_make_setup(ticker, rr=1.2, confidence_score=10.0, actual_outcome=OUTCOME_TARGET_HIT))
        await db_session.flush()

        stats = await get_performance_stats(db_session)
        assert stats["overall"]["total"] == 1

    async def test_immature_setups_excluded_and_counted_as_maturing(self, db_session: AsyncSession):
        ticker = await _make_ticker(db_session)
        now = datetime.now(timezone.utc)
        # Matured (detected well beyond the window) → counted in the stats.
        db_session.add(_make_setup(
            ticker, rr=2.0, actual_outcome=OUTCOME_TARGET_HIT, detected=now - timedelta(days=90),
        ))
        # Young but already resolved → excluded from stats, reported as maturing.
        db_session.add(_make_setup(
            ticker, rr=2.0, actual_outcome=OUTCOME_STOP_HIT, detected=now,
        ))
        # Young and still pending → also maturing.
        db_session.add(_make_setup(ticker, detected=now))
        await db_session.flush()

        stats = await get_performance_stats(db_session)

        assert stats["overall"]["total"] == 1   # only the matured win
        assert stats["overall"]["wins"] == 1
        assert stats["overall"]["hit_rate"] == 100.0
        assert stats["pending"] == 1
        assert stats["maturing"] == 2           # young resolved + pending
