"""LowVolAnomaly — Frazzini-Pedersen (2014) "Betting Against Beta" / Low-Vol Anomaly.

On the first trading day of each calendar month, rank universe by ASCENDING
realized volatility over `lookback_days` (default 60). Buy the top-N
LOWEST-vol symbols at next_open, hold for `holding_days` trading days, then
force-flat.

Long-only, large-cap-friendly. The lowest-vol slice of a liquid universe is
expected to be dominated by stable mega-caps — explicitly orthogonal to the
small-cap moonshot tail of the form4 silo (HYMC etc).

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.

Mirrors libs.backtest.cross_sectional_momentum but ranks on inverse-vol.
"""
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.low_vol_cache import LowVolRankCache

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

logger = get_logger(__name__)

LOW_VOL_ANOMALY_EVENT_TYPE = "low_vol_anomaly"

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


# ---------------------------------------------------------------------------
# Provider Protocol (shared shape with xsmom / 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 _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"LowVolAnomaly {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."""
    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_realized_volatility(
    bars: list[tuple[dt.date, dict[str, Any]]],
    *,
    lookback_days: int = 60,
) -> tuple[float | None, list[dt.date]]:
    """Stdev of daily simple returns over the trailing ``lookback_days`` bars.

    Returns (volatility, used_dates). None when insufficient history.
    """
    if len(bars) < lookback_days + 1:
        return None, []
    window = bars[-(lookback_days + 1):]
    closes = [float(b.get("close", 0.0)) for _, b in window]
    if any(c <= 0 for c in closes):
        return None, []
    rets: list[float] = []
    for i in range(1, len(closes)):
        rets.append((closes[i] / closes[i - 1]) - 1.0)
    if len(rets) < 2:
        return None, []
    vol = float(statistics.stdev(rets))
    used_dates = [d for d, _ in window]
    return vol, used_dates


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 LowVolAnomalyInputs:
    symbol: str
    decision_date: dt.date
    next_trading_date: dt.date
    last_bar_date: dt.date
    last_bar_timestamp: dt.datetime
    last_close: float
    realized_volatility: float
    avg_dollar_volume_20d: float


def evaluate_universe_gates(
    inputs: LowVolAnomalyInputs,
    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"LowVolAnomaly {inputs.symbol}: last_bar_date "
            f"{inputs.last_bar_date.isoformat()} not strictly before decision_date"
        )
    price_min = float(getattr(engine, "lowvol_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, "lowvol_min_avg_dollar_volume", 5_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_min = float(getattr(engine, "lowvol_volatility_min", 0.0) or 0.0)
    if vol_min > 0 and inputs.realized_volatility < vol_min:
        return False, f"realized_volatility {inputs.realized_volatility:.6f} < min {vol_min:.6f}"
    return True, None


# ---------------------------------------------------------------------------
# Build candidates (top-N ascending vol 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).

    Sorts ASCENDING by realized_volatility (lowest vol first).
    """
    rows_sorted = sorted(rows, key=lambda r: float(r["realized_volatility"]))
    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 = LowVolAnomalyInputs(
            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"]),
            realized_volatility=float(row["realized_volatility"]),
            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(
            "low_vol_anomaly_rebalance",
            decision_date=decision_date.isoformat(),
            universe_scanned=total_ranked,
            top_n_emitted=len(candidates),
            top_score=float(top[0]["realized_volatility"]) if top else None,
            bottom_score=float(top[-1]["realized_volatility"]) 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: "LowVolRankCache | None" = None,
) -> list[Candidate]:
    """Emit top-N synthetic LOW-VOL candidates on rebalance days only.

    On non-rebalance days, returns []. The universe is scanned once per
    rebalance day, ranked ASCENDING by realized_volatility, and the top-N
    (lowest vol) 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, "lowvol_enabled", False):
        return []
    if next_trading_date <= decision_date:
        raise LookaheadViolationError(
            f"LowVolAnomaly next_trading_date {next_trading_date.isoformat()} "
            f"must be strictly after decision_date {decision_date.isoformat()}"
        )

    lookback = int(getattr(engine, "lowvol_lookback_days", 60) or 60)
    top_n = int(getattr(engine, "lowvol_top_n", 20) or 20)
    vol_min = float(getattr(engine, "lowvol_volatility_min", 0.0) or 0.0)
    max_mcap = getattr(engine, "lowvol_max_market_cap_proxy", None)
    # max_market_cap_proxy is None by default — low-vol = large-cap-friendly.
    # The runner-level filter (engine.max_market_cap_proxy) still applies.

    # +5 day buffer for off-by-one and weekend gaps
    fetch_lookback = lookback + 25

    scored: list[tuple[float, LowVolAnomalyInputs]] = []
    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"LowVolAnomaly 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

        vol, vol_used_dates = compute_realized_volatility(bars, lookback_days=lookback)
        if vol is None:
            continue
        if vol_min > 0 and vol < vol_min:
            continue

        _assert_strictly_before(symbol, decision_date, vol_used_dates)

        adv_20d = compute_avg_dollar_volume_20d(bars)

        inputs = LowVolAnomalyInputs(
            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,
            realized_volatility=vol,
            avg_dollar_volume_20d=adv_20d,
        )

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

        scored.append((vol, inputs))

    # Sort by volatility ASCENDING (lowest vol = best), take top N.
    scored.sort(key=lambda t: t[0])
    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,
                "realized_volatility": inp.realized_volatility,
                "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(
            "low_vol_anomaly_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: LowVolAnomalyInputs,
    engine: StrategyEngineConfig,
    *,
    rank: int,
    total_ranked: int,
) -> Candidate:
    holding_days = int(getattr(engine, "lowvol_holding_days", 21) or 21)
    stop_pct = float(getattr(engine, "lowvol_stop_pct", 0.10) or 0.10)
    target_pct = float(getattr(engine, "lowvol_target_pct", 0.20) or 0.20)

    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 2.0

    # Score: lower vol → higher score, monotonic, clipped to [0.5, 0.99].
    # Map vol [0%, 4%] → score [0.99, 0.5] (linear, inverted).
    vol_ref = 0.04
    inv = 1.0 - min(1.0, max(0.0, inputs.realized_volatility / vol_ref))
    score = 0.5 + 0.49 * inv
    score_bucket = (
        "high" if score >= 0.8
        else "medium_high" if score >= 0.6
        else "medium"
    )

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

    features = {
        "lowvol_decision_date": inputs.decision_date.isoformat(),
        "lowvol_last_close": inputs.last_close,
        "lowvol_realized_volatility": round(inputs.realized_volatility, 6),
        "lowvol_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d,
        "lowvol_rank": rank,
        "lowvol_total_ranked": total_ranked,
        "lowvol_stop_pct": stop_pct,
        "lowvol_target_pct": target_pct,
        "lowvol_holding_days": holding_days,
    }

    return Candidate(
        event_id=event_id,
        symbol=inputs.symbol,
        source_symbol=inputs.symbol,
        score=score,
        sector="UNKNOWN",
        event_type=LOW_VOL_ANOMALY_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,
        # low-vol needs full holding period — explicitly disable NO_PROGRESS / trailing
        # so v9.x base config's tight gates don't fire on synthetic candidates.
        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 xsmom'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__ = [
    "LOW_VOL_ANOMALY_EVENT_TYPE",
    "BarHistoryProvider",
    "LowVolAnomalyInputs",
    "_SnapshotStoreBarAdapter",
    "build_candidates",
    "compute_avg_dollar_volume_20d",
    "compute_realized_volatility",
    "evaluate_universe_gates",
    "is_rebalance_day",
]
