"""
opportunity_ranker.py

[SIMULATION-ONLY 2026-09-05] Ranks ALL currently-eligible symbols against
each other by risk-adjusted opportunity quality, per section 26: "the
bot must evaluate ALL eligible symbols, not just one... if only one slot
is available, trade the best one, not the first that passes." Not
consumed anywhere in the live bot yet -- see config.json's
opportunity_ranker._note.

DELIBERATELY REUSES prediction_engine.py's already-penalized score
rather than re-applying extension/exhaustion/spread/chop/poor-liquidity
penalties a second time -- section 26 lists those as opportunity-score
penalties, but prediction_engine.py already computes every one of them
(see its `penalties` dict) and folds them into `prediction.score`.
Re-deriving the same penalties here would double-penalize a symbol for
the same underlying condition under two different names. The one
genuinely NEW, comparative penalty added here is overhead-resistance
ROOM (section 26's "resistance" item) -- how close price already is to
the last confirmed swing high -- which is inherently a per-symbol
proximity check nothing upstream computes, not a duplicate of the
extension penalty (extension measures distance from VWAP/EMA9; this
measures distance to a hard structural ceiling).
"""

from dataclasses import dataclass, field

from config_loader import get_config

DEFAULT_CONFIG = {
    "weights": {
        "prediction": 30, "entry_quality": 25, "trend": 15,
        "pressure": 10, "structure": 10, "risk_quality": 10,
    },
    "resistance_penalty_weight": 10,
    "resistance_min_room_pct": 1.0,
}


@dataclass
class OpportunityCandidate:
    symbol: str
    opportunity_score: float
    eligible: bool
    action: str
    breakdown: dict = field(default_factory=dict)
    penalties: dict = field(default_factory=dict)
    reasons: list = field(default_factory=list)


def _direction_credit(direction: str) -> float:
    return {"UP": 1.0, "NEUTRAL": 0.3, "DOWN": 0.0}.get(direction, 0.3)


def _structure_credit(structure_label: str) -> float:
    return {"BULLISH_STRUCTURE": 1.0, "TRANSITION_STRUCTURE": 0.3,
            "RANGE_STRUCTURE": 0.3, "BEARISH_STRUCTURE": 0.0}.get(structure_label, 0.3)


def score_candidate(symbol: str, features, trend, structure, prediction, entry, cfg: dict = None) -> OpportunityCandidate:
    """
    entry: an entry_score.EntryScoreReading (already carries entry_score,
    risk_score, and the confirmed action for this symbol).
    """
    cfg = {**DEFAULT_CONFIG, **(cfg or get_config().get("opportunity_ranker", {}))}
    weights = {**DEFAULT_CONFIG["weights"], **(cfg.get("weights") or {})}

    if (getattr(features, "insufficient_data", False) or getattr(trend, "insufficient_data", False)
            or getattr(structure, "insufficient_data", False) or getattr(prediction, "insufficient_data", False)
            or getattr(entry, "insufficient_data", False)):
        return OpportunityCandidate(symbol=symbol, opportunity_score=0.0, eligible=False, action="WAIT",
                                     reasons=["insufficient upstream data"])

    pressure_score = features.pressure.get("score")
    pressure_component = (pressure_score + 100) / 200.0 if pressure_score is not None else 0.5

    components = {
        "prediction": prediction.score / 100.0,
        "entry_quality": entry.entry_score / 100.0,
        "trend": (trend.score / 100.0) * _direction_credit(trend.direction),
        "pressure": pressure_component,
        "structure": (structure.score / 100.0) * _structure_credit(structure.structure),
        "risk_quality": entry.risk_score / 100.0,
    }
    weight_total = sum(weights.values())
    breakdown = {k: round(components[k] * weights[k], 2) for k in components}
    base_score = sum(breakdown.values()) / weight_total * 100.0

    reasons = []
    resistance_penalty = 0.0
    last_high = getattr(structure, "last_swing_high", None)
    price = features.price.get("last")
    if last_high is not None and price is not None and last_high > price:
        room_pct = (last_high - price) / price * 100.0
        min_room = cfg["resistance_min_room_pct"]
        if room_pct < min_room:
            resistance_penalty = round((1 - room_pct / min_room) * cfg["resistance_penalty_weight"], 2)
            reasons.append(f"only {room_pct:.2f}% room before overhead resistance at {last_high}")

    penalties = {"resistance": resistance_penalty}
    opportunity_score = round(max(0.0, min(100.0, base_score - resistance_penalty)), 2)

    action = getattr(entry, "action", "WAIT")
    eligible = action == "READY"
    if not eligible:
        reasons.append(f"not currently tradeable (action={action})")

    return OpportunityCandidate(symbol=symbol, opportunity_score=opportunity_score, eligible=eligible,
                                 action=action, breakdown=breakdown, penalties=penalties, reasons=reasons)


def rank_opportunities(candidates: dict, cfg: dict = None) -> list:
    """
    candidates: {symbol: {"features":..., "trend":..., "structure":...,
                 "prediction":..., "entry":...}} for every symbol
                 currently being evaluated.
    Returns a list of OpportunityCandidate, ALL symbols (not just
    eligible ones) sorted descending by opportunity_score -- ranking
    everything is useful for logging/visibility even though only
    eligible=True candidates are actually tradeable right now.
    """
    scored = []
    for symbol, r in candidates.items():
        scored.append(score_candidate(symbol, r["features"], r["trend"], r["structure"],
                                       r["prediction"], r["entry"], cfg=cfg))
    scored.sort(key=lambda c: c.opportunity_score, reverse=True)
    return scored


def best_opportunities(ranked: list, max_slots: int = 1) -> list:
    """The top `max_slots` ELIGIBLE (action == READY) candidates from an
    already-ranked list -- per section 26's explicit requirement: when
    only one slot is available, take the best one, not the first
    candidate that happens to pass. A high-scoring but not-yet-READY
    (still CONFIRMING) candidate is correctly excluded here even if it
    outranks everything else -- confirmation must complete before a
    symbol is actually takeable, regardless of rank."""
    eligible = [c for c in ranked if c.eligible]
    return eligible[:max_slots]
