"""Breakout52w — George-Hwang (2004) 52-week-high price-momentum engine.

Monthly rebalance, top-N cross-sectional by 20-day volume ratio descending.
Long-only. Buys liquid mid/large caps that close at a NEW 52-week (252-day)
high on T-1. Enters at next_open, holds for breakout_holding_days
trading days (default 21, monthly cadence).

Distinct from libs.backtest.vol_breakout_52w (which is a 2-day MOC tactical
engine with -3%/+5% bracket exits). This engine targets the trend-continuation
tail catalyzed by George-Hwang's anchoring-bias finding: stocks that print
new 52-week highs systematically under-react and continue higher.

The retired topgainer family (v1-v54, killed at -87% honest replay in
project_topgainer_phase1_lookahead_2026-05-05.md) used today's daily_high in
a Phase-1 pre-screen — a fatal look-ahead. This engine structurally cannot
repeat that bug:
  * BarHistoryProvider returns bars STRICTLY before decision_date.
  * The 52w max-high window excludes T-1 itself: bars[-(lookback+1):-1].
    Wait — actually we compute "new high today" so we INCLUDE T-1's close
    being compared against the prior 252-day max. The prior max is over
    [T-252..T-2], so T-1's close > max(T-252..T-2).high is the breakout.
  * _assert_strictly_before re-checks every used bar date.

Mirrors libs.backtest.low_vol_anomaly exactly for monthly rebalance, top-N,
21d hold, next_open entry — ranking is descending 20d volume ratio (top
conviction breakouts first) rather than ascending volatility.
"""
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.breakout_52w_cache import Breakout52wRankCache

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

logger = get_logger(__name__)

BREAKOUT_52W_EVENT_TYPE = "breakout_52w"

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


# ---------------------------------------------------------------------------
# Provider Protocol
# ---------------------------------------------------------------------------


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"Breakout52w {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_52w_high_breakout(
    bars: list[tuple[dt.date, dict[str, Any]]],
    *,
    lookback_days: int = 252,
) -> tuple[bool, float, float, list[dt.date]]:
    """Return (is_breakout, last_close, prior_max_high, used_window_dates).

    The "prior 52-week high" is the max(high) over the window of `lookback_days`
    bars BEFORE T-1 — i.e. bars[-(lookback+1):-1] — so T-1's own bar never
    enters the max. Breakout = T-1's close strictly greater than prior max high.

    Bars must be chronologically ordered AND strictly before decision_date.
    Returns (False, 0, 0, []) on insufficient history.
    """
    if len(bars) < 2:
        return False, 0.0, 0.0, []
    last_date, last_bar = bars[-1]
    last_close = float(last_bar.get("close", 0.0))
    if last_close <= 0:
        return False, 0.0, 0.0, []

    # Window = the 252 bars BEFORE T-1 (excludes T-1 itself).
    prior_window = bars[-(lookback_days + 1):-1]
    if len(prior_window) < max(20, lookback_days // 4):
        return False, last_close, 0.0, []

    used_dates = [d for d, _ in prior_window]
    prior_max_high = max(float(b.get("high", 0.0)) for _, b in prior_window)
    is_breakout = last_close > prior_max_high
    return bool(is_breakout), last_close, float(prior_max_high), used_dates


def compute_volume_ratio_20d(
    bars: list[tuple[dt.date, dict[str, Any]]],
) -> float | None:
    """volume_T-1 / median(volume over 20 bars ending T-2). None on insufficient history."""
    if len(bars) < 21:
        return None
    last_vol = float(bars[-1][1].get("volume", 0.0))
    prior_window = bars[-21:-1]
    prior_volumes = [float(b.get("volume", 0.0)) for _, b in prior_window]
    if not prior_volumes:
        return None
    median_vol = float(statistics.median(prior_volumes))
    if median_vol <= 0:
        return None
    return last_vol / median_vol


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 inputs + universe gates
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Breakout52wInputs:
    symbol: str
    decision_date: dt.date
    next_trading_date: dt.date
    last_bar_date: dt.date
    last_bar_timestamp: dt.datetime
    last_close: float
    prior_252d_max_high: float
    is_52w_breakout: bool
    volume_ratio_20d: float
    avg_dollar_volume_20d: float


def evaluate_universe_gates(
    inputs: Breakout52wInputs,
    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"Breakout52w {inputs.symbol}: last_bar_date "
            f"{inputs.last_bar_date.isoformat()} not strictly before decision_date"
        )
    price_min = float(getattr(engine, "breakout_52w_min_price", 10.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, "breakout_52w_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}"
        )
    if not inputs.is_52w_breakout:
        return False, (
            f"close {inputs.last_close:.4f} <= prior 252d max high "
            f"{inputs.prior_252d_max_high:.4f}"
        )
    return True, None


# ---------------------------------------------------------------------------
# Build candidates (top-N descending volume ratio 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 DESCENDING by volume_ratio_20d (highest conviction first).
    """
    rows_sorted = sorted(rows, key=lambda r: float(r["volume_ratio_20d"]), 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 = Breakout52wInputs(
            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"]),
            prior_252d_max_high=float(row["prior_252d_max_high"]),
            is_52w_breakout=True,
            volume_ratio_20d=float(row["volume_ratio_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(
            "breakout_52w_rebalance",
            decision_date=decision_date.isoformat(),
            universe_scanned=total_ranked,
            top_n_emitted=len(candidates),
            top_score=float(top[0]["volume_ratio_20d"]) if top else None,
            bottom_score=float(top[-1]["volume_ratio_20d"]) 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: "Breakout52wRankCache | None" = None,
) -> list[Candidate]:
    """Emit top-N synthetic 52w-breakout candidates on rebalance days only.

    On non-rebalance days, returns []. The universe is scanned once per
    rebalance day; symbols that closed at a NEW 52-week high on T-1 pass the
    universe gates and are ranked DESCENDING by 20d volume ratio. Top-N enter.
    """
    if not getattr(engine, "breakout_52w_enabled", False):
        return []
    if next_trading_date <= decision_date:
        raise LookaheadViolationError(
            f"Breakout52w next_trading_date {next_trading_date.isoformat()} "
            f"must be strictly after decision_date {decision_date.isoformat()}"
        )

    lookback = int(getattr(engine, "breakout_52w_lookback_days", 252) or 252)
    top_n = int(getattr(engine, "breakout_52w_top_n", 20) or 20)

    # Need enough bars for the 252d window + 20d volume ratio buffer + weekend gap allowance.
    fetch_lookback = lookback + 25

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

        is_brk, _last, prior_max, used_dates = compute_52w_high_breakout(
            bars, lookback_days=lookback
        )
        if not is_brk:
            continue
        _assert_strictly_before(symbol, decision_date, used_dates)

        vol_ratio = compute_volume_ratio_20d(bars)
        if vol_ratio is None:
            continue

        adv_20d = compute_avg_dollar_volume_20d(bars)

        inputs = Breakout52wInputs(
            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,
            prior_252d_max_high=prior_max,
            is_52w_breakout=is_brk,
            volume_ratio_20d=vol_ratio,
            avg_dollar_volume_20d=adv_20d,
        )

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

        scored.append((vol_ratio, inputs))

    # Sort by volume_ratio DESCENDING (highest conviction = best), 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,
                "volume_ratio_20d": inp.volume_ratio_20d,
                "avg_dollar_volume_20d": inp.avg_dollar_volume_20d,
                "last_close": inp.last_close,
                "prior_252d_max_high": inp.prior_252d_max_high,
                "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(
            "breakout_52w_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: Breakout52wInputs,
    engine: StrategyEngineConfig,
    *,
    rank: int,
    total_ranked: int,
) -> Candidate:
    holding_days = int(getattr(engine, "breakout_52w_holding_days", 21) or 21)
    stop_pct = float(getattr(engine, "breakout_52w_stop_pct", 0.10) or 0.10)
    target_pct = float(getattr(engine, "breakout_52w_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 volume ratio → higher score, monotonic, clipped to [0.5, 0.99].
    # Map vol_ratio [1.0, 5.0] → score [0.5, 0.99] (linear).
    vol_ref = 5.0
    norm = min(1.0, max(0.0, (inputs.volume_ratio_20d - 1.0) / (vol_ref - 1.0)))
    score = 0.5 + 0.49 * norm
    score_bucket = (
        "high" if score >= 0.8
        else "medium_high" if score >= 0.6
        else "medium"
    )

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

    features = {
        "breakout_52w_decision_date": inputs.decision_date.isoformat(),
        "breakout_52w_last_close": inputs.last_close,
        "breakout_52w_prior_252d_max_high": inputs.prior_252d_max_high,
        "breakout_52w_volume_ratio_20d": round(inputs.volume_ratio_20d, 4),
        "breakout_52w_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d,
        "breakout_52w_rank": rank,
        "breakout_52w_total_ranked": total_ranked,
        "breakout_52w_stop_pct": stop_pct,
        "breakout_52w_target_pct": target_pct,
        "breakout_52w_holding_days": holding_days,
    }

    return Candidate(
        event_id=event_id,
        symbol=inputs.symbol,
        source_symbol=inputs.symbol,
        score=score,
        sector="UNKNOWN",
        event_type=BREAKOUT_52W_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,
        # breakout_52w 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 (mirrors low_vol_anomaly / xsmom / vol_breakout_52w 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__ = [
    "BREAKOUT_52W_EVENT_TYPE",
    "BarHistoryProvider",
    "Breakout52wInputs",
    "_SnapshotStoreBarAdapter",
    "build_candidates",
    "compute_52w_high_breakout",
    "compute_avg_dollar_volume_20d",
    "compute_volume_ratio_20d",
    "evaluate_universe_gates",
    "is_rebalance_day",
]
