"""Deterministic recommendation eligibility logic.

Evaluates trend summaries against configurable thresholds to decide:
- Whether a recommendation should be generated at all
- What action type (buy/sell/hold/watch) is appropriate
- What execution mode (informational/paper_eligible/live_eligible) is allowed
- Position sizing guidance based on portfolio rules

All decisions are rule-based with no model involvement. The LLM is only
used downstream for optional thesis wording (a separate task).

Requirements: 7.1, 7.2, 7.3, 7.4, 14.1, 14.2, 14.3, 14.4, 14.5, 14.6

v3 additions:
- ReturnDistribution and EV gate (Requirement 12)
- Regime-aware eligibility and mode escalation (Requirements 13.1–13.5)
"""
from __future__ import annotations

import math
from dataclasses import dataclass, field
from enum import Enum

from services.shared.schemas import (
    ActionType,
    PositionSizing,
    RecommendationMode,
    TrendDirection,
    TrendSummary,
)


class RejectionReason(str, Enum):
    """Why a trend summary was deemed ineligible for a recommendation."""

    LOW_CONFIDENCE = "low_confidence"
    LOW_TREND_STRENGTH = "low_trend_strength"
    HIGH_CONTRADICTION = "high_contradiction"
    INSUFFICIENT_EVIDENCE = "insufficient_evidence"
    NEUTRAL_DIRECTION = "neutral_direction"


@dataclass(frozen=True)
class EligibilityConfig:
    """Tunable thresholds for recommendation eligibility.

    All thresholds are deterministic — no model inference involved.
    """

    # --- Gate thresholds (below these → no recommendation) ---
    min_confidence: float = 0.35
    min_trend_strength: float = 0.10
    max_contradiction_score: float = 0.60
    min_evidence_count: int = 2  # combined supporting + opposing

    # --- Action mapping thresholds ---
    # Trend strength above this → buy/sell; below → hold/watch
    action_strength_threshold: float = 0.25
    # Confidence above this → hold (rather than watch) for weak signals
    hold_confidence_threshold: float = 0.50

    # --- Mode escalation thresholds ---
    # Confidence required for paper_eligible (below → informational)
    paper_confidence_threshold: float = 0.50
    # Confidence required for live_eligible (below → paper at most)
    live_confidence_threshold: float = 0.70
    # Contradiction must be below this for live eligibility
    live_max_contradiction: float = 0.25
    # Minimum evidence count for live eligibility
    live_min_evidence: int = 5

    # --- Position sizing rules (Requirement 7.3) ---
    # Base portfolio allocation percentage
    base_portfolio_pct: float = 0.01
    # Maximum portfolio allocation percentage
    max_portfolio_pct: float = 0.10
    # Base max loss percentage
    base_max_loss_pct: float = 0.003
    # Maximum max loss percentage
    max_max_loss_pct: float = 0.02
    # Confidence scaling: higher confidence → larger position (linear)
    confidence_sizing_weight: float = 0.8
    # Contradiction penalty: higher contradiction → smaller position
    contradiction_sizing_penalty: float = 0.5

    # --- Expected value gate (Requirement 14) ---
    # EV threshold: minimum expected value to allow recommendation through
    ev_threshold: float = 0.005


DEFAULT_ELIGIBILITY_CONFIG = EligibilityConfig()


@dataclass
class EligibilityResult:
    """Output of the deterministic eligibility evaluation.

    Captures the decision, the reasoning, and all inputs used so the
    full decision trace is reproducible (Requirement 8.3).
    """

    eligible: bool
    action: ActionType
    mode: RecommendationMode
    position_sizing: PositionSizing
    rejection_reasons: list[RejectionReason] = field(default_factory=list)
    time_horizon: str = ""
    invalidation_conditions: list[str] = field(default_factory=list)

    # Probabilistic pipeline fields (Req 14.5, 16.2)
    ev_value: float | None = None
    p_bull: float | None = None
    pipeline_mode: str = "heuristic"


# ---------------------------------------------------------------------------
# Gate checks
# ---------------------------------------------------------------------------


def _check_gates(
    summary: TrendSummary,
    config: EligibilityConfig,
) -> list[RejectionReason]:
    """Apply hard gate checks. Returns a list of rejection reasons (empty = pass)."""
    reasons: list[RejectionReason] = []

    if summary.confidence < config.min_confidence:
        reasons.append(RejectionReason.LOW_CONFIDENCE)

    if summary.trend_strength < config.min_trend_strength:
        reasons.append(RejectionReason.LOW_TREND_STRENGTH)

    if summary.contradiction_score > config.max_contradiction_score:
        reasons.append(RejectionReason.HIGH_CONTRADICTION)

    evidence_count = len(summary.top_supporting_evidence) + len(summary.top_opposing_evidence)
    if evidence_count < config.min_evidence_count:
        reasons.append(RejectionReason.INSUFFICIENT_EVIDENCE)

    if summary.trend_direction == TrendDirection.NEUTRAL:
        reasons.append(RejectionReason.NEUTRAL_DIRECTION)

    return reasons


# ---------------------------------------------------------------------------
# Action mapping
# ---------------------------------------------------------------------------


def _determine_action(
    summary: TrendSummary,
    config: EligibilityConfig,
) -> ActionType:
    """Map trend direction and strength to an action type.

    Strong bullish → BUY, strong bearish → SELL.
    Weak but directional → HOLD if confidence is decent, else WATCH.
    Mixed → WATCH.
    """
    direction = summary.trend_direction
    strength = summary.trend_strength

    if direction == TrendDirection.MIXED:
        return ActionType.WATCH

    if direction == TrendDirection.NEUTRAL:
        return ActionType.WATCH

    strong_signal = strength >= config.action_strength_threshold

    if direction == TrendDirection.BULLISH:
        if strong_signal:
            return ActionType.BUY
        return ActionType.HOLD if summary.confidence >= config.hold_confidence_threshold else ActionType.WATCH

    if direction == TrendDirection.BEARISH:
        if strong_signal:
            return ActionType.SELL
        return ActionType.HOLD if summary.confidence >= config.hold_confidence_threshold else ActionType.WATCH

    return ActionType.WATCH


# ---------------------------------------------------------------------------
# Mode escalation
# ---------------------------------------------------------------------------


def _determine_mode(
    summary: TrendSummary,
    action: ActionType,
    config: EligibilityConfig,
) -> RecommendationMode:
    """Determine the highest execution mode allowed.

    WATCH and HOLD actions are always informational — they don't trigger trades.
    BUY/SELL can escalate to paper_eligible or live_eligible based on
    confidence, contradiction, and evidence thresholds.
    """
    if action in (ActionType.WATCH, ActionType.HOLD):
        return RecommendationMode.INFORMATIONAL

    evidence_count = len(summary.top_supporting_evidence) + len(summary.top_opposing_evidence)

    # Check live eligibility first (strictest)
    if (
        summary.confidence >= config.live_confidence_threshold
        and summary.contradiction_score <= config.live_max_contradiction
        and evidence_count >= config.live_min_evidence
    ):
        return RecommendationMode.LIVE_ELIGIBLE

    # Check paper eligibility
    if summary.confidence >= config.paper_confidence_threshold:
        return RecommendationMode.PAPER_ELIGIBLE

    return RecommendationMode.INFORMATIONAL


# ---------------------------------------------------------------------------
# Position sizing (Requirement 7.3)
# ---------------------------------------------------------------------------


def _compute_position_sizing(
    summary: TrendSummary,
    config: EligibilityConfig,
) -> PositionSizing:
    """Compute position sizing guidance from portfolio rules and signal quality.

    Higher confidence and trend strength → larger allocation (up to max).
    Higher contradiction → smaller allocation (penalty).
    Low evidence count further reduces allocation.
    """
    # Confidence-based scaling over the full range
    confidence_factor = config.confidence_sizing_weight * summary.confidence
    # Trend strength multiplier — stronger trends justify larger positions
    strength_factor = 0.5 + 0.5 * summary.trend_strength  # range [0.5, 1.0]

    portfolio_range = config.max_portfolio_pct - config.base_portfolio_pct
    raw_portfolio = config.base_portfolio_pct + confidence_factor * strength_factor * portfolio_range

    # Apply contradiction penalty
    contradiction_penalty = config.contradiction_sizing_penalty * summary.contradiction_score
    portfolio_pct = raw_portfolio * (1.0 - contradiction_penalty)

    # Evidence count penalty — fewer sources = less confidence in sizing
    evidence_count = len(summary.top_supporting_evidence) + len(summary.top_opposing_evidence)
    if evidence_count < 3:
        portfolio_pct *= 0.5
    elif evidence_count < 5:
        portfolio_pct *= 0.75

    # Clamp to bounds
    portfolio_pct = max(config.base_portfolio_pct * 0.5, min(portfolio_pct, config.max_portfolio_pct))

    # Max loss scales similarly
    loss_range = config.max_max_loss_pct - config.base_max_loss_pct
    raw_loss = config.base_max_loss_pct + confidence_factor * strength_factor * loss_range
    max_loss_pct = raw_loss * (1.0 - contradiction_penalty)
    if evidence_count < 3:
        max_loss_pct *= 0.5
    elif evidence_count < 5:
        max_loss_pct *= 0.75
    max_loss_pct = max(config.base_max_loss_pct * 0.5, min(max_loss_pct, config.max_max_loss_pct))

    return PositionSizing(
        portfolio_pct=round(portfolio_pct, 6),
        max_loss_pct=round(max_loss_pct, 6),
    )


# ---------------------------------------------------------------------------
# Time horizon mapping
# ---------------------------------------------------------------------------

_WINDOW_TO_HORIZON: dict[str, str] = {
    "intraday": "intraday",
    "1d": "swing_1d_3d",
    "7d": "swing_1d_10d",
    "30d": "position_10d_30d",
    "90d": "position_30d_90d",
}


def _map_time_horizon(window: str) -> str:
    """Map a trend window to a human-readable time horizon label."""
    return _WINDOW_TO_HORIZON.get(window, f"window_{window}")


# ---------------------------------------------------------------------------
# Invalidation conditions
# ---------------------------------------------------------------------------


def _derive_invalidation_conditions(
    summary: TrendSummary,
    action: ActionType,
) -> list[str]:
    """Generate deterministic invalidation conditions for the recommendation.

    These describe when the recommendation should be considered stale or wrong.
    """
    conditions: list[str] = []

    if action == ActionType.BUY:
        conditions.append(
            f"Trend direction for {summary.entity_id} reverses to bearish"
        )
    elif action == ActionType.SELL:
        conditions.append(
            f"Trend direction for {summary.entity_id} reverses to bullish"
        )

    if summary.contradiction_score > 0.0:
        conditions.append(
            f"Contradiction score exceeds 0.60 (currently {summary.contradiction_score:.2f})"
        )

    if summary.confidence > 0.0:
        conditions.append(
            f"Confidence drops below {summary.confidence * 0.7:.2f}"
        )

    if summary.material_risks:
        conditions.append(
            f"Material risk materialises: {summary.material_risks[0]}"
        )

    return conditions


# ---------------------------------------------------------------------------
# Expected value computation (Requirements: 14.1–14.6)
# ---------------------------------------------------------------------------

# Horizon days mapping for EV computation
_EV_HORIZON_DAYS: dict[str, float] = {
    "intraday": 1.0,
    "1d": 1.0,
    "7d": 7.0,
    "30d": 30.0,
    "90d": 90.0,
}


def compute_expected_value(
    p_bull: float,
    strength: float,
    sigma_20: float,
    horizon_days: float,
) -> float:
    """Compute expected value for the recommendation gate.

    Formula:
        R_up   = strength · σ_20 · √(horizon_days)
        R_down = (1 - strength) · σ_20 · √(horizon_days)
        EV     = P_bull · R_up - P_bear · R_down

    where P_bear = 1 - P_bull.

    Args:
        p_bull: Bayesian bullish probability in [0, 1].
        strength: Trend strength in [0, 1].
        sigma_20: 20-day return standard deviation.
        horizon_days: Number of days for the projection horizon.

    Returns:
        Expected value (can be negative).

    Requirements: 14.1, 14.2
    """
    p_bear = 1.0 - p_bull
    sqrt_horizon = math.sqrt(max(horizon_days, 0.0))
    r_up = strength * sigma_20 * sqrt_horizon
    r_down = (1.0 - strength) * sigma_20 * sqrt_horizon
    ev = p_bull * r_up - p_bear * r_down
    # Guard against NaN/infinity from extreme inputs
    if math.isnan(ev) or math.isinf(ev):
        return 0.0
    return ev


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------


def evaluate_eligibility(
    summary: TrendSummary,
    config: EligibilityConfig = DEFAULT_ELIGIBILITY_CONFIG,
    *,
    probabilistic: bool = False,
    p_bull: float | None = None,
    sigma_20: float = 0.01,
) -> EligibilityResult:
    """Evaluate a trend summary for recommendation eligibility.

    This is the single deterministic entry point. It:
    1. Applies gate checks (confidence, strength, contradiction, evidence)
    2. Maps trend direction + strength to an action type
    3. Determines the highest allowed execution mode
    4. Computes position sizing from portfolio rules
    5. Derives invalidation conditions
    6. (probabilistic) Applies EV gate: EV > threshold to proceed

    When ``probabilistic=True``:
    - Computes EV = P_bull · R_up - P_bear · R_down
    - When EV > threshold (default 0.005), allows recommendation through
    - When EV ≤ threshold, forces recommendation to informational mode
    - Populates expected_value, p_bull, pipeline_mode on result

    When ``probabilistic=False``:
    - Skips EV gate entirely (existing behavior)

    Args:
        summary: The current trend summary.
        config: Eligibility configuration thresholds.
        probabilistic: Use EV gate when True.
        p_bull: Bayesian bullish probability (required when probabilistic=True).
        sigma_20: 20-day return standard deviation for EV computation.

    Returns an EligibilityResult with the full decision trace.

    Requirements: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6
    """
    rejection_reasons = _check_gates(summary, config)

    # Even if rejected, we still compute action/mode for the trace
    action = _determine_action(summary, config)
    mode = _determine_mode(summary, action, config)
    sizing = _compute_position_sizing(summary, config)
    horizon = _map_time_horizon(summary.window.value)
    invalidation = _derive_invalidation_conditions(summary, action)

    eligible = len(rejection_reasons) == 0

    # If not eligible, force mode to informational (Requirement 7.4)
    if not eligible:
        mode = RecommendationMode.INFORMATIONAL

    # EV gate (Requirement 14.1–14.6)
    ev_value: float | None = None
    if probabilistic and p_bull is not None:
        horizon_days = _EV_HORIZON_DAYS.get(summary.window.value, 7.0)
        ev_value = compute_expected_value(
            p_bull=p_bull,
            strength=summary.trend_strength,
            sigma_20=sigma_20,
            horizon_days=horizon_days,
        )

        if ev_value <= config.ev_threshold:
            # Force to informational mode (Req 14.4)
            mode = RecommendationMode.INFORMATIONAL

    return EligibilityResult(
        eligible=eligible,
        action=action,
        mode=mode,
        position_sizing=sizing,
        rejection_reasons=rejection_reasons,
        time_horizon=horizon,
        invalidation_conditions=invalidation,
        ev_value=ev_value,
        p_bull=p_bull if probabilistic else None,
        pipeline_mode="probabilistic" if probabilistic else "heuristic",
    )


# ---------------------------------------------------------------------------
# V3 Return Distribution and EV Gate (Requirements 12.1–12.7)
# ---------------------------------------------------------------------------

_V3_MIN_EDGE: dict[str, float] = {
    "panic": 0.0100,
    "trend_following": 0.0035,
    "mean_reversion": 0.0050,
    "uncertainty": 0.0075,
}

_V3_ELIGIBILITY_THRESHOLDS: dict[str, tuple[float, float, float]] = {
    # (confidence_min, contradiction_max, strength_min)
    "panic": (0.70, 0.25, 0.36),
    "trend_following": (0.55, 0.40, 0.20),
    "mean_reversion": (0.60, 0.35, 0.26),
    "uncertainty": (0.65, 0.30, 0.30),
}


@dataclass(frozen=True)
class ReturnDistribution:
    """V3 return distribution model output.

    Encapsulates the horizon-scaled volatility, expected return, risk-adjusted
    expected value, regime minimum edge, and final eligibility decision.

    Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7
    """

    sigma_h: float  # realized_vol_20d * sqrt(horizon_days / 252)
    mu_h: float  # tanh(A_projected / 3.0) * confidence * sigma_h
    ev_long: float  # mu_h - costs - 0.10 * CVaR_5
    min_edge: float  # regime-specific minimum edge
    eligible: bool


def compute_return_distribution(
    a_projected: float,
    confidence: float,
    realized_vol_20d: float,
    horizon_days: int,
    costs: float,
    regime: str,
    *,
    confidence_actual: float = 1.0,
    contradiction: float = 0.0,
    n_eff_total: float = 0.0,
    data_quality: float = 1.0,
) -> ReturnDistribution:
    """Compute the v3 return distribution and EV gate eligibility.

    Implements the return distribution model from the v3 math spec:
    - sigma_h: horizon-scaled volatility
    - mu_h: expected return using tanh-compressed projected alpha
    - CVaR_5: Gaussian approximation of 5th percentile tail loss
    - EV_long: risk-adjusted expected value after costs and tail risk

    Eligibility requires:
    - EV_long > regime min_edge
    - EV_long > max(0.0025, 0.25 * costs)
    - confidence_actual >= regime confidence_min
    - contradiction <= regime contradiction_max
    - n_eff_total >= 2.0
    - data_quality >= 0.50

    Args:
        a_projected: Projected evidence state A_projected_h from projection.
        confidence: Multiplicative confidence from the v3 pipeline.
        realized_vol_20d: 20-day realized annualized volatility.
            If <= 0 or unavailable, defaults to 0.25.
        horizon_days: Trading days for the horizon (1, 7, 30, or 90).
        costs: Total costs (spread + slippage + commission).
        regime: Market regime string (panic, trend_following, mean_reversion, uncertainty).
        confidence_actual: The raw confidence value for threshold checks (defaults to
            same as confidence if not separately provided).
        contradiction: Contradiction score in [0, 1].
        n_eff_total: Total effective evidence count across clusters.
        data_quality: Data quality score in [0, 1].

    Returns:
        ReturnDistribution with computed fields and eligibility decision.

    Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.6, 12.7
    """
    # Default volatility when unavailable (Req 12.7)
    if realized_vol_20d is None or realized_vol_20d <= 0:  # type: ignore[redundant-expr]
        realized_vol_20d = 0.25

    # Req 12.1: sigma_h = realized_vol_20d * sqrt(horizon_days / 252)
    sigma_h = realized_vol_20d * math.sqrt(horizon_days / 252.0)

    # Req 12.2: mu_h = tanh(A_projected / 3.0) * confidence * sigma_h
    mu_h = math.tanh(a_projected / 3.0) * confidence * sigma_h

    # Req 12.3: CVaR_5 = sigma_h * 1.645 * 1.4
    cvar_5 = sigma_h * 1.645 * 1.4

    # Req 12.3: EV_long = mu_h - costs - 0.10 * CVaR_5
    ev_long = mu_h - costs - 0.10 * cvar_5

    # Req 12.4: regime-specific min_edge
    min_edge = _V3_MIN_EDGE.get(regime, _V3_MIN_EDGE["uncertainty"])

    # Req 12.5: EV_long > min_edge AND EV_long > max(0.0025, 0.25 * costs)
    ev_gate_passed = ev_long > min_edge and ev_long > max(0.0025, 0.25 * costs)

    # Req 12.6: Additional eligibility checks
    thresholds = _V3_ELIGIBILITY_THRESHOLDS.get(
        regime, _V3_ELIGIBILITY_THRESHOLDS["uncertainty"]
    )
    confidence_min, contradiction_max, _strength_min = thresholds

    quality_gate_passed = (
        confidence_actual >= confidence_min
        and contradiction <= contradiction_max
        and n_eff_total >= 2.0
        and data_quality >= 0.50
    )

    eligible = ev_gate_passed and quality_gate_passed

    return ReturnDistribution(
        sigma_h=sigma_h,
        mu_h=mu_h,
        ev_long=ev_long,
        min_edge=min_edge,
        eligible=eligible,
    )


# ===========================================================================
# v3 Regime-Aware Eligibility and Mode Escalation (Requirements 13.1–13.5)
# ===========================================================================

# Direction thresholds for action mapping (from bayesian.py)
_V3_DIRECTION_THRESHOLDS: dict[str, tuple[float, float]] = {
    "panic": (0.68, 0.32),
    "trend_following": (0.60, 0.40),
    "mean_reversion": (0.63, 0.37),
    "uncertainty": (0.65, 0.35),
}


@dataclass(frozen=True)
class V3Eligibility:
    """Result of v3 regime-aware eligibility evaluation.

    Requirements: 13.1–13.5
    """

    action: str  # "BUY", "SELL", "HOLD", "WATCH"
    mode: str  # "live", "paper", "informational"
    eligible: bool  # meets regime thresholds
    reasons: list[str]  # reasons for non-eligibility or downgrade


def compute_v3_eligibility(
    p_up: float,
    ev_long: float,
    min_edge: float,
    confidence: float,
    contradiction: float,
    strength: float,
    n_eff_total: float,
    data_quality: float,
    regime: str,
    has_existing_position: bool = False,
    risk_engine_passed: bool = True,
) -> V3Eligibility:
    """Compute regime-aware eligibility and mode escalation.

    Requirements: 13.1–13.5

    Steps:
    1. Check regime-specific eligibility gates (confidence, contradiction, strength)
    2. Determine action (BUY/SELL/HOLD/WATCH) based on P_up and EV
    3. Escalate mode (live/paper/informational) based on signal quality

    Args:
        p_up: Posterior probability of upward move from Bayesian posterior.
        ev_long: Expected value from return distribution.
        min_edge: Regime-specific minimum edge from EV gate.
        confidence: Multiplicative confidence score in [0, 1].
        contradiction: LLR entropy contradiction in [0, 1].
        strength: Signal strength = abs(2 * P_up - 1).
        n_eff_total: Effective evidence count across all clusters.
        data_quality: Data quality score in [0, 1].
        regime: Current market regime string.
        has_existing_position: Whether the entity already has an open position.
        risk_engine_passed: Whether the risk engine approved the trade.

    Returns:
        V3Eligibility with action, mode, eligible flag, and reasons.
    """
    reasons: list[str] = []

    # --- 1. Regime-specific eligibility gates (Requirement 13.1) ---
    conf_min, contra_max, str_min = _V3_ELIGIBILITY_THRESHOLDS.get(
        regime, (0.65, 0.30, 0.30)  # default to uncertainty thresholds
    )

    eligible = True

    if confidence < conf_min:
        eligible = False
        reasons.append(f"confidence {confidence:.3f} < regime min {conf_min:.2f}")

    if contradiction > contra_max:
        eligible = False
        reasons.append(
            f"contradiction {contradiction:.3f} > regime max {contra_max:.2f}"
        )

    if strength < str_min:
        eligible = False
        reasons.append(f"strength {strength:.3f} < regime min {str_min:.2f}")

    if data_quality < 0.50:
        eligible = False
        reasons.append(f"data_quality {data_quality:.3f} < 0.50")

    if n_eff_total < 2.0:
        eligible = False
        reasons.append(f"n_eff_total {n_eff_total:.2f} < 2.0")

    # --- 2. Action mapping (Requirement 13.2) ---
    bull_thresh, _bear_thresh = _V3_DIRECTION_THRESHOLDS.get(
        regime, (0.65, 0.35)
    )

    if not eligible:
        action = "WATCH"
    elif p_up >= bull_thresh and ev_long > min_edge:
        action = "BUY"
    elif has_existing_position and ev_long <= 0:
        # SELL when existing position and exit EV > hold EV
        # Simplified: EV_exit > EV_hold approximated as ev_long <= 0
        # (holding has negative expected value → better to exit)
        action = "SELL"
    elif has_existing_position:
        action = "HOLD"
    else:
        action = "WATCH"

    # --- 3. Mode escalation (Requirements 13.3, 13.4, 13.5) ---
    if action in ("BUY", "SELL"):
        # Check live eligibility (Requirement 13.3)
        if (
            confidence >= 0.75
            and contradiction <= 0.20
            and n_eff_total >= 5
            and ev_long > 2 * min_edge
            and risk_engine_passed
        ):
            mode = "live"
        # Check paper eligibility (Requirement 13.4)
        elif (
            confidence >= 0.60
            and ev_long > min_edge
            and risk_engine_passed
        ):
            mode = "paper"
        else:
            mode = "informational"
            if confidence < 0.60:
                reasons.append(
                    f"paper requires confidence >= 0.60, got {confidence:.3f}"
                )
            if ev_long <= min_edge:
                reasons.append(
                    f"paper requires EV > min_edge ({min_edge:.4f}), got {ev_long:.4f}"
                )
            if not risk_engine_passed:
                reasons.append("risk engine did not pass")
    else:
        # HOLD and WATCH are always informational (Requirement 13.5)
        mode = "informational"

    return V3Eligibility(
        action=action,
        mode=mode,
        eligible=eligible,
        reasons=reasons,
    )
