"""CrossSectionalMomentum — classic 12-1 academic momentum factor, look-ahead-safe.

On the first trading day of each calendar month, rank universe by the
cumulative return from t-252 to t-21 (i.e. 12 months back, skipping the most
recent month per Jegadeesh-Titman 1993). Buy the top-N highest-momentum
symbols at next_open, hold for `holding_days` trading days, then force-flat.

This is the canonical academic momentum factor. The 1-month skip removes
short-term reversal contamination. Top-decile long-only is the cleanest
specification compatible with the existing long-only execution stack.

Look-ahead defenses (NON-NEGOTIABLE):
  * BarHistoryProvider returns bars STRICTLY before decision_date.
  * `_assert_strictly_before` re-checks every used bar date.
  * Rebalance gate uses last_bar_date.month vs decision_date.month — purely
    derived from already-strict bars; no calendar lookup needed.

Per advisor pre-commit (Phase 19): STANDALONE POC only — no hybrid scaffolding.
"""
from __future__ import annotations

import datetime as dt
import statistics
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Iterable, Protocol

if TYPE_CHECKING:
    from libs.backtest.xsmom_cache import XsmomRankCache

from libs.backtest.domain import (
    Candidate,
    LookaheadViolationError,
    StrategyEngineConfig,
)
from libs.common.logging import get_logger

logger = get_logger(__name__)

CROSS_SECTIONAL_MOMENTUM_EVENT_TYPE = "xsmom_12_1"

_ET_MARKET_OPEN = dt.time(9, 30)
_ET_OFFSET = dt.timedelta(hours=-5)


# ---------------------------------------------------------------------------
# Provider Protocol (shared shape with VolBreakout52w)
# ---------------------------------------------------------------------------


class BarHistoryProvider(Protocol):
    def get_bars_before(
        self,
        symbol: str,
        as_of_date: dt.date,
        lookback_days: int,
    ) -> list[tuple[dt.date, dict[str, Any]]]: ...


# ---------------------------------------------------------------------------
# Lookahead defense
# ---------------------------------------------------------------------------


def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime:
    et_naive = dt.datetime.combine(decision_date, _ET_MARKET_OPEN)
    utc_naive = et_naive - _ET_OFFSET
    return utc_naive.replace(tzinfo=dt.timezone.utc)


def _assert_strictly_before(
    symbol: str,
    decision_date: dt.date,
    used_dates: Iterable[dt.date],
) -> None:
    for d in used_dates:
        if d >= decision_date:
            raise LookaheadViolationError(
                f"CrossSectionalMomentum {symbol}: bar date {d.isoformat()} "
                f"is not strictly before decision_date {decision_date.isoformat()}"
            )


def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime:
    """16:00 ET close on bar_date, tz-aware UTC."""
    et_naive = dt.datetime.combine(bar_date, dt.time(16, 0))
    utc_naive = et_naive - _ET_OFFSET
    return utc_naive.replace(tzinfo=dt.timezone.utc)


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


def is_rebalance_day(
    bars: list[tuple[dt.date, dict[str, Any]]],
    decision_date: dt.date,
) -> bool:
    """First trading day of the calendar month iff last_bar's month != decision_date.month.

    Pure function: derives the rebalance flag from already-strict bar history.
    Returns False on empty bars.
    """
    if not bars:
        return False
    last_bar_date = bars[-1][0]
    if last_bar_date >= decision_date:
        raise LookaheadViolationError(
            f"is_rebalance_day: last_bar_date {last_bar_date.isoformat()} "
            f"is not strictly before decision_date {decision_date.isoformat()}"
        )
    return last_bar_date.month != decision_date.month


def compute_12_1_momentum(
    bars: list[tuple[dt.date, dict[str, Any]]],
    *,
    lookback_days: int = 252,
    skip_days: int = 21,
) -> tuple[float | None, list[dt.date]]:
    """Cumulative return from t-(lookback+skip) to t-skip. Skips most recent `skip_days`.

    Returns (momentum_score, used_dates). None when insufficient history.
    The window is `bars[-(lookback+skip):-skip]` — last bar is t-skip-1, first
    bar is t-(lookback+skip).
    """
    needed = lookback_days + skip_days
    if len(bars) < needed:
        return None, []
    window = bars[-needed:-skip_days] if skip_days > 0 else bars[-needed:]
    if len(window) < 2:
        return None, []
    start_close = float(window[0][1].get("close", 0.0))
    end_close = float(window[-1][1].get("close", 0.0))
    if start_close <= 0 or end_close <= 0:
        return None, []
    momentum = (end_close / start_close) - 1.0
    used_dates = [d for d, _ in window]
    return momentum, used_dates


def compute_20d_volatility(
    bars: list[tuple[dt.date, dict[str, Any]]],
) -> float | None:
    """Stdev of daily log returns over the last 20 bars. Returns None on insufficient data."""
    if len(bars) < 21:
        return None
    closes = [float(b.get("close", 0.0)) for _, b in bars[-21:]]
    if any(c <= 0 for c in closes):
        return None
    rets = []
    for i in range(1, len(closes)):
        rets.append((closes[i] / closes[i - 1]) - 1.0)
    if len(rets) < 2:
        return None
    return float(statistics.stdev(rets))


def compute_avg_dollar_volume_20d(
    bars: list[tuple[dt.date, dict[str, Any]]],
) -> float:
    if not bars:
        return 0.0
    tail = bars[-20:]
    return statistics.fmean(
        float(b.get("close", 0.0)) * float(b.get("volume", 0.0))
        for _, b in tail
    )


# ---------------------------------------------------------------------------
# Per-symbol trigger inputs + evaluation
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class CrossSectionalMomentumInputs:
    symbol: str
    decision_date: dt.date
    next_trading_date: dt.date
    last_bar_date: dt.date
    last_bar_timestamp: dt.datetime
    last_close: float
    momentum_12_1: float
    volatility_20d: float
    avg_dollar_volume_20d: float


def evaluate_universe_gates(
    inputs: CrossSectionalMomentumInputs,
    engine: StrategyEngineConfig,
) -> tuple[bool, str | None]:
    """Reject low-quality candidates BEFORE ranking. Pure function."""
    if inputs.last_bar_date >= inputs.decision_date:
        raise LookaheadViolationError(
            f"CrossSectionalMomentum {inputs.symbol}: last_bar_date "
            f"{inputs.last_bar_date.isoformat()} not strictly before decision_date"
        )
    price_min = float(getattr(engine, "xsmom_min_price", 5.0) or 0.0)
    if price_min > 0 and inputs.last_close < price_min:
        return False, f"last_close {inputs.last_close:.2f} < min {price_min:.2f}"
    adv_min = float(getattr(engine, "xsmom_min_avg_dollar_volume", 10_000_000.0) or 0.0)
    if adv_min > 0 and inputs.avg_dollar_volume_20d < adv_min:
        return False, (
            f"avg_dollar_volume_20d {inputs.avg_dollar_volume_20d:,.0f} < min {adv_min:,.0f}"
        )
    vol_max = float(getattr(engine, "xsmom_volatility_20d_max", 0.08) or 0.0)
    if vol_max > 0 and inputs.volatility_20d > vol_max:
        return False, f"volatility_20d {inputs.volatility_20d:.4f} > max {vol_max:.4f}"
    return True, None


# ---------------------------------------------------------------------------
# Build candidates (top-N selection across universe)
# ---------------------------------------------------------------------------


def _build_candidates_from_cache(
    rows: list[dict[str, Any]],
    decision_date: dt.date,
    next_trading_date: dt.date,
    engine: StrategyEngineConfig,
    top_n: int,
) -> list[Candidate]:
    """Build top-N candidates from cached ranked rows (already post-quality-gate)."""
    rows_sorted = sorted(rows, key=lambda r: float(r["momentum_12_1"]), reverse=True)
    top = rows_sorted[:top_n]
    total_ranked = len(rows_sorted)

    candidates: list[Candidate] = []
    for rank, row in enumerate(top, start=1):
        last_bar_date = dt.date.fromisoformat(str(row["last_bar_date"]))
        _assert_strictly_before(str(row["symbol"]), decision_date, [last_bar_date])
        inputs = CrossSectionalMomentumInputs(
            symbol=str(row["symbol"]),
            decision_date=decision_date,
            next_trading_date=next_trading_date,
            last_bar_date=last_bar_date,
            last_bar_timestamp=dt.datetime.fromisoformat(str(row["last_bar_timestamp_iso"])),
            last_close=float(row["last_close"]),
            momentum_12_1=float(row["momentum_12_1"]),
            volatility_20d=float(row["volatility_20d"]),
            avg_dollar_volume_20d=float(row["avg_dollar_volume_20d"]),
        )
        candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=total_ranked))

    if candidates:
        logger.info(
            "cross_sectional_momentum_rebalance",
            decision_date=decision_date.isoformat(),
            universe_scanned=total_ranked,
            top_n_emitted=len(candidates),
            top_score=float(top[0]["momentum_12_1"]) if top else None,
            bottom_score=float(top[-1]["momentum_12_1"]) if top else None,
            cache_hit=True,
        )
    return candidates


def build_candidates(
    decision_date: dt.date,
    next_trading_date: dt.date,
    universe_symbols: Iterable[str],
    engine: StrategyEngineConfig,
    bar_provider: BarHistoryProvider,
    *,
    cache: "XsmomRankCache | None" = None,
) -> list[Candidate]:
    """Emit top-N synthetic momentum candidates on rebalance days only.

    On non-rebalance days, returns []. The universe is scanned once per
    rebalance day, ranked, and the top-N pass through.

    If ``cache`` is provided: on rebalance days, tries to serve from the disk
    cache (populated on prior runs). Cache miss triggers the full universe scan
    and saves the ranked universe for future runs.
    """
    if not getattr(engine, "xsmom_enabled", False):
        return []
    if next_trading_date <= decision_date:
        raise LookaheadViolationError(
            f"CrossSectionalMomentum next_trading_date {next_trading_date.isoformat()} "
            f"must be strictly after decision_date {decision_date.isoformat()}"
        )

    lookback = int(getattr(engine, "xsmom_lookback_days", 252) or 252)
    skip = int(getattr(engine, "xsmom_skip_days", 21) or 21)
    top_n = int(getattr(engine, "xsmom_top_n", 20) or 20)
    momentum_min = float(getattr(engine, "xsmom_momentum_min", 0.0) or 0.0)

    fetch_lookback = lookback + skip + 5

    scored: list[tuple[float, CrossSectionalMomentumInputs]] = []
    seen: set[str] = set()
    rebalance_checked = False

    for raw_symbol in universe_symbols:
        symbol = str(raw_symbol).strip().upper()
        if not symbol or symbol in seen:
            continue
        seen.add(symbol)

        bars = bar_provider.get_bars_before(symbol, decision_date, lookback_days=fetch_lookback)
        if not bars:
            continue

        last_bar_date, last_bar = bars[-1]
        if last_bar_date >= decision_date:
            raise LookaheadViolationError(
                f"CrossSectionalMomentum bar for {symbol} on {last_bar_date.isoformat()} "
                f"is not strictly before decision_date {decision_date.isoformat()}"
            )

        # Rebalance gate — check once per call, derived from bars (no calendar).
        if not rebalance_checked:
            is_rebalance = is_rebalance_day(bars, decision_date)
            rebalance_checked = True
            if not is_rebalance:
                return []
            # Rebalance confirmed: try cache before scanning remaining universe.
            if cache is not None:
                cached_rows = cache.get_date(decision_date)
                if cached_rows is not None:
                    return _build_candidates_from_cache(
                        cached_rows, decision_date, next_trading_date, engine, top_n
                    )

        last_close = float(last_bar.get("close", 0.0))
        if last_close <= 0:
            continue

        momentum, mom_used_dates = compute_12_1_momentum(
            bars, lookback_days=lookback, skip_days=skip
        )
        if momentum is None:
            continue
        if momentum < momentum_min:
            continue

        _assert_strictly_before(symbol, decision_date, mom_used_dates)

        vol_20d = compute_20d_volatility(bars)
        if vol_20d is None:
            continue
        adv_20d = compute_avg_dollar_volume_20d(bars)

        inputs = CrossSectionalMomentumInputs(
            symbol=symbol,
            decision_date=decision_date,
            next_trading_date=next_trading_date,
            last_bar_date=last_bar_date,
            last_bar_timestamp=_bar_close_timestamp(last_bar_date),
            last_close=last_close,
            momentum_12_1=momentum,
            volatility_20d=vol_20d,
            avg_dollar_volume_20d=adv_20d,
        )

        passes, reason = evaluate_universe_gates(inputs, engine)
        if not passes:
            continue

        scored.append((momentum, inputs))

    # Sort by momentum descending, take top N.
    scored.sort(key=lambda t: t[0], reverse=True)
    top = scored[:top_n]

    # Save full ranked universe to cache (pre-top_n) for future runs.
    if cache is not None and scored:
        cache.save_date(decision_date, [
            {
                "decision_date": decision_date.isoformat(),
                "symbol": inp.symbol,
                "momentum_12_1": inp.momentum_12_1,
                "volatility_20d": inp.volatility_20d,
                "avg_dollar_volume_20d": inp.avg_dollar_volume_20d,
                "last_close": inp.last_close,
                "last_bar_date": inp.last_bar_date.isoformat(),
                "last_bar_timestamp_iso": inp.last_bar_timestamp.isoformat(),
            }
            for _, inp in scored
        ])

    candidates: list[Candidate] = []
    for rank, (_, inputs) in enumerate(top, start=1):
        candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=len(scored)))

    if candidates:
        logger.info(
            "cross_sectional_momentum_rebalance",
            decision_date=decision_date.isoformat(),
            universe_scanned=len(scored),
            top_n_emitted=len(candidates),
            top_score=top[0][0] if top else None,
            bottom_score=top[-1][0] if top else None,
        )

    return candidates


def _build_candidate(
    inputs: CrossSectionalMomentumInputs,
    engine: StrategyEngineConfig,
    *,
    rank: int,
    total_ranked: int,
) -> Candidate:
    holding_days = int(getattr(engine, "xsmom_holding_days", 21) or 21)
    stop_pct = float(getattr(engine, "xsmom_stop_pct", 0.10) or 0.10)
    target_pct = float(getattr(engine, "xsmom_target_pct", 0.30) or 0.30)

    synthetic_atr = max(inputs.last_close * 0.02, 0.01)
    stop_mult = stop_pct / 0.02 if stop_pct > 0 else 5.0
    target_r = target_pct / stop_pct if stop_pct > 0 else 3.0

    # Score: higher momentum → higher score, monotonic, clipped to [0, 0.99].
    # Map momentum [0%, +60%] → score [0.5, 0.99] (linear).
    score = 0.5 + 0.49 * min(1.0, max(0.0, inputs.momentum_12_1 / 0.60))
    score_bucket = (
        "high" if score >= 0.8
        else "medium_high" if score >= 0.6
        else "medium"
    )

    event_id = (
        f"synth_xsmom_{inputs.symbol.lower()}_"
        f"{inputs.decision_date.isoformat()}"
    )

    features = {
        "xsmom_decision_date": inputs.decision_date.isoformat(),
        "xsmom_last_close": inputs.last_close,
        "xsmom_momentum_12_1": round(inputs.momentum_12_1, 6),
        "xsmom_volatility_20d": round(inputs.volatility_20d, 6),
        "xsmom_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d,
        "xsmom_rank": rank,
        "xsmom_total_ranked": total_ranked,
        "xsmom_stop_pct": stop_pct,
        "xsmom_target_pct": target_pct,
        "xsmom_holding_days": holding_days,
    }

    return Candidate(
        event_id=event_id,
        symbol=inputs.symbol,
        source_symbol=inputs.symbol,
        score=score,
        sector="UNKNOWN",
        event_type=CROSS_SECTIONAL_MOMENTUM_EVENT_TYPE,
        event_timestamp=inputs.last_bar_timestamp,
        event_date=inputs.decision_date,
        filing_time_bucket="post_market",
        timing_class="after_close",
        reaction_date=inputs.decision_date,
        execution_date=inputs.next_trading_date,
        entry_price_est=inputs.last_close,
        avg_dollar_volume=inputs.avg_dollar_volume_20d,
        atr_14=synthetic_atr,
        score_bucket=score_bucket,
        engine_id=engine.engine_id,
        entry_timing_policy="next_open",
        trade_direction="long",
        engine_max_holding_days=holding_days,
        engine_risk_budget_pct=engine.engine_risk_budget_pct,
        engine_capital_bucket_id=(
            (engine.capital_bucket_id or engine.engine_id)
            if engine.capital_bucket_allocation_pct is not None
            else None
        ),
        engine_capital_bucket_allocation_pct=engine.capital_bucket_allocation_pct,
        engine_per_trade_risk_pct=engine.per_trade_risk_pct_override,
        engine_target_1_r=target_r,
        engine_target_1_fraction=1.0,
        engine_stop_atr_multiplier=stop_mult,
        engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct,
        engine_use_reaction_day_low_stop=False,
        engine_early_failure_close_below_entry_and_reaction_close=False,
        # xsmom needs full holding period — explicitly disable NO_PROGRESS / trailing
        # at the candidate level so v7.356 base config's tight gates don't fire on
        # synthetic candidates in hybrid runs.
        engine_early_failure_no_progress_days=0,
        engine_early_failure_no_progress_r=0.0,
        engine_early_failure_no_progress_fraction=0.0,
        engine_trailing_model="none",
        engine_trailing_warmup_days=999,
        shadow_only=engine.shadow_only,
        features=features,
    )


# ---------------------------------------------------------------------------
# Adapter (reuse VolBreakout52w's cached-bars pattern)
# ---------------------------------------------------------------------------


@dataclass
class _SnapshotStoreBarAdapter:
    bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]]
    _sorted_cache: dict[str, list[tuple[dt.date, dict[str, Any]]]] = field(default_factory=dict)

    def _sorted_for(self, symbol: str) -> list[tuple[dt.date, dict[str, Any]]]:
        sym_upper = symbol.upper()
        cached = self._sorted_cache.get(sym_upper)
        if cached is not None:
            return cached
        sym_bars = self.bars_by_symbol.get(sym_upper)
        if not sym_bars:
            self._sorted_cache[sym_upper] = []
            return self._sorted_cache[sym_upper]
        ordered = sorted(sym_bars.items(), key=lambda kv: kv[0])
        self._sorted_cache[sym_upper] = ordered
        return ordered

    def get_bars_before(
        self,
        symbol: str,
        as_of_date: dt.date,
        lookback_days: int,
    ) -> list[tuple[dt.date, dict[str, Any]]]:
        ordered = self._sorted_for(symbol)
        if not ordered:
            return []
        eligible: list[tuple[dt.date, dict[str, Any]]] = []
        for d, b in ordered:
            if d >= as_of_date:
                break
            eligible.append((d, b))
        return eligible[-lookback_days:]


__all__ = [
    "CROSS_SECTIONAL_MOMENTUM_EVENT_TYPE",
    "BarHistoryProvider",
    "CrossSectionalMomentumInputs",
    "_SnapshotStoreBarAdapter",
    "build_candidates",
    "compute_12_1_momentum",
    "compute_20d_volatility",
    "compute_avg_dollar_volume_20d",
    "evaluate_universe_gates",
    "is_rebalance_day",
]
