"""Unit tests for the CrossSectionalMomentum (12-1) engine.

Like every continuous-rotation engine on this codebase, the non-negotiable bar
is the look-ahead defense: every used bar date MUST be strictly before
decision_date. The momentum window AND the rebalance gate AND the universe
filters all run through `bar_provider.get_bars_before` which enforces this.

The Phase 19 pre-commit abort criteria are documented in the engine module
and the POC config (configs/experiments/xsmom_poc_v1.json). Tests here cover
pure logic, lookahead defense, and the rebalance-day semantics.
"""
from __future__ import annotations

import datetime as dt
from typing import Any

import pytest

from libs.backtest.domain import LookaheadViolationError, StrategyEngineConfig
from libs.backtest.cross_sectional_momentum import (
    CROSS_SECTIONAL_MOMENTUM_EVENT_TYPE,
    _SnapshotStoreBarAdapter,
    build_candidates,
    compute_12_1_momentum,
    compute_20d_volatility,
    compute_avg_dollar_volume_20d,
    is_rebalance_day,
)


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


def _make_engine(**overrides: Any) -> StrategyEngineConfig:
    base: dict[str, Any] = dict(
        engine_id="xsmom_12_1_long",
        event_types=[CROSS_SECTIONAL_MOMENTUM_EVENT_TYPE],
        direction="long_only",
        timing_class="after_close",
        entry_timing_policy="next_open",
        max_holding_days=21,
        xsmom_enabled=True,
        xsmom_lookback_days=60,  # smaller for tests
        xsmom_skip_days=5,
        xsmom_top_n=3,
        xsmom_holding_days=21,
        xsmom_momentum_min=0.0,
        xsmom_min_avg_dollar_volume=1_000_000.0,
        xsmom_min_price=5.0,
        xsmom_volatility_20d_max=0.10,
        xsmom_stop_pct=0.10,
        xsmom_target_pct=0.30,
    )
    base.update(overrides)
    return StrategyEngineConfig(**base)


def _business_days(start: dt.date, count: int) -> list[dt.date]:
    out: list[dt.date] = []
    cursor = start
    while len(out) < count:
        if cursor.weekday() < 5:
            out.append(cursor)
        cursor = cursor + dt.timedelta(days=1)
    return out


def _linear_bars(
    days: list[dt.date],
    *,
    start_close: float = 100.0,
    end_close: float = 130.0,
    volume: float = 5_000_000.0,
) -> dict[dt.date, dict[str, Any]]:
    """Linear close path from start_close to end_close across all bars."""
    n = len(days)
    out: dict[dt.date, dict[str, Any]] = {}
    for i, d in enumerate(days):
        c = start_close + (end_close - start_close) * (i / max(1, n - 1))
        out[d] = {
            "open": c,
            "high": c + 0.5,
            "low": c - 0.5,
            "close": c,
            "volume": volume,
        }
    return out


# ---------------------------------------------------------------------------
# Pure trigger computations
# ---------------------------------------------------------------------------


def test_12_1_momentum_basic() -> None:
    days = _business_days(dt.date(2024, 1, 2), 80)  # need lookback+skip=65, plenty
    bars_dict = _linear_bars(days, start_close=100.0, end_close=200.0)
    bars = sorted(bars_dict.items(), key=lambda kv: kv[0])
    # Use full window: lookback=60, skip=5 → window is bars[-65:-5]
    momentum, used_dates = compute_12_1_momentum(bars, lookback_days=60, skip_days=5)
    assert momentum is not None
    # The window excludes the most recent 5 bars. With linear 100→200 across 80 bars,
    # start of window is bars[-65] and end is bars[-6]. Both >100, <200.
    start_close = bars[-65][1]["close"]
    end_close = bars[-6][1]["close"]
    expected = (end_close / start_close) - 1.0
    assert abs(momentum - expected) < 1e-9
    assert len(used_dates) == 60


def test_12_1_momentum_insufficient_history() -> None:
    days = _business_days(dt.date(2024, 1, 2), 30)
    bars_dict = _linear_bars(days)
    bars = sorted(bars_dict.items(), key=lambda kv: kv[0])
    momentum, used_dates = compute_12_1_momentum(bars, lookback_days=60, skip_days=5)
    assert momentum is None
    assert used_dates == []


def test_20d_volatility_basic() -> None:
    days = _business_days(dt.date(2024, 1, 2), 30)
    bars_dict = _linear_bars(days)
    bars = sorted(bars_dict.items(), key=lambda kv: kv[0])
    vol = compute_20d_volatility(bars)
    assert vol is not None
    assert vol >= 0


def test_avg_dollar_volume_20d_basic() -> None:
    days = _business_days(dt.date(2024, 1, 2), 30)
    bars_dict = _linear_bars(days, start_close=100, end_close=100, volume=1_000_000.0)
    bars = sorted(bars_dict.items(), key=lambda kv: kv[0])
    adv = compute_avg_dollar_volume_20d(bars)
    # close ≈ 100, volume = 1M, so dollar volume ≈ 100M
    assert abs(adv - 100_000_000.0) < 1_000_000.0


# ---------------------------------------------------------------------------
# Rebalance-day semantics
# ---------------------------------------------------------------------------


def test_is_rebalance_day_first_trading_day_of_new_month() -> None:
    # Last bar is 2024-01-31 (Wed); decision_date is 2024-02-01 (Thu)
    bars = [(dt.date(2024, 1, 31), {"close": 100.0})]
    assert is_rebalance_day(bars, dt.date(2024, 2, 1)) is True


def test_is_rebalance_day_mid_month() -> None:
    # Last bar is 2024-01-15 (Mon); decision_date 2024-01-16 (Tue) — same month
    bars = [(dt.date(2024, 1, 15), {"close": 100.0})]
    assert is_rebalance_day(bars, dt.date(2024, 1, 16)) is False


def test_is_rebalance_day_empty_bars_returns_false() -> None:
    assert is_rebalance_day([], dt.date(2024, 1, 2)) is False


def test_is_rebalance_day_lookahead_raises() -> None:
    bars = [(dt.date(2024, 2, 1), {"close": 100.0})]
    with pytest.raises(LookaheadViolationError):
        is_rebalance_day(bars, dt.date(2024, 2, 1))


# ---------------------------------------------------------------------------
# Lookahead defense: build_candidates rejects T+0 bars
# ---------------------------------------------------------------------------


class _LeakyProvider:
    """Returns bars INCLUDING the decision_date — must be caught by build_candidates."""

    def __init__(self, bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]]) -> None:
        self.bars_by_symbol = bars_by_symbol

    def get_bars_before(
        self, symbol: str, as_of_date: dt.date, lookback_days: int
    ) -> list[tuple[dt.date, dict[str, Any]]]:
        # INTENTIONALLY LEAKY: returns bars where date == as_of_date too.
        sym_bars = self.bars_by_symbol.get(symbol.upper(), {})
        ordered = sorted(sym_bars.items(), key=lambda kv: kv[0])
        return [(d, b) for d, b in ordered if d <= as_of_date][-lookback_days:]


def test_build_raises_lookahead_when_provider_returns_t0_bar() -> None:
    """Proof-by-contradiction: a leaky provider that returns T+0 must trip the assertion."""
    days = _business_days(dt.date(2024, 1, 2), 80)
    decision_date = days[-1]  # final day is decision_date — leaky provider returns it
    next_date = decision_date + dt.timedelta(days=1)
    # Ensure decision_date is first trading day of new month — pick a setup that's a rebalance.
    # Easier: just patch the rebalance check by using a non-rebalance date but with a leaky T+0;
    # the leak should trip BEFORE rebalance gate via the explicit assertion.
    bars_dict = _linear_bars(days)
    leaky = _LeakyProvider({"FOO": bars_dict})
    engine = _make_engine()
    with pytest.raises(LookaheadViolationError):
        build_candidates(
            decision_date=decision_date,
            next_trading_date=next_date,
            universe_symbols=["FOO"],
            engine=engine,
            bar_provider=leaky,
        )


def test_build_returns_empty_on_non_rebalance_day() -> None:
    """Mid-month decision_date must yield 0 candidates (no rebalance)."""
    days = _business_days(dt.date(2024, 1, 2), 80)
    bars_dict = _linear_bars(days)
    bars_by_symbol = {f"SYM{i}": bars_dict for i in range(5)}
    adapter = _SnapshotStoreBarAdapter(bars_by_symbol=bars_by_symbol)
    engine = _make_engine()

    # Use a mid-month date (where last_bar.month == decision_date.month).
    decision_date = days[40]  # somewhere mid-period
    next_date = decision_date + dt.timedelta(days=1)
    # Adjust: ensure last_bar (strictly before decision_date) is in same month
    # The adapter strictly-before guarantees last_bar = days[39]. days[39] and days[40] same month.
    candidates = build_candidates(
        decision_date=decision_date,
        next_trading_date=next_date,
        universe_symbols=list(bars_by_symbol.keys()),
        engine=engine,
        bar_provider=adapter,
    )
    assert candidates == []


# ---------------------------------------------------------------------------
# Build candidates — happy path with multiple symbols + top-N selection
# ---------------------------------------------------------------------------


def test_build_emits_top_n_on_rebalance_day() -> None:
    """Construct 5 symbols with distinct momentum and verify top-3 emission."""
    days = _business_days(dt.date(2024, 1, 2), 200)
    # decision_date should be first trading day of a new month AND have
    # >= lookback+skip bars (65) of history strictly before it.
    rebalance_idx = None
    for i in range(70, len(days)):
        if days[i].month != days[i - 1].month:
            rebalance_idx = i
            break
    assert rebalance_idx is not None
    decision_date = days[rebalance_idx]
    next_date = decision_date + dt.timedelta(days=1)
    used_days = days[: rebalance_idx + 1]  # bar_provider returns strictly-before, so up to days[rebalance_idx-1] are eligible

    bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]] = {}
    # 5 symbols with end_close in increasing order → momentum ranks ascending
    end_closes = [110.0, 120.0, 130.0, 140.0, 150.0]
    for i, end_c in enumerate(end_closes):
        sym = f"SYM{i}"
        bars_by_symbol[sym] = _linear_bars(used_days, start_close=100.0, end_close=end_c)

    adapter = _SnapshotStoreBarAdapter(bars_by_symbol=bars_by_symbol)
    engine = _make_engine(xsmom_top_n=3)
    candidates = build_candidates(
        decision_date=decision_date,
        next_trading_date=next_date,
        universe_symbols=list(bars_by_symbol.keys()),
        engine=engine,
        bar_provider=adapter,
    )
    assert len(candidates) == 3
    # Highest momentum (SYM4) should be top-ranked
    symbols_emitted = [c.symbol for c in candidates]
    assert "SYM4" in symbols_emitted
    assert "SYM3" in symbols_emitted
    assert "SYM2" in symbols_emitted
    assert "SYM0" not in symbols_emitted
    assert "SYM1" not in symbols_emitted


def test_build_filters_negative_momentum() -> None:
    """Symbols with momentum < momentum_min must be dropped."""
    days = _business_days(dt.date(2024, 1, 2), 200)
    rebalance_idx = None
    for i in range(70, len(days)):
        if days[i].month != days[i - 1].month:
            rebalance_idx = i
            break
    assert rebalance_idx is not None
    decision_date = days[rebalance_idx]
    next_date = decision_date + dt.timedelta(days=1)
    used_days = days[: rebalance_idx + 1]

    # 3 symbols with end_close BELOW start → negative momentum
    bars_by_symbol = {
        f"NEG{i}": _linear_bars(used_days, start_close=100.0, end_close=80.0 + i)
        for i in range(3)
    }
    # 2 with positive
    bars_by_symbol["POS1"] = _linear_bars(used_days, start_close=100.0, end_close=120.0)
    bars_by_symbol["POS2"] = _linear_bars(used_days, start_close=100.0, end_close=140.0)

    adapter = _SnapshotStoreBarAdapter(bars_by_symbol=bars_by_symbol)
    engine = _make_engine(xsmom_top_n=10, xsmom_momentum_min=0.0)
    candidates = build_candidates(
        decision_date=decision_date,
        next_trading_date=next_date,
        universe_symbols=list(bars_by_symbol.keys()),
        engine=engine,
        bar_provider=adapter,
    )
    symbols_emitted = {c.symbol for c in candidates}
    assert symbols_emitted == {"POS1", "POS2"}


def test_build_filters_volatility_max() -> None:
    """Symbols with 20d vol above the max must be dropped."""
    days = _business_days(dt.date(2024, 1, 2), 200)
    rebalance_idx = None
    for i in range(70, len(days)):
        if days[i].month != days[i - 1].month:
            rebalance_idx = i
            break
    assert rebalance_idx is not None
    decision_date = days[rebalance_idx]
    next_date = decision_date + dt.timedelta(days=1)
    used_days = days[: rebalance_idx + 1]

    # SMOOTH: linear path → low vol
    smooth_bars = _linear_bars(used_days, start_close=100.0, end_close=130.0)
    # CHOPPY: alternating up/down by 20% from base, end at 130
    choppy_bars: dict[dt.date, dict[str, Any]] = {}
    for i, d in enumerate(used_days):
        base = 100 + 30 * (i / max(1, len(used_days) - 1))
        # Alternate ±20% on consecutive days
        c = base * (1.2 if i % 2 == 0 else 0.8)
        choppy_bars[d] = {"open": c, "high": c + 0.5, "low": c - 0.5, "close": c, "volume": 5_000_000.0}

    bars_by_symbol = {"SMOOTH": smooth_bars, "CHOPPY": choppy_bars}
    adapter = _SnapshotStoreBarAdapter(bars_by_symbol=bars_by_symbol)
    engine = _make_engine(xsmom_top_n=10, xsmom_volatility_20d_max=0.05)  # ~5% vol cap
    candidates = build_candidates(
        decision_date=decision_date,
        next_trading_date=next_date,
        universe_symbols=list(bars_by_symbol.keys()),
        engine=engine,
        bar_provider=adapter,
    )
    symbols_emitted = {c.symbol for c in candidates}
    assert "CHOPPY" not in symbols_emitted
    # SMOOTH may or may not survive depending on momentum_min — but it must NOT be the choppy one


# ---------------------------------------------------------------------------
# Engine disabled / no universe / etc — defensive
# ---------------------------------------------------------------------------


def test_build_returns_empty_when_engine_disabled() -> None:
    engine = _make_engine(xsmom_enabled=False)
    adapter = _SnapshotStoreBarAdapter(bars_by_symbol={})
    candidates = build_candidates(
        decision_date=dt.date(2024, 2, 1),
        next_trading_date=dt.date(2024, 2, 2),
        universe_symbols=["FOO"],
        engine=engine,
        bar_provider=adapter,
    )
    assert candidates == []


def test_build_raises_when_next_date_not_after_decision() -> None:
    engine = _make_engine()
    adapter = _SnapshotStoreBarAdapter(bars_by_symbol={})
    with pytest.raises(LookaheadViolationError):
        build_candidates(
            decision_date=dt.date(2024, 2, 2),
            next_trading_date=dt.date(2024, 2, 1),  # before
            universe_symbols=["FOO"],
            engine=engine,
            bar_provider=adapter,
        )


def test_snapshot_store_adapter_returns_only_strictly_before() -> None:
    """The cached adapter must NEVER return bars where date >= as_of_date."""
    days = [dt.date(2024, 1, d) for d in (2, 3, 4, 5, 8)]  # mix of weekdays
    bars = {d: {"close": 100.0 + i, "high": 101.0, "low": 99.0, "volume": 1e6} for i, d in enumerate(days)}
    adapter = _SnapshotStoreBarAdapter(bars_by_symbol={"FOO": bars})

    # as_of_date = days[3]; should return only days[:3]
    out = adapter.get_bars_before("FOO", days[3], lookback_days=10)
    out_dates = [d for d, _ in out]
    assert all(d < days[3] for d in out_dates)
    assert out_dates == days[:3]
