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

import datetime as dt
from zoneinfo import ZoneInfo

import pytest

from libs.backtest.domain import (
    EventTypeProfile,
    SignalConfig,
    StrategyEngineConfig,
    UniverseConfig,
)

_UTC = ZoneInfo("UTC")


def _make_raw_row(**kwargs) -> dict:
    defaults = {
        "event_id": "EVT::TEST::001",
        "symbol": "AAPL",
        "issuer_id": "ISSUER::0000320193",
        "score": 0.75,
        "sector": "Technology",
        "event_type": "earnings",
        "event_timestamp": "2026-01-05T21:00:00+00:00",
        "filing_time_bucket": "post_market",
        "reaction_date": "2026-01-06",
        "entry_date": "2026-01-07",
        "entry_price": 150.0,
        "avg_dollar_volume": 5_000_000.0,
        "atr_14": 3.5,
    }
    defaults.update(kwargs)
    return defaults


class TestBuildCandidate:
    def test_basic(self):
        from libs.backtest.selector import build_candidate

        row = _make_raw_row()
        c = build_candidate(row)
        assert c is not None
        assert c.symbol == "AAPL"
        assert c.score == 0.75
        assert c.execution_date == dt.date(2026, 1, 7)
        assert c.event_timestamp.tzinfo is not None
        assert c.timing_class == "after_close"

    def test_null_timestamp_returns_none(self):
        from libs.backtest.selector import build_candidate

        row = _make_raw_row(event_timestamp=None)
        assert build_candidate(row) is None

    def test_zero_entry_price_returns_none(self):
        from libs.backtest.selector import build_candidate

        row = _make_raw_row(entry_price=0.0)
        assert build_candidate(row) is None

    def test_missing_entry_price_returns_none(self):
        from libs.backtest.selector import build_candidate

        row = _make_raw_row()
        del row["entry_price"]
        assert build_candidate(row) is None

    def test_null_exec_date_returns_none(self):
        from libs.backtest.selector import build_candidate

        row = _make_raw_row()
        del row["entry_date"]
        assert build_candidate(row) is None

    def test_sector_defaults_to_unknown(self):
        from libs.backtest.selector import build_candidate

        row = _make_raw_row(sector=None)
        c = build_candidate(row)
        assert c is not None
        assert c.sector == "UNKNOWN"

    def test_score_bucket_classification(self):
        from libs.backtest.selector import build_candidate

        c = build_candidate(_make_raw_row(score=0.85))
        assert c.score_bucket == "high"

        c = build_candidate(_make_raw_row(score=0.65))
        assert c.score_bucket == "medium_high"

        c = build_candidate(_make_raw_row(score=0.45))
        assert c.score_bucket == "medium"

        c = build_candidate(_make_raw_row(score=0.25))
        assert c.score_bucket == "medium_low"

        c = build_candidate(_make_raw_row(score=0.10))
        assert c.score_bucket == "low"

    def test_same_day_timing_and_direction_from_reaction(self):
        from libs.backtest.selector import build_candidate

        row = _make_raw_row(
            event_date="2026-01-06",
            reaction_date="2026-01-06",
            execution_date="2026-01-07",
            trade_direction="",
            reaction_day_return=-0.12,
        )
        c = build_candidate(row)
        assert c is not None
        assert c.event_date == dt.date(2026, 1, 6)
        assert c.timing_class == "same_day"
        assert c.trade_direction == "short"

    def test_reaction_close_engine_uses_event_close(self):
        from libs.backtest.selector import build_candidate

        engine = StrategyEngineConfig(
            engine_id="earnings_same_day_long_close_v1",
            event_types=["earnings"],
            timing_class="same_day",
            direction="long_only",
            entry_timing_policy="reaction_close",
            max_holding_days=3,
            engine_risk_budget_pct=0.35,
        )
        row = _make_raw_row(
            event_date="2026-01-06",
            reaction_date="2026-01-06",
            event_close=149.5,
            entry_date="2026-01-07",
            reaction_day_return=0.11,
        )
        c = build_candidate(row, strategy_engine=engine)
        assert c is not None
        assert c.execution_date == dt.date(2026, 1, 6)
        assert c.entry_price_est == pytest.approx(149.5)
        assert c.engine_id == "earnings_same_day_long_close_v1"
        assert c.entry_timing_policy == "reaction_close"

    def test_engine_execution_overrides_are_copied_to_candidate(self):
        from libs.backtest.selector import build_candidate

        engine = StrategyEngineConfig(
            engine_id="earnings_same_day_long_trend_v1",
            event_types=["earnings"],
            timing_class="same_day",
            direction="long_only",
            entry_timing_policy="reaction_close",
            max_holding_days=12,
            engine_risk_budget_pct=0.25,
            target_atr_multiplier_override=2.5,
            target_1_fraction_override=0.33,
            trailing_model_override="pct_10",
            trailing_warmup_days_override=2,
        )
        row = _make_raw_row(
            event_date="2026-01-06",
            reaction_date="2026-01-06",
            event_close=149.5,
            entry_date="2026-01-07",
            reaction_day_return=0.11,
        )
        c = build_candidate(row, strategy_engine=engine)
        assert c is not None
        assert c.engine_max_holding_days == 12
        assert c.engine_risk_budget_pct == pytest.approx(0.25)
        assert c.engine_target_atr_multiplier == pytest.approx(2.5)
        assert c.engine_target_1_fraction == pytest.approx(0.33)
        assert c.engine_trailing_model == "pct_10"
        assert c.engine_trailing_warmup_days == 2

    def test_engine_route_skips_non_matching_direction(self):
        from libs.backtest.selector import build_candidate

        engine = StrategyEngineConfig(
            engine_id="short_only_engine",
            event_types=["earnings"],
            timing_class="after_close",
            direction="short_only",
        )
        row = _make_raw_row(reaction_day_return=0.09, trade_direction="long")
        assert build_candidate(row, strategy_engine=engine) is None

    def test_select_candidates_recomputes_pead_score_for_engine_thresholds(self):
        from libs.backtest.selector import select_candidates

        rows = [
            _make_raw_row(
                symbol="LOWVOL",
                score=0.8,
                event_type="earnings_release",
                reaction_day_return=0.14,
                volume_ratio_20d=2.4,
                gap_size=0.01,
            ),
            _make_raw_row(
                symbol="HIGHVOL",
                score=0.8,
                event_type="earnings_release",
                reaction_day_return=0.14,
                volume_ratio_20d=4.5,
                gap_size=0.01,
            ),
        ]
        universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0)
        signal = SignalConfig(
            scoring_model="pead",
            score_threshold=0.65,
            pead_reaction_threshold=0.10,
            pead_volume_threshold=2.0,
            max_candidates_per_day=5,
        )
        engine = StrategyEngineConfig(
            engine_id="after_close_long_quality",
            event_types=["earnings_release"],
            timing_class="after_close",
            direction="long_only",
            pead_volume_threshold_override=3.0,
        )

        selected = select_candidates(rows, universe, signal, strategy_engine=engine)
        assert [candidate.symbol for candidate in selected] == ["HIGHVOL"]

    def test_select_candidates_uses_engine_score_threshold_override(self):
        from libs.backtest.selector import select_candidates

        rows = [
            _make_raw_row(
                symbol="PASS",
                score=0.78,
                event_type="earnings_release",
                reaction_day_return=0.16,
                volume_ratio_20d=4.0,
                gap_size=0.01,
            ),
            _make_raw_row(
                symbol="FAIL",
                score=0.72,
                event_type="earnings_release",
                reaction_day_return=0.14,
                volume_ratio_20d=4.0,
                gap_size=0.01,
            ),
        ]
        universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0)
        signal = SignalConfig(
            scoring_model="pead",
            score_threshold=0.65,
            pead_reaction_threshold=0.10,
            pead_volume_threshold=2.0,
            max_candidates_per_day=5,
        )
        engine = StrategyEngineConfig(
            engine_id="after_close_long_quality",
            event_types=["earnings_release"],
            timing_class="after_close",
            direction="long_only",
            score_threshold_override=0.75,
        )

        selected = select_candidates(rows, universe, signal, strategy_engine=engine)
        assert [candidate.symbol for candidate in selected] == ["PASS"]

    def test_select_candidates_respects_engine_reaction_day_return_bounds(self):
        from libs.backtest.selector import select_candidates

        rows = [
            _make_raw_row(
                symbol="CRASH",
                score=0.85,
                event_type="earnings_release",
                event_date="2026-01-06",
                reaction_date="2026-01-06",
                reaction_day_return=-0.52,
                volume_ratio_20d=8.0,
                trade_direction="short",
            ),
            _make_raw_row(
                symbol="NORMAL",
                score=0.82,
                event_type="earnings_release",
                event_date="2026-01-06",
                reaction_date="2026-01-06",
                reaction_day_return=-0.18,
                volume_ratio_20d=5.0,
                trade_direction="short",
            ),
        ]
        universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0)
        signal = SignalConfig(
            scoring_model="pead",
            score_threshold=0.65,
            pead_reaction_threshold=0.10,
            pead_volume_threshold=2.0,
            max_candidates_per_day=5,
        )
        engine = StrategyEngineConfig(
            engine_id="same_day_short_filtered",
            event_types=["earnings_release"],
            timing_class="same_day",
            direction="short_only",
            reaction_day_return_min=-0.45,
        )

        selected = select_candidates(rows, universe, signal, strategy_engine=engine)
        assert [candidate.symbol for candidate in selected] == ["NORMAL"]

    def test_select_candidates_respects_engine_reaction_day_return_upper_bound(self):
        from libs.backtest.selector import select_candidates

        rows = [
            _make_raw_row(
                symbol="TOO_HOT",
                score=0.90,
                event_type="earnings_release",
                event_date="2026-01-06",
                reaction_date="2026-01-06",
                reaction_day_return=0.42,
                volume_ratio_20d=6.0,
                trade_direction="long",
            ),
            _make_raw_row(
                symbol="OK",
                score=0.80,
                event_type="earnings_release",
                event_date="2026-01-06",
                reaction_date="2026-01-06",
                reaction_day_return=0.18,
                volume_ratio_20d=4.0,
                trade_direction="long",
            ),
        ]
        universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0)
        signal = SignalConfig(
            scoring_model="pead",
            score_threshold=0.65,
            pead_reaction_threshold=0.10,
            pead_volume_threshold=2.0,
            max_candidates_per_day=5,
        )
        engine = StrategyEngineConfig(
            engine_id="same_day_long_capped",
            event_types=["earnings_release"],
            timing_class="same_day",
            direction="long_only",
            reaction_day_return_max=0.30,
        )

        selected = select_candidates(rows, universe, signal, strategy_engine=engine)
        assert [candidate.symbol for candidate in selected] == ["OK"]

    def test_select_candidates_respects_engine_gap_size_bounds(self):
        from libs.backtest.selector import select_candidates

        rows = [
            _make_raw_row(
                symbol="TIGHT",
                score=0.82,
                event_type="earnings_release",
                event_date="2026-01-06",
                reaction_date="2026-01-06",
                reaction_day_return=0.18,
                volume_ratio_20d=4.0,
                gap_size=0.04,
                trade_direction="long",
            ),
            _make_raw_row(
                symbol="WIDE",
                score=0.84,
                event_type="earnings_release",
                event_date="2026-01-06",
                reaction_date="2026-01-06",
                reaction_day_return=0.18,
                volume_ratio_20d=4.0,
                gap_size=0.16,
                trade_direction="long",
            ),
            _make_raw_row(
                symbol="TOO_WIDE",
                score=0.86,
                event_type="earnings_release",
                event_date="2026-01-06",
                reaction_date="2026-01-06",
                reaction_day_return=0.18,
                volume_ratio_20d=4.0,
                gap_size=0.34,
                trade_direction="long",
            ),
        ]
        universe = UniverseConfig(min_price=5.0, min_avg_dollar_volume=0.0)
        signal = SignalConfig(
            scoring_model="pead",
            score_threshold=0.65,
            pead_reaction_threshold=0.10,
            pead_volume_threshold=2.0,
            max_candidates_per_day=5,
        )
        engine = StrategyEngineConfig(
            engine_id="same_day_long_gapped",
            event_types=["earnings_release"],
            timing_class="same_day",
            direction="long_only",
            gap_size_min=0.10,
            gap_size_max=0.30,
        )

        selected = select_candidates(rows, universe, signal, strategy_engine=engine)
        assert [candidate.symbol for candidate in selected] == ["WIDE"]


class TestRankCandidates:
    def test_sorted_by_score_desc(self):
        from libs.backtest.selector import build_candidate, rank_candidates

        rows = [
            _make_raw_row(symbol="A", score=0.5, avg_dollar_volume=1e6),
            _make_raw_row(symbol="B", score=0.8, avg_dollar_volume=1e6),
            _make_raw_row(symbol="C", score=0.6, avg_dollar_volume=1e6),
        ]
        candidates = [build_candidate(r) for r in rows]
        ranked = rank_candidates([c for c in candidates if c])
        assert ranked[0].symbol == "B"
        assert ranked[1].symbol == "C"
        assert ranked[2].symbol == "A"

    def test_tiebreak_by_avg_dollar_volume(self):
        from libs.backtest.selector import build_candidate, rank_candidates

        rows = [
            _make_raw_row(symbol="A", score=0.7, avg_dollar_volume=1e6),
            _make_raw_row(symbol="B", score=0.7, avg_dollar_volume=5e6),
        ]
        candidates = [build_candidate(r) for r in rows]
        ranked = rank_candidates([c for c in candidates if c])
        assert ranked[0].symbol == "B"  # higher avg_dollar_volume

    def test_tiebreak_by_symbol_asc(self):
        from libs.backtest.selector import build_candidate, rank_candidates

        rows = [
            _make_raw_row(symbol="Z", score=0.7, avg_dollar_volume=1e6),
            _make_raw_row(symbol="A", score=0.7, avg_dollar_volume=1e6),
        ]
        candidates = [build_candidate(r) for r in rows]
        ranked = rank_candidates([c for c in candidates if c])
        assert ranked[0].symbol == "A"

    def test_deterministic(self):
        from libs.backtest.selector import build_candidate, rank_candidates

        rows = [
            _make_raw_row(symbol="C", score=0.9),
            _make_raw_row(symbol="A", score=0.7),
            _make_raw_row(symbol="B", score=0.8),
        ]
        candidates = [build_candidate(r) for r in rows]
        r1 = rank_candidates([c for c in candidates if c])
        r2 = rank_candidates([c for c in candidates if c])
        assert [c.symbol for c in r1] == [c.symbol for c in r2]


class TestFilterCandidates:
    def test_score_threshold(self):
        from libs.backtest.selector import build_candidate, filter_by_score

        rows = [
            _make_raw_row(symbol="A", score=0.3),
            _make_raw_row(symbol="B", score=0.7),
            _make_raw_row(symbol="C", score=0.5),
        ]
        candidates = [build_candidate(r) for r in rows if build_candidate(r)]
        filtered = filter_by_score(candidates, score_threshold=0.5)
        assert len(filtered) == 2
        assert all(c.score >= 0.5 for c in filtered)

    def test_min_price_filter(self):
        from libs.backtest.selector import build_candidate, filter_by_universe

        u = UniverseConfig(min_price=100.0, min_avg_dollar_volume=0)
        rows = [
            _make_raw_row(symbol="CHEAP", entry_price=50.0),
            _make_raw_row(symbol="OK", entry_price=150.0),
        ]
        candidates = [build_candidate(r) for r in rows if build_candidate(r)]
        filtered = filter_by_universe(candidates, u)
        assert len(filtered) == 1
        assert filtered[0].symbol == "OK"

    def test_min_adv_filter(self):
        from libs.backtest.selector import build_candidate, filter_by_universe

        u = UniverseConfig(min_price=0, min_avg_dollar_volume=2_000_000)
        rows = [
            _make_raw_row(symbol="ILLIQUID", avg_dollar_volume=500_000),
            _make_raw_row(symbol="LIQUID", avg_dollar_volume=5_000_000),
        ]
        candidates = [build_candidate(r) for r in rows if build_candidate(r)]
        filtered = filter_by_universe(candidates, u)
        assert len(filtered) == 1
        assert filtered[0].symbol == "LIQUID"

    def test_truncate(self):
        from libs.backtest.selector import build_candidate, rank_candidates, truncate_candidates

        rows = [_make_raw_row(symbol=s, score=0.9 - i * 0.1) for i, s in enumerate("ABCDE")]
        candidates = rank_candidates([build_candidate(r) for r in rows if build_candidate(r)])
        truncated = truncate_candidates(candidates, max_per_day=3)
        assert len(truncated) == 3


class TestFilterByEventType:
    def test_disabled_event_type_filtered(self):
        from libs.backtest.selector import build_candidate, filter_by_event_type

        rows = [
            _make_raw_row(symbol="A", event_type="earnings_release"),
            _make_raw_row(symbol="B", event_type="management_change"),
        ]
        candidates = [build_candidate(r) for r in rows if build_candidate(r)]
        profiles = {
            "earnings_release": EventTypeProfile(enabled=True),
            "management_change": EventTypeProfile(enabled=False),
        }
        filtered = filter_by_event_type(candidates, profiles)
        assert len(filtered) == 1
        assert filtered[0].symbol == "A"

    def test_per_type_score_threshold(self):
        from libs.backtest.selector import build_candidate, filter_by_event_type

        rows = [
            _make_raw_row(symbol="A", event_type="earnings_release", score=0.55),
            _make_raw_row(symbol="B", event_type="earnings_release", score=0.75),
        ]
        candidates = [build_candidate(r) for r in rows if build_candidate(r)]
        profiles = {
            "earnings_release": EventTypeProfile(score_threshold_override=0.6),
        }
        filtered = filter_by_event_type(candidates, profiles)
        assert len(filtered) == 1
        assert filtered[0].symbol == "B"

    def test_unknown_event_type_blocked(self):
        """Event types not in profiles dict are blocked (default deny)."""
        from libs.backtest.selector import build_candidate, filter_by_event_type

        rows = [
            _make_raw_row(symbol="A", event_type="earnings_release"),
            _make_raw_row(symbol="B", event_type="unknown_type"),
        ]
        candidates = [build_candidate(r) for r in rows if build_candidate(r)]
        profiles = {
            "earnings_release": EventTypeProfile(enabled=True),
        }
        filtered = filter_by_event_type(candidates, profiles)
        assert len(filtered) == 1
        assert filtered[0].symbol == "A"

    def test_unknown_event_type_passes_when_in_profiles(self):
        """Event type 'unknown' passes through when explicitly enabled in profiles."""
        from libs.backtest.selector import build_candidate, filter_by_event_type

        rows = [
            _make_raw_row(symbol="A", event_type="earnings_release"),
            _make_raw_row(symbol="B", event_type="unknown"),
        ]
        candidates = [build_candidate(r) for r in rows if build_candidate(r)]
        profiles = {
            "earnings_release": EventTypeProfile(enabled=True),
            "unknown": EventTypeProfile(enabled=True, direction_filter="any"),
        }
        filtered = filter_by_event_type(candidates, profiles)
        assert len(filtered) == 2
        symbols = [c.symbol for c in filtered]
        assert "A" in symbols
        assert "B" in symbols

    def test_no_profiles_passthrough(self):
        from libs.backtest.selector import build_candidate, filter_by_event_type

        rows = [_make_raw_row(symbol="A")]
        candidates = [build_candidate(r) for r in rows if build_candidate(r)]
        assert filter_by_event_type(candidates, {}) == candidates


class TestSelectCandidates:
    def test_full_pipeline(self):
        from libs.backtest.selector import select_candidates

        rows = [
            _make_raw_row(symbol="A", score=0.9, avg_dollar_volume=5e6, entry_price=100.0),
            _make_raw_row(symbol="B", score=0.3, avg_dollar_volume=5e6, entry_price=100.0),  # below threshold
            _make_raw_row(symbol="C", score=0.8, avg_dollar_volume=1e4, entry_price=100.0),  # low ADV
            _make_raw_row(symbol="D", score=0.7, avg_dollar_volume=5e6, entry_price=2.0),   # below min_price
        ]
        u = UniverseConfig(min_price=5.0, min_avg_dollar_volume=1_000_000)
        s = SignalConfig(score_threshold=0.5, max_candidates_per_day=10)
        result = select_candidates(rows, u, s)
        symbols = [c.symbol for c in result]
        assert "A" in symbols
        assert "B" not in symbols  # below threshold
        assert "C" not in symbols  # low ADV
        assert "D" not in symbols  # below min_price

    def test_pipeline_with_event_type_profiles(self):
        from libs.backtest.selector import select_candidates

        rows = [
            _make_raw_row(symbol="A", score=0.9, avg_dollar_volume=5e6, entry_price=100.0, event_type="earnings_release"),
            _make_raw_row(symbol="B", score=0.7, avg_dollar_volume=5e6, entry_price=100.0, event_type="management_change"),
        ]
        u = UniverseConfig(min_price=5.0, min_avg_dollar_volume=1_000_000)
        s = SignalConfig(score_threshold=0.5, max_candidates_per_day=10)
        profiles = {
            "earnings_release": EventTypeProfile(enabled=True),
            "management_change": EventTypeProfile(enabled=False),
        }
        result = select_candidates(rows, u, s, event_type_profiles=profiles)
        assert len(result) == 1
        assert result[0].symbol == "A"
