"""EarningsRunup pre-event drift engine.

Buys stocks 3-7 trading days before scheduled earnings when both attention and
dollar-volume z-scores rise above their 20-day baselines. Exits before the print.

This module is the *pure* logic — `BacktestRunner` calls into
``build_earnings_runup_candidates`` from a thin scheduling hook. The pure
function takes provider Protocols so it can be unit-tested with stubs.

Architectural choice (a): synthetic Candidate emission into the existing
`_scheduled_delayed_entries` queue, mirroring `_schedule_leader_follower_candidates`.
"""
from __future__ import annotations

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

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

logger = get_logger(__name__)

EARNINGS_RUNUP_EVENT_TYPE = "earnings_runup_preevent"

# Eastern-time market open used as the leakage cutoff. Decision date features
# must be timestamped strictly before this instant.
_ET_MARKET_OPEN = dt.time(9, 30)
# Naive UTC offset is fine for ordering checks because every timestamp we
# produce is normalized to the same convention (timezone-aware UTC).
_ET_OFFSET = dt.timedelta(hours=-5)  # EST; DST is irrelevant for an ordering bound


# ---------------------------------------------------------------------------
# Provider Protocols (test-friendly seams)
# ---------------------------------------------------------------------------


class AttentionZscoreProvider(Protocol):
    """Returns the 20-day attention z-score for ``symbol`` as of ``as_of_date``.

    Implementations must guarantee that no information from on/after ``as_of_date``
    is incorporated into the returned value. ``None`` if data is unavailable.
    """

    def get_zscore_20d(self, symbol: str, as_of_date: dt.date) -> float | None: ...


class UpcomingEarningsProvider(Protocol):
    """Returns the next-known scheduled earnings reaction date for ``symbol`` as of ``as_of_date``."""

    def get_next_reaction_date(
        self,
        symbol: str,
        as_of_date: dt.date,
        max_lookahead_calendar_days: int,
    ) -> dt.date | None: ...


class BarHistoryProvider(Protocol):
    """Returns chronologically-ordered (date, bar_dict) pairs for ``symbol`` strictly before ``as_of_date``."""

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


# ---------------------------------------------------------------------------
# Adapters: bridge BacktestRunner state to the Protocols above.
# ---------------------------------------------------------------------------


@dataclass
class _PitCalendarUpcomingEarningsAdapter:
    """Adapt PointInTimeEarningsCalendar to UpcomingEarningsProvider."""

    pit_calendar: Any  # libs.backtest.earnings_calendar.PointInTimeEarningsCalendar
    trading_days: list[dt.date]

    def get_next_reaction_date(
        self,
        symbol: str,
        as_of_date: dt.date,
        max_lookahead_calendar_days: int,
    ) -> dt.date | None:
        try:
            idx = self.trading_days.index(as_of_date)
        except ValueError:
            return None
        # Look at every trading day strictly after as_of_date up to lookahead window.
        cutoff = as_of_date + dt.timedelta(days=max_lookahead_calendar_days)
        future = [d for d in self.trading_days[idx + 1:] if d <= cutoff]
        if not future:
            return None
        result = self.pit_calendar.get_known_upcoming_reaction_dates(
            as_of_date=as_of_date,
            allowed_reaction_dates=future,
            symbols=[symbol],
        )
        return result.get(symbol.upper())


@dataclass
class _SnapshotStoreBarAdapter:
    """Adapt SnapshotStore (or any object exposing ._bars) to BarHistoryProvider."""

    bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]]

    def get_bars_before(
        self,
        symbol: str,
        as_of_date: dt.date,
        lookback_days: int,
    ) -> list[tuple[dt.date, dict[str, Any]]]:
        sym_bars = self.bars_by_symbol.get(symbol.upper())
        if not sym_bars:
            return []
        eligible = sorted(
            (d, sym_bars[d])
            for d in sym_bars
            if d < as_of_date  # strict; T-1 is the latest allowed
        )
        return eligible[-lookback_days:]


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


def _decision_cutoff_utc(decision_date: dt.date) -> dt.datetime:
    """09:30 ET on decision_date, expressed as a UTC-aware timestamp.

    Any feature timestamp >= this instant carries information from inside the
    entry day and constitutes a look-ahead violation.
    """
    et_naive = dt.datetime.combine(decision_date, _ET_MARKET_OPEN)
    # Convert to UTC by subtracting the (negative) ET offset.
    utc_naive = et_naive - _ET_OFFSET
    return utc_naive.replace(tzinfo=dt.timezone.utc)


def _assert_no_lookahead(
    symbol: str,
    decision_date: dt.date,
    feature_timestamps: Iterable[dt.datetime],
) -> None:
    cutoff = _decision_cutoff_utc(decision_date)
    for ts in feature_timestamps:
        if ts is None:
            continue
        if ts.tzinfo is None:
            raise LookaheadViolationError(
                f"EarningsRunup feature timestamp for {symbol} is naive ({ts.isoformat()}); "
                "all timestamps must be timezone-aware to compare against the cutoff"
            )
        if ts >= cutoff:
            raise LookaheadViolationError(
                f"EarningsRunup feature timestamp {ts.isoformat()} for {symbol} is "
                f">= decision_date cutoff {cutoff.isoformat()}; this is a look-ahead violation"
            )


# ---------------------------------------------------------------------------
# Trigger evaluation
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class EarningsRunupTriggerInputs:
    """Bundle of T-1 close inputs for one (symbol, decision_date) candidate.

    Every field whose source bears a timestamp must be timestamped strictly
    before ``decision_date`` 09:30 ET; the candidate builder enforces this.
    """

    symbol: str
    decision_date: dt.date
    next_trading_date: dt.date
    upcoming_earnings_reaction_date: dt.date
    days_to_earnings: int  # trading-day count from decision_date to event
    attention_zscore_20d: float
    dollar_volume_zscore_20d: float
    last_close_price: float
    avg_dollar_volume_20d: float
    last_bar_date: dt.date
    last_bar_timestamp: dt.datetime  # tz-aware


def evaluate_trigger(
    inputs: EarningsRunupTriggerInputs,
    engine: StrategyEngineConfig,
) -> tuple[bool, str | None]:
    """Pure trigger check. Returns (passes, reject_reason)."""
    dmin = engine.earnings_runup_days_to_earnings_min
    dmax = engine.earnings_runup_days_to_earnings_max
    if dmin is not None and inputs.days_to_earnings < dmin:
        return False, f"days_to_earnings {inputs.days_to_earnings} < min {dmin}"
    if dmax is not None and inputs.days_to_earnings > dmax:
        return False, f"days_to_earnings {inputs.days_to_earnings} > max {dmax}"

    az_min = engine.earnings_runup_attention_zscore_20d_min
    if az_min is not None and inputs.attention_zscore_20d < az_min:
        return False, f"attention_z {inputs.attention_zscore_20d:.3f} < min {az_min}"

    dvz_min = engine.earnings_runup_dollar_volume_zscore_20d_min
    if dvz_min is not None and inputs.dollar_volume_zscore_20d < dvz_min:
        return False, f"dollar_volume_z {inputs.dollar_volume_zscore_20d:.3f} < min {dvz_min}"

    adv_min = engine.earnings_runup_min_avg_dollar_volume
    if adv_min is not None and inputs.avg_dollar_volume_20d < adv_min:
        return False, (
            f"avg_dollar_volume_20d {inputs.avg_dollar_volume_20d:,.0f} "
            f"< min {adv_min:,.0f}"
        )

    return True, None


# ---------------------------------------------------------------------------
# Dollar-volume z-score from bar history
# ---------------------------------------------------------------------------


def _dollar_volume_zscore_20d(bars: list[tuple[dt.date, dict[str, Any]]]) -> tuple[float | None, float | None]:
    """Compute (zscore_20d, avg_dollar_volume_20d) from the last 21 bars.

    The most recent bar (T-1) is the observation; the prior 20 form the baseline.
    Returns (None, None) if insufficient history.
    """
    if len(bars) < 21:
        return None, None
    recent = bars[-1][1]
    prior_20 = bars[-21:-1]
    recent_dv = float(recent.get("close", 0.0)) * float(recent.get("volume", 0.0))
    prior_dv = [
        float(b.get("close", 0.0)) * float(b.get("volume", 0.0))
        for _, b in prior_20
    ]
    if len(prior_dv) < 2:
        return None, None
    mu = statistics.fmean(prior_dv)
    sigma = statistics.pstdev(prior_dv)
    if sigma <= 0 or math.isnan(sigma):
        return None, mu
    z = (recent_dv - mu) / sigma
    return z, mu


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------


def build_earnings_runup_candidates(
    decision_date: dt.date,
    next_trading_date: dt.date,
    universe_symbols: Iterable[str],
    engine: StrategyEngineConfig,
    upcoming_earnings_provider: UpcomingEarningsProvider,
    attention_provider: AttentionZscoreProvider,
    bar_provider: BarHistoryProvider,
) -> list[Candidate]:
    """Construct synthetic EarningsRunup candidates for ``next_trading_date`` execution.

    Decision logic runs at T-1 close (=decision_date close); orders fill at
    T+1 next_open. Every input must satisfy ``timestamp < decision_date 09:30 ET``.
    """
    if not engine.earnings_runup_enabled:
        return []

    dmax = int(engine.earnings_runup_days_to_earnings_max or 0)
    if dmax <= 0:
        return []

    # PIT calendar adapter looks ``dmax`` trading days ahead. We pad in calendar days
    # to be safe (weekends/holidays).
    calendar_lookahead = dmax * 2 + 7

    candidates: list[Candidate] = []
    seen_symbols: set[str] = set()

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

        upcoming_reaction = upcoming_earnings_provider.get_next_reaction_date(
            symbol=symbol,
            as_of_date=decision_date,
            max_lookahead_calendar_days=calendar_lookahead,
        )
        if upcoming_reaction is None:
            continue

        # Trading-day distance from decision_date close to event reaction.
        # We compute via the universe's trading-day index when available; the
        # PIT adapter passes the full sim_dates list, so we recompute from there.
        days_to_earnings = _trading_days_between(
            decision_date, upcoming_reaction, getattr(upcoming_earnings_provider, "trading_days", None)
        )
        if days_to_earnings is None:
            continue

        bars = bar_provider.get_bars_before(symbol, decision_date, lookback_days=65)
        if not bars:
            continue
        last_bar_date, last_bar = bars[-1]

        # Strict T-1 check: most recent allowed bar is the day BEFORE decision_date.
        # We allow same-day decision_date+ data only if the bar's date < decision_date.
        if last_bar_date >= decision_date:
            raise LookaheadViolationError(
                f"EarningsRunup bar for {symbol} on {last_bar_date.isoformat()} is not "
                f"strictly before decision_date {decision_date.isoformat()}"
            )

        last_bar_ts = _bar_close_timestamp(last_bar_date)
        # Run the lookahead assertion early — it MUST be on the hot path.
        _assert_no_lookahead(symbol, decision_date, [last_bar_ts])

        dv_z, adv_20d = _dollar_volume_zscore_20d(bars)
        if dv_z is None or adv_20d is None:
            continue

        attention_z = attention_provider.get_zscore_20d(symbol, decision_date)
        if attention_z is None:
            continue

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

        inputs = EarningsRunupTriggerInputs(
            symbol=symbol,
            decision_date=decision_date,
            next_trading_date=next_trading_date,
            upcoming_earnings_reaction_date=upcoming_reaction,
            days_to_earnings=days_to_earnings,
            attention_zscore_20d=float(attention_z),
            dollar_volume_zscore_20d=float(dv_z),
            last_close_price=last_close,
            avg_dollar_volume_20d=float(adv_20d),
            last_bar_date=last_bar_date,
            last_bar_timestamp=last_bar_ts,
        )

        passes, reason = evaluate_trigger(inputs, engine)
        if not passes:
            logger.debug(
                "earnings_runup_trigger_skipped",
                symbol=symbol,
                decision_date=decision_date.isoformat(),
                reason=reason,
            )
            continue

        candidate = _build_candidate_from_inputs(inputs, engine)
        candidates.append(candidate)

    return candidates


def _trading_days_between(
    decision_date: dt.date,
    target_date: dt.date,
    trading_days: list[dt.date] | None,
) -> int | None:
    if trading_days:
        try:
            i0 = trading_days.index(decision_date)
            i1 = trading_days.index(target_date)
            return i1 - i0
        except ValueError:
            return None
    # Fallback: business-day approximation if trading_days not available.
    # Counts weekdays strictly after decision_date up to target_date.
    if target_date <= decision_date:
        return None
    count = 0
    cursor = decision_date
    while cursor < target_date:
        cursor = cursor + dt.timedelta(days=1)
        if cursor.weekday() < 5:
            count += 1
    return count


def _bar_close_timestamp(bar_date: dt.date) -> dt.datetime:
    """Timestamp the daily-close bar at 16:00 ET on its trading day, in 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)


def _build_candidate_from_inputs(
    inputs: EarningsRunupTriggerInputs,
    engine: StrategyEngineConfig,
) -> Candidate:
    # Hard hold: forced flat by close of the trading day BEFORE the print.
    buffer = max(0, int(engine.earnings_runup_calendar_buffer_days))
    max_holding_days = max(1, inputs.days_to_earnings - buffer)

    # Translate pct exits → existing ATR-multiplier / R-multiple machinery in
    # `libs.backtest.allocator.compute_stop_price` and `compute_target_price`.
    #   Synthetic ATR := 2% of last close (the same fallback compute_stop_price
    #     uses when atr_14 is missing, but we materialize it so target/stop
    #     downstream consumers see a non-null ATR).
    #   stop_atr_multiplier := stop_pct / 0.02 → produces a stop_distance of
    #     ``stop_pct * close`` for the default dynamic_scaler == 1.0.
    #   target_1_r := target_pct / stop_pct → fixed-R target sits at +target_pct.
    #   target_1_fraction := 1.0 → fully exit at first target.
    # Trailing pct exits ARE wired end-to-end via engine_trailing_pct_activation /
    # engine_trailing_pct_giveback on Candidate → ExecutionConfig overrides →
    # update_trailing_stop activation gate. The trailing model name encodes the
    # giveback for legacy logging; the engine fields override the actual stop.
    synthetic_atr = max(inputs.last_close_price * 0.02, 0.01)
    stop_pct = float(engine.earnings_runup_stop_pct)
    target_pct = float(engine.earnings_runup_target_pct)
    stop_mult = stop_pct / 0.02 if stop_pct > 0 else 2.0
    target_r = target_pct / stop_pct if stop_pct > 0 else 2.0

    # Pct-trailing wiring: pick a "pct_<int>" model whose name reflects the
    # giveback (used as fallback if pct_giveback is somehow None at runtime,
    # and shown in trade diagnostics).
    trailing_giveback = float(engine.earnings_runup_trailing_giveback_pct)
    trailing_activation = float(engine.earnings_runup_trailing_activate_pct)
    trailing_giveback_name = max(1, int(round(trailing_giveback * 100)))
    pct_trailing_model = f"pct_{trailing_giveback_name}"

    # Score is a deterministic function of the two z-scores so it ranks
    # candidates without leaking future information.
    z_sum = inputs.attention_zscore_20d + inputs.dollar_volume_zscore_20d
    score = 0.5 + 0.05 * z_sum
    score = max(0.0, min(0.99, score))
    score_bucket = (
        "high" if score >= 0.8
        else "medium_high" if score >= 0.6
        else "medium"
    )

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

    features = {
        "earnings_runup_decision_date": inputs.decision_date.isoformat(),
        "earnings_runup_upcoming_reaction_date": inputs.upcoming_earnings_reaction_date.isoformat(),
        "earnings_runup_days_to_earnings": inputs.days_to_earnings,
        "earnings_runup_attention_zscore_20d": round(inputs.attention_zscore_20d, 4),
        "earnings_runup_dollar_volume_zscore_20d": round(inputs.dollar_volume_zscore_20d, 4),
        "earnings_runup_avg_dollar_volume_20d": inputs.avg_dollar_volume_20d,
        "earnings_runup_max_holding_days": max_holding_days,
        "earnings_runup_stop_pct": engine.earnings_runup_stop_pct,
        "earnings_runup_target_pct": engine.earnings_runup_target_pct,
        "earnings_runup_trailing_activate_pct": engine.earnings_runup_trailing_activate_pct,
        "earnings_runup_trailing_giveback_pct": engine.earnings_runup_trailing_giveback_pct,
    }

    return Candidate(
        event_id=event_id,
        symbol=inputs.symbol,
        source_symbol=inputs.symbol,
        score=score,
        sector="UNKNOWN",
        event_type=EARNINGS_RUNUP_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_price,
        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=max_holding_days,
        engine_risk_budget_pct=engine.engine_risk_budget_pct,
        engine_per_trade_risk_pct=engine.per_trade_risk_pct_override,
        # Map pct-based EarningsRunup exits → engine_*-prefixed overrides on the candidate.
        engine_target_1_r=target_r,
        engine_target_1_fraction=1.0,
        # Trailing: prefer engine-level override; fall back to pct trailing model
        # derived from the engine's giveback config so the activation-gated
        # trailing path is used in update_trailing_stop.
        engine_trailing_model=engine.trailing_model_override or pct_trailing_model,
        engine_trailing_warmup_days=(
            engine.trailing_warmup_days_override
            if engine.trailing_warmup_days_override is not None
            else 0
        ),
        engine_trailing_pct_activation=trailing_activation,
        engine_trailing_pct_giveback=trailing_giveback,
        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,
        engine_early_failure_no_progress_days=engine.early_failure_no_progress_days_override,
        engine_early_failure_no_progress_r=engine.early_failure_no_progress_r_override,
        engine_early_failure_no_progress_fraction=engine.early_failure_no_progress_fraction_override,
        shadow_only=engine.shadow_only,
        features=features,
    )


__all__ = [
    "EARNINGS_RUNUP_EVENT_TYPE",
    "AttentionZscoreProvider",
    "BarHistoryProvider",
    "EarningsRunupTriggerInputs",
    "UpcomingEarningsProvider",
    "_PitCalendarUpcomingEarningsAdapter",
    "_SnapshotStoreBarAdapter",
    "build_earnings_runup_candidates",
    "evaluate_trigger",
]
