"""Pure functions for the *entry* phase of the decision loop.

Two responsibilities (both deterministic, no I/O):

* :func:`validate_entry` — accumulate all blocking reasons that come from
  the access filters in ``docs/01-strategy-rules.md §2``.
* :func:`compute_bias` — translate market trend/funding/regime into a
  :data:`SpreadType` choice per ``docs/01-strategy-rules.md §3.1``.
"""

from __future__ import annotations

from decimal import Decimal

from pydantic import BaseModel, ConfigDict

from cerbero_bite.config import SpreadType, StrategyConfig
from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold

__all__ = [
    "EntryContext",
    "EntryDecision",
    "TrendContext",
    "compute_bias",
    "validate_entry",
]


class EntryContext(BaseModel):
    """Snapshot of the inputs needed to decide whether to open a trade."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    capital_usd: Decimal
    dvol_now: Decimal
    funding_perp_annualized: Decimal
    eth_holdings_pct_of_portfolio: Decimal
    next_macro_event_in_days: int | None
    has_open_position: bool

    # Quant filters (§2.8). Both are optional: when the snapshot
    # collector cannot reach the underlying MCP tool the orchestrator
    # passes ``None``, and ``validate_entry`` skips the gate to avoid
    # blocking entries on infrastructure issues.
    dealer_net_gamma: Decimal | None = None
    liquidation_squeeze_risk_high: bool | None = None

    # IV richness gate (§2.9). Differenza IV30g − RV30g in punti vol.
    # Optional, stessa logica best-effort dei filtri quant: ``None``
    # significa "dato non disponibile" e fa saltare il gate (non
    # invalida l'entry).
    iv_minus_rv: Decimal | None = None

    # Valori IV-RV nella finestra rolling già scelta dal caller
    # (entry_cycle): tutti i record validi su window_days, ASC, NULL e
    # fetch_ok=0 esclusi. Caricata dal repository quando
    # `iv_minus_rv_adaptive_enabled` è True. Tuple per coerenza con
    # frozen=True.
    iv_rv_history: tuple[Decimal, ...] = ()

    # Numero di giorni di calendario distinti coperti dalla storia
    # IV-RV disponibile (non solo dalla finestra `iv_rv_history`).
    # ``0`` = warmup hard, gate disabilitato (fail-open). Calcolato dal
    # caller via `repository.count_iv_rv_distinct_days`.
    iv_rv_n_days: int = 0

    # DVOL al tick più vicino a now - vol_of_vol_lookback_hours.
    # ``None`` = gap nel dato (es. cron mancante 24h fa) → VoV guard
    # skip. Caricato dal repository in `entry_cycle` quando
    # `vol_of_vol_guard_enabled` è True.
    dvol_24h_ago: Decimal | None = None


class EntryDecision(BaseModel):
    """Result of :func:`validate_entry`. ``reasons`` holds *all* blocking reasons."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    accepted: bool
    reasons: list[str]


class TrendContext(BaseModel):
    """Market regime inputs for :func:`compute_bias`."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    eth_now: Decimal
    eth_30d_ago: Decimal
    funding_cross_annualized: Decimal
    dvol_now: Decimal
    adx_14: Decimal


def validate_entry(ctx: EntryContext, cfg: StrategyConfig) -> EntryDecision:
    """Return the entry decision, collecting *every* failing condition.

    Order matches the documentation but does not short-circuit: callers
    receive the full list of reasons so the report message can explain
    why a trade was skipped without needing multiple passes.
    """
    reasons: list[str] = []
    entry_cfg = cfg.entry

    if ctx.has_open_position:
        reasons.append("open position already exists")

    if ctx.capital_usd < entry_cfg.capital_min_usd:
        reasons.append(
            f"capital below minimum ({ctx.capital_usd} < {entry_cfg.capital_min_usd})"
        )

    if ctx.dvol_now < entry_cfg.dvol_min:
        reasons.append(f"dvol too low ({ctx.dvol_now} < {entry_cfg.dvol_min})")
    elif ctx.dvol_now > entry_cfg.dvol_max:
        reasons.append(f"dvol too high ({ctx.dvol_now} > {entry_cfg.dvol_max})")

    if (
        ctx.next_macro_event_in_days is not None
        and ctx.next_macro_event_in_days <= entry_cfg.exclude_macro_within_days
    ):
        reasons.append(
            f"macro event within DTE window ({ctx.next_macro_event_in_days} days)"
        )

    if abs(ctx.funding_perp_annualized) > entry_cfg.funding_perp_abs_max_annualized:
        reasons.append(
            f"funding rate beyond cap ({ctx.funding_perp_annualized} vs "
            f"±{entry_cfg.funding_perp_abs_max_annualized})"
        )

    if ctx.eth_holdings_pct_of_portfolio > entry_cfg.eth_holdings_pct_max:
        reasons.append(
            f"eth holdings above cap ({ctx.eth_holdings_pct_of_portfolio} > "
            f"{entry_cfg.eth_holdings_pct_max})"
        )

    # §2.8: dealer-gamma regime gate. Skip the cycle when dealers are
    # net short gamma (vol-amplifying flow) — selling premium during a
    # short-gamma regime maximises path-dependent loss.
    if (
        entry_cfg.dealer_gamma_filter_enabled
        and ctx.dealer_net_gamma is not None
        and ctx.dealer_net_gamma < entry_cfg.dealer_gamma_min
    ):
        reasons.append(
            f"dealer short-gamma regime "
            f"({ctx.dealer_net_gamma} < {entry_cfg.dealer_gamma_min})"
        )

    # §2.8: liquidation-pressure gate. Skip when the heuristic flags an
    # imminent squeeze on either side.
    if (
        entry_cfg.liquidation_filter_enabled
        and ctx.liquidation_squeeze_risk_high is True
    ):
        reasons.append("imminent liquidation squeeze risk")

    # §2.9: IV richness gate. Vendere vol senza un margine misurabile
    # fra IV e RV è statisticamente neutro: l'edge della strategia
    # esiste solo quando il premio è "ricco" rispetto a quanto il
    # mercato si è effettivamente mosso. La modalità adattiva calcola
    # la soglia come max(P_q rolling, iv_minus_rv_min) sulla storia
    # disponibile in market_snapshots; altrimenti fallback alla
    # soglia statica `iv_minus_rv_min`.
    if entry_cfg.iv_minus_rv_filter_enabled and ctx.iv_minus_rv is not None:
        if entry_cfg.iv_minus_rv_adaptive_enabled:
            threshold = compute_adaptive_threshold(
                history=ctx.iv_rv_history,
                n_days=ctx.iv_rv_n_days,
                percentile=entry_cfg.iv_minus_rv_percentile,
                absolute_floor=entry_cfg.iv_minus_rv_min,
            )
            if threshold is not None and ctx.iv_minus_rv < threshold:
                pct = int(entry_cfg.iv_minus_rv_percentile * 100)
                reasons.append(
                    f"IV richness below P{pct} rolling "
                    f"(IV-RV={ctx.iv_minus_rv} < {threshold} vol pts)"
                )
        elif ctx.iv_minus_rv < entry_cfg.iv_minus_rv_min:
            reasons.append(
                f"IV richness below floor "
                f"(IV-RV={ctx.iv_minus_rv} < {entry_cfg.iv_minus_rv_min} vol pts)"
            )

    # §4-quater roadmap: vol-of-vol guard. Blocca entry quando il
    # regime di volatilità sta cambiando bruscamente, anche se IV-RV
    # è alto. Fail-open su gap dati 24h fa.
    if (
        entry_cfg.vol_of_vol_guard_enabled
        and ctx.dvol_24h_ago is not None
    ):
        delta = abs(ctx.dvol_now - ctx.dvol_24h_ago)
        if delta >= entry_cfg.vol_of_vol_threshold_pt:
            reasons.append(
                f"DVOL shifted {delta} pt in {entry_cfg.vol_of_vol_lookback_hours}h "
                f"(threshold {entry_cfg.vol_of_vol_threshold_pt})"
            )

    return EntryDecision(accepted=not reasons, reasons=reasons)


def _trend_signal(
    pct_change: Decimal,
    bull_threshold: Decimal,
    bear_threshold: Decimal,
) -> str:
    """Classify a percentage change into ``bull`` / ``bear`` / ``neutral``."""
    if pct_change >= bull_threshold:
        return "bull"
    if pct_change <= bear_threshold:
        return "bear"
    return "neutral"


def compute_bias(ctx: TrendContext, cfg: StrategyConfig) -> SpreadType | None:
    """Return the spread type to attempt, or ``None`` to skip the week."""
    entry_cfg = cfg.entry

    if ctx.eth_30d_ago <= 0:
        # Invalid market history: refuse to opine on bias.
        return None
    trend_pct = ctx.eth_now / ctx.eth_30d_ago - Decimal("1")
    trend = _trend_signal(
        trend_pct,
        entry_cfg.trend_bull_threshold_pct,
        entry_cfg.trend_bear_threshold_pct,
    )

    funding = _trend_signal(
        ctx.funding_cross_annualized,
        entry_cfg.funding_bull_threshold_annualized,
        entry_cfg.funding_bear_threshold_annualized,
    )

    if trend == "bull" and funding == "bull":
        return "bull_put"
    if trend == "bear" and funding == "bear":
        return "bear_call"
    if trend == "neutral" and funding == "neutral":
        if (
            ctx.dvol_now >= entry_cfg.iron_condor_dvol_min
            and ctx.adx_14 < entry_cfg.iron_condor_adx_max
        ):
            return "iron_condor"
        return None
    # All remaining combinations (discordant or one-neutral-one-directional)
    # explicitly fall through to "no entry".
    return None
