"""
Single Match Orchestrator (V20+)
================================
Primary prediction orchestration for frontend/live match clicks and automation.

Design goals:
- One authoritative match package contract.
- Scenario-consistent market board from a single prediction output.
- Data quality and risk tagging for consumer UX.
"""

from __future__ import annotations

import json
import re
import time
import math
import os
import pandas as pd
import numpy as np
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Set, Tuple

import psycopg2
from psycopg2.extras import RealDictCursor

from data.db import get_clean_dsn
from models.v20_ensemble import FullMatchPrediction
from models.v25_ensemble import V25Predictor, get_v25_predictor
from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge
from features.odds_band_analyzer import OddsBandAnalyzer
try:
    from models.basketball_v25 import (
        BasketballMatchPrediction,
        get_basketball_v25_predictor,
    )
except ImportError:
    BasketballMatchPrediction = Any
    def get_basketball_v25_predictor():
        raise ImportError("Basketball predictor is not available")
from core.engines.player_predictor import PlayerPrediction, get_player_predictor
from services.feature_enrichment import FeatureEnrichmentService
from services.betting_brain import BettingBrain
from services.v26_shadow_engine import V26ShadowEngine, get_v26_shadow_engine
from utils.top_leagues import load_top_league_ids
from utils.league_reliability import load_league_reliability


@dataclass
class MatchData:
    match_id: str
    home_team_id: str
    away_team_id: str
    home_team_name: str
    away_team_name: str
    match_date_ms: int
    sport: str
    league_id: Optional[str]
    league_name: str
    referee_name: Optional[str]
    odds_data: Dict[str, float]
    home_lineup: Optional[List[str]]
    away_lineup: Optional[List[str]]
    sidelined_data: Optional[Dict[str, Any]]
    home_goals_avg: float
    home_conceded_avg: float
    away_goals_avg: float
    away_conceded_avg: float
    home_position: int
    away_position: int
    lineup_source: str
    status: str = ""
    state: Optional[str] = None
    substate: Optional[str] = None
    current_score_home: Optional[int] = None
    current_score_away: Optional[int] = None
    lineup_confidence: float = 0.0


class SingleMatchOrchestrator:
    """Main V20+ application service used by API endpoints."""
    DEFAULT_MS_H = 2.65
    DEFAULT_MS_D = 3.20
    DEFAULT_MS_A = 2.65
    RELATIONAL_ODDS_KEYS = (
        "ms_h",
        "ms_d",
        "ms_a",
        "dc_1x",
        "dc_x2",
        "dc_12",
        "ou15_o",
        "ou15_u",
        "ou25_o",
        "ou25_u",
        "ou35_o",
        "ou35_u",
        "btts_y",
        "btts_n",
        "ht_h",
        "ht_d",
        "ht_a",
        "ht_ou05_o",
        "ht_ou05_u",
        "ht_ou15_o",
        "ht_ou15_u",
        "cards_o",
        "cards_u",
        "hcap_h",
        "hcap_d",
        "hcap_a",
        "ml_h",
        "ml_a",
        "tot_line",
        "tot_o",
        "tot_u",
        "spread_home_line",
        "spread_h",
        "spread_a",
    )
    V25_ODDS_FEATURE_KEYS = (
        "ms_h", "ms_d", "ms_a",
        "ht_h", "ht_d", "ht_a",
        "ou05_o", "ou05_u",
        "ou15_o", "ou15_u",
        "ou25_o", "ou25_u",
        "ou35_o", "ou35_u",
        "ht_ou05_o", "ht_ou05_u",
        "ht_ou15_o", "ht_ou15_u",
        "btts_y", "btts_n",
    )
    ODDS_REQUIRED_MARKETS = (
        "MS",
        "DC",
        "OU15",
        "OU25",
        "OU35",
        "BTTS",
        "HT",
        "HT_OU05",
        "HT_OU15",
        "HTFT",
        "OE",
        "CARDS",
        "HCAP",
    )

    def __init__(self) -> None:
        self.v25_predictor: Optional[V25Predictor] = None
        self.v26_shadow_engine: Optional[V26ShadowEngine] = None
        self.basketball_predictor: Optional[Any] = None
        self.dsn = get_clean_dsn()
        self.engine_mode = str(os.getenv("AI_ENGINE_MODE", "v28-pro-max")).strip().lower()
        self.top_league_ids = load_top_league_ids()
        self.league_reliability = load_league_reliability()
        self.enrichment = FeatureEnrichmentService()
        self.odds_band_analyzer = OddsBandAnalyzer()
        # ── V32 Calibration Rebalance ──────────────────────────────────
        # RULE: max_reachable = 100 × calibration MUST be > min_conf + 8
        # Previous values had 5 markets where this was IMPOSSIBLE:
        #   HT(0.42×100=42 < 45), HCAP(0.40×100=40 < 46), HTFT(0.28×100=28 < 32)
        #   HT_OU15(0.46×100=46 < 48), CARDS(0.45×100=45 < 48)
        # These markets could NEVER become playable → all predictions were PASS.
        #
        # New calibration: conservative but mathematically achievable.
        # Each market's calibration ensures high-confidence model outputs CAN pass.
        self.market_calibration: Dict[str, float] = {
            "MS": 0.62,       # max=62 vs min=42 ✓ (was 0.48→max=48 vs 44 ⚠️)
            "DC": 0.82,       # max=82 vs min=52 ✓ (unchanged, already good)
            "OU15": 0.84,     # max=84 vs min=55 ✓ (unchanged, already good)
            "OU25": 0.68,     # max=68 vs min=48 ✓ (was 0.54→max=54 vs 52 ⚠️)
            "OU35": 0.60,     # max=60 vs min=48 ✓ (was 0.44→max=44 vs 54 ❌)
            "BTTS": 0.65,     # max=65 vs min=46 ✓ (was 0.50→max=50 vs 50 ⚠️)
            "HT": 0.58,       # max=58 vs min=40 ✓ (was 0.42→max=42 vs 45 ❌)
            "HT_OU05": 0.68,  # max=68 vs min=50 ✓ (unchanged)
            "HT_OU15": 0.60,  # max=60 vs min=42 ✓ (was 0.46→max=46 vs 48 ❌)
            "OE": 0.62,       # max=62 vs min=46 ✓ (was 0.58→max=58 vs 50 ok)
            "CARDS": 0.58,    # max=58 vs min=42 ✓ (was 0.45→max=45 vs 48 ❌)
            "HCAP": 0.56,     # max=56 vs min=40 ✓ (was 0.40→max=40 vs 46 ❌)
            "HTFT": 0.45,     # max=45 vs min=28 ✓ (was 0.28→max=28 vs 32 ❌)
        }
        # Min confidence: lowered to be achievable (max_reachable - 16 to -20)
        self.market_min_conf: Dict[str, float] = {
            "MS": 42.0,       # was 44 — 3-way market, hard to get high conf
            "DC": 52.0,       # was 55 — double chance is easier
            "OU15": 55.0,     # was 58 — binary + usually high conf
            "OU25": 48.0,     # was 52 — core market, allow more through
            "OU35": 48.0,     # was 54 — lowered to let signals pass
            "BTTS": 46.0,     # was 50 — binary market
            "HT": 40.0,       # was 45 — was ❌ impossible, now achievable
            "HT_OU05": 50.0,  # was 54 — binary HT market
            "HT_OU15": 42.0,  # was 48 — was ❌ impossible, now achievable
            "OE": 46.0,       # was 50 — coin-flip market, lower bar
            "CARDS": 42.0,    # was 48 — was ❌ impossible, now achievable
            "HCAP": 40.0,     # was 46 — was ❌ impossible, now achievable
            "HTFT": 28.0,     # was 32 — was ❌ impossible, 9-way market
        }
        # Min play score: moderate reduction to allow more C-grade bets
        self.market_min_play_score: Dict[str, float] = {
            "MS": 65.0,       # was 72 — let more MS through for tracking
            "DC": 58.0,       # was 62 — DC is high accuracy
            "OU15": 60.0,     # was 64 — strong market per backtest
            "OU25": 64.0,     # was 70 — core market
            "OU35": 68.0,     # was 76 — riskier market
            "BTTS": 64.0,     # was 70 — allow more signals
            "HT": 66.0,       # was 74 — was never reachable anyway
            "HT_OU05": 60.0,  # was 64 — strong backtest market
            "HT_OU15": 64.0,  # was 72 — moderate
            "OE": 60.0,       # was 66 — low priority market
            "CARDS": 66.0,    # was 74 — niche market
            "HCAP": 68.0,     # was 76 — risky
            "HTFT": 72.0,     # was 82 — 9-way, very risky
        }
        self.market_min_edge: Dict[str, float] = {
            "MS": 0.02,       # was 0.03 — slight relaxation
            "DC": 0.01,       # unchanged
            "OU15": 0.01,     # unchanged
            "OU25": 0.02,     # unchanged
            "OU35": 0.03,     # was 0.04
            "BTTS": 0.02,     # was 0.03
            "HT": 0.03,       # was 0.04
            "HT_OU05": 0.01,  # unchanged
            "HT_OU15": 0.02,  # was 0.03
            "OE": 0.02,       # unchanged
            "CARDS": 0.02,    # was 0.03
            "HCAP": 0.03,     # was 0.04
            "HTFT": 0.05,     # was 0.06
        }

    def _get_v25_predictor(self) -> V25Predictor:
        if self.v25_predictor is None:
            try:
                self.v25_predictor = get_v25_predictor()
                print(f"[V25] ✅ Predictor loaded: {len(self.v25_predictor.models)} market models")
            except Exception as e:
                print(f"[V25] ❌ PREDICTOR LOAD FAILED: {e}")
                raise
        return self.v25_predictor

    def _get_v26_shadow_engine(self) -> V26ShadowEngine:
        if getattr(self, "v26_shadow_engine", None) is None:
            self.v26_shadow_engine = get_v26_shadow_engine()
        return self.v26_shadow_engine

    def _get_v27_predictor(self) -> Optional[V27Predictor]:
        """Non-fatal V27 loader — returns None if models can't load."""
        if getattr(self, "_v27", None) is not None:
            return self._v27
        try:
            pred = V27Predictor()
            if pred.load_models():
                self._v27 = pred
                print(f"[V27] ✅ Predictor loaded: {sum(len(v) for v in pred.models.values())} models")
                return self._v27
        except Exception as e:
            print(f"[V27] ⚠ Load failed (non-fatal): {e}")
        self._v27 = None
        return None

    def _build_v25_features(self, data: MatchData) -> Dict[str, float]:
        """
        Build the single authoritative V25 pre-match feature vector.
        """
        odds = self._sanitize_v25_odds(data.odds_data or {})
        ms_h = float(odds.get('ms_h', 0))
        ms_d = float(odds.get('ms_d', 0))
        ms_a = float(odds.get('ms_a', 0))

        # Implied probabilities (vig-normalised)
        implied_home, implied_draw, implied_away = 0.33, 0.33, 0.33
        if ms_h > 0 and ms_d > 0 and ms_a > 0:
            raw_sum = 1 / ms_h + 1 / ms_d + 1 / ms_a
            implied_home = (1 / ms_h) / raw_sum
            implied_draw = (1 / ms_d) / raw_sum
            implied_away = (1 / ms_a) / raw_sum
        upset_potential = max(
            0.0,
            min(
                1.0,
                1.0 - abs(implied_home - implied_away) + (implied_draw * 0.35),
            ),
        )

        # All enrichment queries in a single DB connection
        home_elo, away_elo = 1500.0, 1500.0
        home_venue_elo, away_venue_elo = 1500.0, 1500.0
        home_form_elo_val, away_form_elo_val = 1500.0, 1500.0
        enr = self.enrichment
        try:
            with psycopg2.connect(self.dsn) as conn:
                with conn.cursor(cursor_factory=RealDictCursor) as cur:
                    # ELO (overall + venue + form) - Updated for sport-partitioned schema
                    cur.execute(
                        "SELECT home_elo, away_elo, "
                        "       home_home_elo, away_away_elo, "
                        "       home_form_elo, away_form_elo "
                        "FROM football_ai_features "
                        "WHERE match_id = %s LIMIT 1",
                        (data.match_id,),
                    )
                    elo_row = cur.fetchone()
                    if elo_row:
                        home_elo = float(elo_row.get('home_elo') or 1500.0)
                        away_elo = float(elo_row.get('away_elo') or 1500.0)
                        home_venue_elo = float(elo_row.get('home_home_elo') or home_elo)
                        away_venue_elo = float(elo_row.get('away_away_elo') or away_elo)
                        home_form_elo_val = float(elo_row.get('home_form_elo') or home_elo)
                        away_form_elo_val = float(elo_row.get('away_form_elo') or away_elo)

                    # Enrichment queries
                    home_stats = enr.compute_team_stats(cur, data.home_team_id, data.match_date_ms)
                    away_stats = enr.compute_team_stats(cur, data.away_team_id, data.match_date_ms)
                    h2h = enr.compute_h2h(cur, data.home_team_id, data.away_team_id, data.match_date_ms)
                    home_form = enr.compute_form_streaks(cur, data.home_team_id, data.match_date_ms)
                    away_form = enr.compute_form_streaks(cur, data.away_team_id, data.match_date_ms)
                    ref = enr.compute_referee_stats(cur, data.referee_name, data.match_date_ms)
                    league = enr.compute_league_averages(cur, data.league_id, data.match_date_ms)
                    home_momentum = enr.compute_momentum(cur, data.home_team_id, data.match_date_ms)
                    away_momentum = enr.compute_momentum(cur, data.away_team_id, data.match_date_ms)
                    # V27 enrichment
                    home_rolling = enr.compute_rolling_stats(cur, data.home_team_id, data.match_date_ms)
                    away_rolling = enr.compute_rolling_stats(cur, data.away_team_id, data.match_date_ms)
                    home_venue = enr.compute_venue_stats(cur, data.home_team_id, data.match_date_ms, is_home=True)
                    away_venue = enr.compute_venue_stats(cur, data.away_team_id, data.match_date_ms, is_home=False)
                    home_rest = enr.compute_days_rest(cur, data.home_team_id, data.match_date_ms)
                    away_rest = enr.compute_days_rest(cur, data.away_team_id, data.match_date_ms)
                    # V28 Odds-Band Historical Performance
                    odds_band_features = self.odds_band_analyzer.compute_all(
                        cur=cur,
                        home_team_id=data.home_team_id,
                        away_team_id=data.away_team_id,
                        league_id=data.league_id,
                        odds=odds,
                        before_ts=data.match_date_ms,
                        referee_name=data.referee_name,
                    )
        except Exception:
            # Full fallback — use all defaults
            home_stats = dict(enr._DEFAULT_TEAM_STATS)
            away_stats = dict(enr._DEFAULT_TEAM_STATS)
            h2h = dict(enr._DEFAULT_H2H)
            home_form = dict(enr._DEFAULT_FORM)
            away_form = dict(enr._DEFAULT_FORM)
            ref = dict(enr._DEFAULT_REFEREE)
            league = dict(enr._DEFAULT_LEAGUE)
            home_momentum = 0.0
            away_momentum = 0.0
            # V27 fallbacks
            home_rolling = dict(enr._DEFAULT_ROLLING)
            away_rolling = dict(enr._DEFAULT_ROLLING)
            home_venue = dict(enr._DEFAULT_VENUE)
            away_venue = dict(enr._DEFAULT_VENUE)
            home_rest = 7.0
            away_rest = 7.0
            odds_band_features = {}  # V28 fallback

        odds_presence = {
            'odds_ms_h_present': 1.0 if ms_h > 1.01 else 0.0,
            'odds_ms_d_present': 1.0 if ms_d > 1.01 else 0.0,
            'odds_ms_a_present': 1.0 if ms_a > 1.01 else 0.0,
            'odds_ht_ms_h_present': 1.0 if float(odds.get('ht_h', 0)) > 1.01 else 0.0,
            'odds_ht_ms_d_present': 1.0 if float(odds.get('ht_d', 0)) > 1.01 else 0.0,
            'odds_ht_ms_a_present': 1.0 if float(odds.get('ht_a', 0)) > 1.01 else 0.0,
            'odds_ou05_o_present': 1.0 if float(odds.get('ou05_o', 0)) > 1.01 else 0.0,
            'odds_ou05_u_present': 1.0 if float(odds.get('ou05_u', 0)) > 1.01 else 0.0,
            'odds_ou15_o_present': 1.0 if float(odds.get('ou15_o', 0)) > 1.01 else 0.0,
            'odds_ou15_u_present': 1.0 if float(odds.get('ou15_u', 0)) > 1.01 else 0.0,
            'odds_ou25_o_present': 1.0 if float(odds.get('ou25_o', 0)) > 1.01 else 0.0,
            'odds_ou25_u_present': 1.0 if float(odds.get('ou25_u', 0)) > 1.01 else 0.0,
            'odds_ou35_o_present': 1.0 if float(odds.get('ou35_o', 0)) > 1.01 else 0.0,
            'odds_ou35_u_present': 1.0 if float(odds.get('ou35_u', 0)) > 1.01 else 0.0,
            'odds_ht_ou05_o_present': 1.0 if float(odds.get('ht_ou05_o', 0)) > 1.01 else 0.0,
            'odds_ht_ou05_u_present': 1.0 if float(odds.get('ht_ou05_u', 0)) > 1.01 else 0.0,
            'odds_ht_ou15_o_present': 1.0 if float(odds.get('ht_ou15_o', 0)) > 1.01 else 0.0,
            'odds_ht_ou15_u_present': 1.0 if float(odds.get('ht_ou15_u', 0)) > 1.01 else 0.0,
            'odds_btts_y_present': 1.0 if float(odds.get('btts_y', 0)) > 1.01 else 0.0,
            'odds_btts_n_present': 1.0 if float(odds.get('btts_n', 0)) > 1.01 else 0.0,
        }

        # ── Calendar features (V27) ──
        import datetime
        match_dt = datetime.datetime.utcfromtimestamp(data.match_date_ms / 1000)
        match_month = match_dt.month
        is_season_start = 1.0 if match_month in (7, 8, 9) else 0.0
        is_season_end = 1.0 if match_month in (5, 6) else 0.0

        # ── Derived / Interaction features (V27) ──
        elo_diff = home_elo - away_elo
        form_elo_diff = home_form_elo_val - away_form_elo_val
        attack_vs_defense_home = data.home_goals_avg - data.away_conceded_avg
        attack_vs_defense_away = data.away_goals_avg - data.home_conceded_avg
        xga_home = data.home_conceded_avg
        xga_away = data.away_conceded_avg
        xg_diff = xga_home - xga_away
        mom_diff = home_momentum - away_momentum
        form_momentum_interaction = mom_diff * form_elo_diff / 1000.0
        elo_form_consistency = 1.0 - abs(elo_diff - form_elo_diff) / max(abs(elo_diff), 100.0)
        upset_x_elo_gap = upset_potential * abs(elo_diff) / 500.0

        return {
            # META (1)
            'mst_utc': float(data.match_date_ms),
            # ELO (8)
            'home_overall_elo': home_elo,
            'away_overall_elo': away_elo,
            'elo_diff': elo_diff,
            'home_home_elo': home_venue_elo,
            'away_away_elo': away_venue_elo,
            'home_form_elo': home_form_elo_val,
            'away_form_elo': away_form_elo_val,
            'form_elo_diff': form_elo_diff,
            # Form (12)
            'home_goals_avg': data.home_goals_avg,
            'home_conceded_avg': data.home_conceded_avg,
            'away_goals_avg': data.away_goals_avg,
            'away_conceded_avg': data.away_conceded_avg,
            'home_clean_sheet_rate': home_form['clean_sheet_rate'],
            'away_clean_sheet_rate': away_form['clean_sheet_rate'],
            'home_scoring_rate': home_form['scoring_rate'],
            'away_scoring_rate': away_form['scoring_rate'],
            'home_winning_streak': home_form['winning_streak'],
            'away_winning_streak': away_form['winning_streak'],
            'home_unbeaten_streak': home_form['unbeaten_streak'],
            'away_unbeaten_streak': away_form['unbeaten_streak'],
            # H2H (10 — original 6 + V27 expanded 4)
            'h2h_total_matches': h2h['total_matches'],
            'h2h_home_win_rate': h2h['home_win_rate'],
            'h2h_draw_rate': h2h['draw_rate'],
            'h2h_avg_goals': h2h['avg_goals'],
            'h2h_btts_rate': h2h['btts_rate'],
            'h2h_over25_rate': h2h['over25_rate'],
            'h2h_home_goals_avg': h2h['home_goals_avg'],
            'h2h_away_goals_avg': h2h['away_goals_avg'],
            'h2h_recent_trend': h2h['recent_trend'],
            'h2h_venue_advantage': h2h['venue_advantage'],
            # Stats (8)
            'home_avg_possession': home_stats['avg_possession'],
            'away_avg_possession': away_stats['avg_possession'],
            'home_avg_shots_on_target': home_stats['avg_shots_on_target'],
            'away_avg_shots_on_target': away_stats['avg_shots_on_target'],
            'home_shot_conversion': home_stats['shot_conversion'],
            'away_shot_conversion': away_stats['shot_conversion'],
            'home_avg_corners': home_stats['avg_corners'],
            'away_avg_corners': away_stats['avg_corners'],
            # Odds (24)
            'odds_ms_h': ms_h,
            'odds_ms_d': ms_d,
            'odds_ms_a': ms_a,
            'implied_home': implied_home,
            'implied_draw': implied_draw,
            'implied_away': implied_away,
            'odds_ht_ms_h': float(odds.get('ht_h', 0)),
            'odds_ht_ms_d': float(odds.get('ht_d', 0)),
            'odds_ht_ms_a': float(odds.get('ht_a', 0)),
            'odds_ou05_o': float(odds.get('ou05_o', 0)),
            'odds_ou05_u': float(odds.get('ou05_u', 0)),
            'odds_ou15_o': float(odds.get('ou15_o', 0)),
            'odds_ou15_u': float(odds.get('ou15_u', 0)),
            'odds_ou25_o': float(odds.get('ou25_o', 0)),
            'odds_ou25_u': float(odds.get('ou25_u', 0)),
            'odds_ou35_o': float(odds.get('ou35_o', 0)),
            'odds_ou35_u': float(odds.get('ou35_u', 0)),
            'odds_ht_ou05_o': float(odds.get('ht_ou05_o', 0)),
            'odds_ht_ou05_u': float(odds.get('ht_ou05_u', 0)),
            'odds_ht_ou15_o': float(odds.get('ht_ou15_o', 0)),
            'odds_ht_ou15_u': float(odds.get('ht_ou15_u', 0)),
            'odds_btts_y': float(odds.get('btts_y', 0)),
            'odds_btts_n': float(odds.get('btts_n', 0)),
            **odds_presence,
            # League (9 — original 2 + V27 expanded 5 + xga 2)
            'home_xga': xga_home,
            'away_xga': xga_away,
            'league_avg_goals': league['avg_goals'],
            'league_zero_goal_rate': league['zero_goal_rate'],
            'league_home_win_rate': league['home_win_rate'],
            'league_draw_rate': league['draw_rate'],
            'league_btts_rate': league['btts_rate'],
            'league_ou25_rate': league['ou25_rate'],
            'league_reliability_score': league['reliability_score'],
            # Upset (4)
            'upset_atmosphere': 0.0,
            'upset_motivation': 0.0,
            'upset_fatigue': 0.0,
            'upset_potential': upset_potential,
            # Referee (5)
            'referee_home_bias': ref['home_bias'],
            'referee_avg_goals': ref['avg_goals'],
            'referee_cards_total': ref['cards_total'],
            'referee_avg_yellow': ref['avg_yellow'],
            'referee_experience': ref['experience'],
            # Momentum (3)
            'home_momentum_score': home_momentum,
            'away_momentum_score': away_momentum,
            'momentum_diff': mom_diff,
            # ── V27 Rolling Stats (13) ──
            'home_rolling5_goals': home_rolling['rolling5_goals'],
            'home_rolling5_conceded': home_rolling['rolling5_conceded'],
            'home_rolling10_goals': home_rolling['rolling10_goals'],
            'home_rolling10_conceded': home_rolling['rolling10_conceded'],
            'home_rolling20_goals': home_rolling['rolling20_goals'],
            'home_rolling20_conceded': home_rolling['rolling20_conceded'],
            'away_rolling5_goals': away_rolling['rolling5_goals'],
            'away_rolling5_conceded': away_rolling['rolling5_conceded'],
            'away_rolling10_goals': away_rolling['rolling10_goals'],
            'away_rolling10_conceded': away_rolling['rolling10_conceded'],
            'home_rolling5_cs': home_rolling['rolling5_cs'],
            'away_rolling5_cs': away_rolling['rolling5_cs'],
            # ── V27 Venue Stats (4) ──
            'home_venue_goals': home_venue['venue_goals'],
            'home_venue_conceded': home_venue['venue_conceded'],
            'away_venue_goals': away_venue['venue_goals'],
            'away_venue_conceded': away_venue['venue_conceded'],
            # ── V27 Goal Trend (2) ──
            'home_goal_trend': home_rolling['rolling5_goals'] - home_rolling['rolling10_goals'],
            'away_goal_trend': away_rolling['rolling5_goals'] - away_rolling['rolling10_goals'],
            # ── V27 Calendar (4) ──
            'home_days_rest': home_rest,
            'away_days_rest': away_rest,
            'match_month': float(match_month),
            'is_season_start': is_season_start,
            'is_season_end': is_season_end,
            # ── V27 Interaction (6) ──
            'attack_vs_defense_home': attack_vs_defense_home,
            'attack_vs_defense_away': attack_vs_defense_away,
            'xg_diff': xg_diff,
            'form_momentum_interaction': form_momentum_interaction,
            'elo_form_consistency': elo_form_consistency,
            'upset_x_elo_gap': upset_x_elo_gap,
            # Squad Features (9) — PlayerPredictorEngine
            **self._get_squad_features(data),
            # V28 Odds-Band Historical Performance Features
            **odds_band_features,
        }

    def _get_squad_features(self, data: MatchData) -> Dict[str, float]:
        """Non-fatal squad analysis. Returns neutral-average defaults on failure.

        Design note (V32-fix): Previous 0.0 defaults caused the model to treat
        missing lineups as 'both teams have zero quality', producing overly
        conservative predictions (e.g. static 1.5 Under).  Neutral averages let
        the model fall back on stronger signals (odds, ELO, form, H2H).
        """
        defaults = {
            'home_squad_quality': 0.50, 'away_squad_quality': 0.50, 'squad_diff': 0.0,
            'home_key_players': 3.0, 'away_key_players': 3.0,
            'home_missing_impact': 0.0, 'away_missing_impact': 0.0,
            'home_goals_form': 1.3, 'away_goals_form': 1.3,
        }
        try:
            engine = get_player_predictor()
            pred = engine.predict(
                match_id=data.match_id,
                home_team_id=data.home_team_id,
                away_team_id=data.away_team_id,
                home_lineup=data.home_lineup,
                away_lineup=data.away_lineup,
                sidelined_data=data.sidelined_data,
            )
            return {
                'home_squad_quality': float(pred.home_squad_quality),
                'away_squad_quality': float(pred.away_squad_quality),
                'squad_diff': float(pred.squad_diff),
                'home_key_players': float(pred.home_key_players),
                'away_key_players': float(pred.away_key_players),
                'home_missing_impact': float(pred.home_missing_impact),
                'away_missing_impact': float(pred.away_missing_impact),
                'home_goals_form': float(pred.home_goals_form),
                'away_goals_form': float(pred.away_goals_form),
            }
        except Exception as e:
            print(f"⚠️ Squad features failed: {e}")
            return defaults

    # ── V25 internal key → _build_v25_prediction key mapping ──
    _V25_KEY_MAP = {
        "ms": "MS",
        "ou15": "OU15",
        "ou25": "OU25",
        "ou35": "OU35",
        "btts": "BTTS",
        "ht_result": "HT",
        "ht_ou05": "HT_OU05",
        "ht_ou15": "HT_OU15",
        "htft": "HTFT",
        "cards_ou45": "CARDS",
        "handicap_ms": "HCAP",
        "odd_even": "OE",
    }

    def _get_v25_signal(
        self,
        data: MatchData,
        features: Optional[Dict[str, float]] = None,
    ) -> Dict[str, Any]:
        """
        Get V25 ensemble predictions for all available markets.
        Returns a dict keyed by UPPERCASE market name (MS, OU25, BTTS, etc.)
        each with a 'probs' sub-dict that _prob_map can consume.

        CRITICAL: Keys MUST be uppercase to match _build_v25_prediction lookups.
        """
        v25 = self._get_v25_predictor()
        feature_row = features or self._build_v25_features(data)

        signal: Dict[str, Any] = {}

        def _temperature_scale(probs_dict: Dict[str, float], temperature: float = 2.5) -> Dict[str, float]:
            """
            Apply temperature scaling to soften overconfident model outputs.

            LightGBM often produces extreme probabilities (e.g., 0.999 / 0.001).
            Temperature scaling converts to log-odds, divides by T, then re-normalizes.
            T=1.0 → no change, T>1 → softer probabilities.

            Standard approach for post-hoc model calibration (Guo et al., 2017).
            """
            import math
            eps = 1e-7  # numerical stability
            n = len(probs_dict)

            # Determine appropriate temperature based on market type
            # Binary markets (2-class) tend to be more overconfident in LGB
            if n <= 2:
                T = max(temperature, 2.0)
            elif n == 3:
                T = max(temperature * 0.8, 1.5)  # 3-way slightly less aggressive
            else:
                T = max(temperature * 0.6, 1.3)  # 9-way (HTFT) already spread

            # Convert to log-odds and apply temperature
            labels = list(probs_dict.keys())
            log_odds = []
            for label in labels:
                p = max(eps, min(1.0 - eps, float(probs_dict[label])))
                log_odds.append(math.log(p) / T)

            # Softmax re-normalization
            max_lo = max(log_odds)
            exp_vals = [math.exp(lo - max_lo) for lo in log_odds]
            total = sum(exp_vals)

            scaled = {}
            for i, label in enumerate(labels):
                scaled[label] = exp_vals[i] / total

            return scaled

        def _enrich_signal_entry(probs_dict: Dict[str, float]) -> Dict[str, Any]:
            """Add pick, probability, confidence to a signal entry from its probs.

            Applies temperature scaling to convert overconfident LightGBM outputs
            into realistic, calibrated probabilities.
            """
            # Apply temperature scaling to soften extreme probabilities
            scaled_probs = _temperature_scale(probs_dict, temperature=2.5)

            best_label = max(scaled_probs, key=scaled_probs.get)
            best_prob = float(scaled_probs[best_label])
            return {
                "probs": scaled_probs,
                "raw_probs": probs_dict,  # keep originals for debugging
                "pick": best_label,
                "probability": best_prob,
                "confidence": round(best_prob * 100.0, 1),
            }

        # Core markets using dedicated methods
        h, d, a = v25.predict_ms(feature_row)
        signal["MS"] = _enrich_signal_entry({"1": h, "X": d, "2": a})
        print(f"  [V25-SIGNAL] MS → H={h:.4f} D={d:.4f} A={a:.4f}")

        over25, under25 = v25.predict_ou25(feature_row)
        signal["OU25"] = _enrich_signal_entry({"Over": over25, "Under": under25})
        print(f"  [V25-SIGNAL] OU25 → O={over25:.4f} U={under25:.4f}")

        btts_y, btts_n = v25.predict_btts(feature_row)
        signal["BTTS"] = _enrich_signal_entry({"Yes": btts_y, "No": btts_n})
        print(f"  [V25-SIGNAL] BTTS → Y={btts_y:.4f} N={btts_n:.4f}")

        # Additional markets via generic predict_market
        for model_key, label_map in [
            ("ou15", {"Over": 0, "Under": None}),
            ("ou35", {"Over": 0, "Under": None}),
            ("ht_result", {"1": 0, "X": 1, "2": 2}),
            ("ht_ou05", {"Over": 0, "Under": None}),
            ("ht_ou15", {"Over": 0, "Under": None}),
            ("htft", None),
            ("cards_ou45", {"Over": 0, "Under": None}),
            ("handicap_ms", {"1": 0, "X": 1, "2": 2}),
            ("odd_even", {"Odd": 0, "Even": None}),
        ]:
            out_key = self._V25_KEY_MAP.get(model_key, model_key.upper())
            if not v25.has_market(model_key):
                continue
            raw = v25.predict_market(model_key, feature_row)
            if raw is None:
                continue

            if label_map is None:
                # HTFT — 9 combinations
                htft_labels = ["1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2"]
                probs_dict = {}
                for i, label in enumerate(htft_labels):
                    probs_dict[label] = float(raw[i]) if i < len(raw) else 0.0
                signal[out_key] = _enrich_signal_entry(probs_dict)
            elif len(label_map) == 2:
                # Binary market
                labels = list(label_map.keys())
                p = float(raw[0]) if len(raw) >= 1 else None
                if p is None:
                    print(f"  [V25-SIGNAL] {out_key} → EMPTY raw output, skipped")
                    continue
                signal[out_key] = _enrich_signal_entry({labels[0]: p, labels[1]: 1.0 - p})
            elif len(label_map) == 3:
                # 3-class market
                labels = list(label_map.keys())
                probs_dict = {}
                for i, label in enumerate(labels):
                    if i >= len(raw):
                        print(f"  [V25-SIGNAL] {out_key} → insufficient probabilities in raw output")
                        break
                    probs_dict[label] = float(raw[i])
                else:
                    signal[out_key] = _enrich_signal_entry(probs_dict)

            if out_key in signal:
                print(f"  [V25-SIGNAL] {out_key} → {signal[out_key]['probs']}")

        print(f"  [V25-SIGNAL] Total markets with real predictions: {len(signal)}")
        if not signal:
            raise RuntimeError("V25 model produced ZERO market predictions — cannot continue")

        return signal

    @staticmethod
    def _prob_map(signal: Optional[Dict[str, Any]], market: str, defaults: Dict[str, float]) -> Dict[str, float]:
        """Extract normalised probabilities from signal.

        If the signal contains real model output for this market, use it.
        If the market is missing from the signal, log a warning and return
        the defaults as a LAST RESORT (so the pipeline doesn't crash).
        The defaults are ONLY used for non-core / secondary markets that
        may not have a trained model yet (e.g. CARDS, HCAP, OE).
        """
        market_payload = signal.get(market, {}) if isinstance(signal, dict) else {}
        probs = market_payload.get("probs", {}) if isinstance(market_payload, dict) else {}
        if not isinstance(probs, dict) or not probs:
            print(f"  ⚠️ [PROB_MAP] Market '{market}' NOT found in V25 signal — model output missing")
            return dict(defaults)
        out = {key: float(probs.get(key, value)) for key, value in defaults.items()}
        total = sum(out.values())
        if total <= 0:
            print(f"  ⚠️ [PROB_MAP] Market '{market}' has zero total probability")
            return dict(defaults)
        return {key: value / total for key, value in out.items()}

    @staticmethod
    def _best_prob_pick(prob_map: Dict[str, float]) -> Tuple[str, float]:
        pick = max(prob_map, key=prob_map.get)
        return pick, float(prob_map[pick])

    @staticmethod
    def _poisson_score_top5(home_xg: float, away_xg: float, max_goals: int = 5) -> List[Dict[str, Any]]:
        def poisson_p(lmbda: float, k: int) -> float:
            return math.exp(-lmbda) * (lmbda ** k) / math.factorial(k)

        scores: List[Tuple[str, float]] = []
        for home_goals in range(max_goals + 1):
            for away_goals in range(max_goals + 1):
                prob = poisson_p(home_xg, home_goals) * poisson_p(away_xg, away_goals)
                scores.append((f"{home_goals}-{away_goals}", prob))
        scores.sort(key=lambda item: item[1], reverse=True)
        return [
            {"score": score, "prob": round(prob, 4)}
            for score, prob in scores[:5]
        ]

    def _build_v25_prediction(
        self,
        data: MatchData,
        features: Dict[str, float],
        v25_signal: Dict[str, Any],
    ) -> FullMatchPrediction:
        prediction = FullMatchPrediction(
            match_id=data.match_id,
            home_team=data.home_team_name,
            away_team=data.away_team_name,
        )

        ms_probs = self._prob_map(v25_signal, "MS", {"1": 0.33, "X": 0.34, "2": 0.33})
        ou15_probs = self._prob_map(v25_signal, "OU15", {"Under": 0.5, "Over": 0.5})
        ou25_probs = self._prob_map(v25_signal, "OU25", {"Under": 0.5, "Over": 0.5})
        ou35_probs = self._prob_map(v25_signal, "OU35", {"Under": 0.5, "Over": 0.5})
        btts_probs = self._prob_map(v25_signal, "BTTS", {"No": 0.5, "Yes": 0.5})
        ht_probs = self._prob_map(v25_signal, "HT", {"1": 0.33, "X": 0.34, "2": 0.33})
        ht_ou05_probs = self._prob_map(v25_signal, "HT_OU05", {"Under": 0.5, "Over": 0.5})
        ht_ou15_probs = self._prob_map(v25_signal, "HT_OU15", {"Under": 0.5, "Over": 0.5})
        htft_probs = self._prob_map(
            v25_signal,
            "HTFT",
            {"1/1": 1 / 9, "1/X": 1 / 9, "1/2": 1 / 9, "X/1": 1 / 9, "X/X": 1 / 9, "X/2": 1 / 9, "2/1": 1 / 9, "2/X": 1 / 9, "2/2": 1 / 9},
        )
        oe_probs = self._prob_map(v25_signal, "OE", {"Even": 0.5, "Odd": 0.5})
        cards_probs = self._prob_map(v25_signal, "CARDS", {"Under": 0.5, "Over": 0.5})
        hcap_probs = self._prob_map(v25_signal, "HCAP", {"1": 0.33, "X": 0.34, "2": 0.33})

        prediction.ms_home_prob = ms_probs["1"]
        prediction.ms_draw_prob = ms_probs["X"]
        prediction.ms_away_prob = ms_probs["2"]
        prediction.ms_pick, ms_top = self._best_prob_pick(ms_probs)
        prediction.ms_confidence = ms_top * 100.0

        prediction.dc_1x_prob = prediction.ms_home_prob + prediction.ms_draw_prob
        prediction.dc_x2_prob = prediction.ms_draw_prob + prediction.ms_away_prob
        prediction.dc_12_prob = prediction.ms_home_prob + prediction.ms_away_prob
        dc_probs = {"1X": prediction.dc_1x_prob, "X2": prediction.dc_x2_prob, "12": prediction.dc_12_prob}
        prediction.dc_pick, dc_top = self._best_prob_pick(dc_probs)
        prediction.dc_confidence = dc_top * 100.0

        prediction.over_15_prob = ou15_probs["Over"]
        prediction.under_15_prob = ou15_probs["Under"]
        prediction.ou15_pick = "1.5 Üst" if prediction.over_15_prob >= prediction.under_15_prob else "1.5 Alt"
        prediction.ou15_confidence = max(prediction.over_15_prob, prediction.under_15_prob) * 100.0

        prediction.over_25_prob = ou25_probs["Over"]
        prediction.under_25_prob = ou25_probs["Under"]
        prediction.ou25_pick = "2.5 Üst" if prediction.over_25_prob >= prediction.under_25_prob else "2.5 Alt"
        prediction.ou25_confidence = max(prediction.over_25_prob, prediction.under_25_prob) * 100.0

        prediction.over_35_prob = ou35_probs["Over"]
        prediction.under_35_prob = ou35_probs["Under"]
        prediction.ou35_pick = "3.5 Üst" if prediction.over_35_prob >= prediction.under_35_prob else "3.5 Alt"
        prediction.ou35_confidence = max(prediction.over_35_prob, prediction.under_35_prob) * 100.0

        prediction.btts_yes_prob = btts_probs["Yes"]
        prediction.btts_no_prob = btts_probs["No"]
        prediction.btts_pick = "KG Var" if prediction.btts_yes_prob >= prediction.btts_no_prob else "KG Yok"
        prediction.btts_confidence = max(prediction.btts_yes_prob, prediction.btts_no_prob) * 100.0

        prediction.ht_home_prob = ht_probs["1"]
        prediction.ht_draw_prob = ht_probs["X"]
        prediction.ht_away_prob = ht_probs["2"]
        prediction.ht_pick, ht_top = self._best_prob_pick(ht_probs)
        prediction.ht_confidence = ht_top * 100.0

        prediction.ht_over_05_prob = ht_ou05_probs["Over"]
        prediction.ht_under_05_prob = ht_ou05_probs["Under"]
        prediction.ht_ou_pick = "İY 0.5 Üst" if prediction.ht_over_05_prob >= prediction.ht_under_05_prob else "İY 0.5 Alt"

        prediction.ht_over_15_prob = ht_ou15_probs["Over"]
        prediction.ht_under_15_prob = ht_ou15_probs["Under"]
        prediction.ht_ou15_pick = "İY 1.5 Üst" if prediction.ht_over_15_prob >= prediction.ht_under_15_prob else "İY 1.5 Alt"

        prediction.ht_ft_probs = htft_probs

        prediction.odd_prob = oe_probs["Odd"]
        prediction.even_prob = oe_probs["Even"]
        prediction.odd_even_pick = "Tek" if prediction.odd_prob >= prediction.even_prob else "Çift"

        prediction.cards_over_prob = cards_probs["Over"]
        prediction.cards_under_prob = cards_probs["Under"]
        prediction.card_pick = "4.5 Üst" if prediction.cards_over_prob >= prediction.cards_under_prob else "4.5 Alt"
        prediction.cards_confidence = max(prediction.cards_over_prob, prediction.cards_under_prob) * 100.0

        prediction.handicap_home_prob = hcap_probs["1"]
        prediction.handicap_draw_prob = hcap_probs["X"]
        prediction.handicap_away_prob = hcap_probs["2"]
        prediction.handicap_pick, hcap_top = self._best_prob_pick(hcap_probs)
        prediction.handicap_confidence = hcap_top * 100.0

        base_home_xg = max(0.25, (float(data.home_goals_avg) + float(features.get("away_xga", data.away_conceded_avg))) / 2.0)
        base_away_xg = max(0.25, (float(data.away_goals_avg) + float(features.get("home_xga", data.home_conceded_avg))) / 2.0)
        ms_edge = prediction.ms_home_prob - prediction.ms_away_prob
        total_target = max(
            1.4,
            min(
                4.8,
                (float(features.get("league_avg_goals", 2.7)) * 0.55)
                + ((float(data.home_goals_avg) + float(data.away_goals_avg)) * 0.45)
                + ((prediction.over_25_prob - prediction.under_25_prob) * 1.15),
            ),
        )
        home_xg = max(0.2, base_home_xg + (ms_edge * 0.55) + ((prediction.btts_yes_prob - 0.5) * 0.18))
        away_xg = max(0.2, base_away_xg - (ms_edge * 0.55) + ((prediction.btts_yes_prob - 0.5) * 0.18))
        scale = total_target / max(home_xg + away_xg, 0.1)
        prediction.home_xg = round(home_xg * scale, 2)
        prediction.away_xg = round(away_xg * scale, 2)
        prediction.total_xg = round(prediction.home_xg + prediction.away_xg, 2)

        prediction.predicted_ft_score = f"{int(round(prediction.home_xg))}-{int(round(prediction.away_xg))}"
        prediction.predicted_ht_score = f"{int(round(prediction.home_xg * 0.45))}-{int(round(prediction.away_xg * 0.45))}"
        prediction.ft_scores_top5 = self._poisson_score_top5(prediction.home_xg, prediction.away_xg)

        max_market_conf = max(
            prediction.ms_confidence,
            prediction.ou15_confidence,
            prediction.ou25_confidence,
            prediction.ou35_confidence,
            prediction.btts_confidence,
            prediction.ht_confidence,
            prediction.cards_confidence,
            prediction.handicap_confidence,
        )
        lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
        lineup_penalty = 12.0 if data.lineup_source == "none" else max(1.5, (1.0 - lineup_conf) * 8.0) if data.lineup_source == "probable_xi" else 0.0
        referee_penalty = 6.0 if not data.referee_name else 0.0
        parity_penalty = 8.0 if abs(ms_edge) < 0.08 else 0.0
        prediction.risk_score = round(min(100.0, max(10.0, 100.0 - max_market_conf + lineup_penalty + referee_penalty + parity_penalty)), 1)
        if prediction.risk_score >= 78:
            prediction.risk_level = "EXTREME"
        elif prediction.risk_score >= 62:
            prediction.risk_level = "HIGH"
        elif prediction.risk_score >= 40:
            prediction.risk_level = "MEDIUM"
        else:
            prediction.risk_level = "LOW"
        prediction.is_surprise_risk = prediction.risk_level in {"HIGH", "EXTREME"} or prediction.ms_draw_prob >= 0.30
        prediction.surprise_type = "balanced_match_risk" if abs(ms_edge) < 0.08 else "draw_pressure" if prediction.ms_draw_prob >= 0.30 else ""
        prediction.risk_warnings = []
        if data.lineup_source == "probable_xi":
            prediction.risk_warnings.append("lineup_probable_not_confirmed")
            if lineup_conf < 0.65:
                prediction.risk_warnings.append("lineup_projection_low_confidence")
        if data.lineup_source == "none":
            prediction.risk_warnings.append("lineup_unavailable")
        if not data.referee_name:
            prediction.risk_warnings.append("missing_referee")
        if prediction.ms_draw_prob >= 0.30:
            prediction.risk_warnings.append("draw_probability_elevated")

        prediction.upset_score = int(round(max(0.0, min(100.0, (prediction.ms_draw_prob + min(prediction.ms_home_prob, prediction.ms_away_prob)) * 100.0))))
        prediction.upset_level = "HIGH" if prediction.upset_score >= 65 else "MEDIUM" if prediction.upset_score >= 45 else "LOW"
        prediction.upset_reasons = [prediction.surprise_type] if prediction.surprise_type else []
        surprise = self._build_surprise_profile(data, prediction)
        prediction.surprise_score = surprise["score"]
        prediction.surprise_comment = surprise["comment"]
        prediction.surprise_reasons = surprise["reasons"]

        prediction.team_confidence = round(max(35.0, min(95.0, 45.0 + (abs(ms_edge) * 85.0) + (abs(float(features.get("form_elo_diff", 0.0))) / 40.0))), 1)
        prediction.player_confidence = round(max(20.0, min(95.0, 38.0 + (float(features.get("home_key_players", 0.0)) + float(features.get("away_key_players", 0.0))) * 2.0 - (float(features.get("home_missing_impact", 0.0)) + float(features.get("away_missing_impact", 0.0))) * 22.0)), 1)
        prediction.odds_confidence = round(max(30.0, min(95.0, np.mean([prediction.ms_confidence, prediction.ou25_confidence, prediction.btts_confidence]))), 1)
        prediction.referee_confidence = 62.0 if data.referee_name else 35.0

        prediction.total_cards_pred = 4.8 if prediction.cards_over_prob >= prediction.cards_under_prob else 4.1
        prediction.total_corners_pred = round(8.8 + (prediction.over_25_prob - 0.5) * 2.5, 1)
        prediction.corner_pick = "9.5 Üst" if prediction.total_corners_pred >= 9.5 else "9.5 Alt"
        prediction.analysis_details = {
            "primary_model": "v25",
            "features_source": "v25.pre_match",
            "market_count": len([key for key in v25_signal.keys() if key != "value_bets"]),
            "lineup_source": data.lineup_source,
        }
        return prediction

    def _get_basketball_predictor(self) -> Any:
        if self.basketball_predictor is None:
            self.basketball_predictor = get_basketball_v25_predictor()
        return self.basketball_predictor

    def analyze_match(self, match_id: str) -> Optional[Dict[str, Any]]:
        data = self._load_match_data(match_id)
        if data is None:
            return None

        # ── Pre-Match Simulation Mode ────────────────────────────
        # For finished (FT/postGame) matches, strip live scores so the
        # entire pipeline treats them as if they haven't kicked off yet.
        # _is_live_match already returns False for FT, but this adds
        # defense-in-depth against any code path that reads scores directly.
        _status_upper = str(data.status or "").upper()
        _state_upper  = str(data.state  or "").upper()
        if _status_upper in {"FT", "FINISHED"} or _state_upper in {"POSTGAME", "POST_GAME"}:
            data.current_score_home = None
            data.current_score_away = None

        sport_key = str(data.sport or "football").lower()
        if sport_key == "basketball":
            prediction = self._get_basketball_predictor().predict(
                match_id=data.match_id,
                home_team_id=data.home_team_id,
                away_team_id=data.away_team_id,
                home_team_name=data.home_team_name,
                away_team_name=data.away_team_name,
                match_date_ms=data.match_date_ms,
                league_id=data.league_id,
                league_name=data.league_name,
                odds_data=data.odds_data,
                sidelined_data=data.sidelined_data,
            )
            return self._build_basketball_prediction_package(data, prediction)

        features = self._build_v25_features(data)
        v25_signal = self._get_v25_signal(data, features)
        prediction = self._build_v25_prediction(data, features, v25_signal)
        base_package = self._build_prediction_package(data, prediction, v25_signal)

        # ── V27 Dual-Engine Divergence ──────────────────────────────
        v27_predictor = self._get_v27_predictor()
        if v27_predictor is not None:
            try:
                v27_preds = v27_predictor.predict_all(features)

                # MS divergence
                v27_ms = v27_preds.get("ms")
                if v27_ms:
                    v25_ms_probs = {
                        "home": prediction.ms_home_prob,
                        "draw": prediction.ms_draw_prob,
                        "away": prediction.ms_away_prob,
                    }
                    ms_divergence = compute_divergence(v25_ms_probs, v27_ms)
                    ms_odds = {
                        "home": float((data.odds_data or {}).get("ms_h", 0)),
                        "draw": float((data.odds_data or {}).get("ms_d", 0)),
                        "away": float((data.odds_data or {}).get("ms_a", 0)),
                    }
                    ms_value = compute_value_edge(v25_ms_probs, v27_ms, ms_odds)
                else:
                    ms_divergence = {}
                    ms_value = {}

                # OU25 divergence
                v27_ou25 = v27_preds.get("ou25")
                if v27_ou25:
                    v25_ou25_probs = {
                        "under": prediction.under_25_prob,
                        "over": prediction.over_25_prob,
                    }
                    ou25_divergence = compute_divergence(v25_ou25_probs, v27_ou25)
                    ou25_odds = {
                        "under": float((data.odds_data or {}).get("ou25_u", 0)),
                        "over": float((data.odds_data or {}).get("ou25_o", 0)),
                    }
                    ou25_value = compute_value_edge(v25_ou25_probs, v27_ou25, ou25_odds)
                else:
                    ou25_divergence = {}
                    ou25_value = {}

                # ── V28 Odds-Band Historical Performance ─────────────
                odds_band_ms_home = {
                    "win_rate": features.get("home_band_ms_win_rate", 0.33),
                    "draw_rate": features.get("home_band_ms_draw_rate", 0.33),
                    "loss_rate": features.get("home_band_ms_loss_rate", 0.34),
                    "sample": features.get("home_band_ms_sample", 0),
                    "avg_goals_scored": features.get("home_band_ms_avg_goals_scored", 1.3),
                    "avg_goals_conceded": features.get("home_band_ms_avg_goals_conceded", 1.1),
                }
                odds_band_ms_away = {
                    "win_rate": features.get("away_band_ms_win_rate", 0.33),
                    "draw_rate": features.get("away_band_ms_draw_rate", 0.33),
                    "loss_rate": features.get("away_band_ms_loss_rate", 0.34),
                    "sample": features.get("away_band_ms_sample", 0),
                    "avg_goals_scored": features.get("away_band_ms_avg_goals_scored", 1.3),
                    "avg_goals_conceded": features.get("away_band_ms_avg_goals_conceded", 1.1),
                }
                odds_band_ou25 = {
                    "over_rate": features.get("band_ou25_over_rate", 0.50),
                    "under_rate": features.get("band_ou25_under_rate", 0.50),
                    "avg_total_goals": features.get("band_ou25_avg_total_goals", 2.5),
                    "sample": features.get("band_ou25_sample", 0),
                }
                odds_band_ou15 = {
                    "over_rate": features.get("band_ou15_over_rate", 0.65),
                    "under_rate": features.get("band_ou15_under_rate", 0.35),
                    "avg_total_goals": features.get("band_ou15_avg_total_goals", 2.5),
                    "sample": features.get("band_ou15_sample", 0),
                }
                odds_band_ou35 = {
                    "over_rate": features.get("band_ou35_over_rate", 0.35),
                    "under_rate": features.get("band_ou35_under_rate", 0.65),
                    "avg_total_goals": features.get("band_ou35_avg_total_goals", 2.5),
                    "sample": features.get("band_ou35_sample", 0),
                }
                odds_band_btts = {
                    "yes_rate": features.get("band_btts_yes_rate", 0.50),
                    "no_rate": features.get("band_btts_no_rate", 0.50),
                    "sample": features.get("band_btts_sample", 0),
                }
                odds_band_dc = {
                    "1x_rate": features.get("band_dc_1x_rate", 0.60),
                    "x2_rate": features.get("band_dc_x2_rate", 0.60),
                    "12_rate": features.get("band_dc_12_rate", 0.67),
                    "1x_sample": features.get("band_dc_1x_sample", 0),
                    "x2_sample": features.get("band_dc_x2_sample", 0),
                    "12_sample": features.get("band_dc_12_sample", 0),
                }
                odds_band_ht_home = {
                    "win_rate": features.get("home_band_ht_win_rate", 0.33),
                    "draw_rate": features.get("home_band_ht_draw_rate", 0.40),
                    "loss_rate": features.get("home_band_ht_loss_rate", 0.27),
                    "sample": features.get("home_band_ht_sample", 0),
                }
                odds_band_ht_away = {
                    "win_rate": features.get("away_band_ht_win_rate", 0.33),
                    "draw_rate": features.get("away_band_ht_draw_rate", 0.40),
                    "loss_rate": features.get("away_band_ht_loss_rate", 0.27),
                    "sample": features.get("away_band_ht_sample", 0),
                }
                odds_band_ht_ou05 = {
                    "over_rate": features.get("band_ht_ou05_over_rate", 0.50),
                    "under_rate": features.get("band_ht_ou05_under_rate", 0.50),
                    "sample": features.get("band_ht_ou05_sample", 0),
                }
                odds_band_ht_ou15 = {
                    "over_rate": features.get("band_ht_ou15_over_rate", 0.35),
                    "under_rate": features.get("band_ht_ou15_under_rate", 0.65),
                    "sample": features.get("band_ht_ou15_sample", 0),
                }
                odds_band_oe = {
                    "odd_rate": features.get("band_oe_odd_rate", 0.50),
                    "even_rate": features.get("band_oe_even_rate", 0.50),
                    "sample": features.get("band_oe_sample", 0),
                }

                # Cards (Kart) band — hakem + takım profili
                odds_band_cards = {
                    "referee_avg": features.get("band_cards_referee_avg", 0.0),
                    "referee_over_rate": features.get("band_cards_referee_over_rate", 0.50),
                    "referee_sample": features.get("band_cards_referee_sample", 0),
                    "team_avg": features.get("band_cards_team_avg", 0.0),
                    "team_over_rate": features.get("band_cards_team_over_rate", 0.50),
                    "team_sample": features.get("band_cards_team_sample", 0),
                    "combined_over_rate": features.get("band_cards_combined_over_rate", 0.50),
                    "sample": features.get("band_cards_sample", 0),
                }

                # HTFT (İY/MS) 9 combination rates
                odds_band_htft = {}
                for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"):
                    odds_band_htft[combo] = {
                        "rate": features.get(f"band_htft_{combo}_rate", 0.11),
                        "sample": features.get(f"band_htft_{combo}_sample", 0),
                    }

                # ── Triple Value Detection ────────────────────────────
                ms_odds = {
                    "home": float((data.odds_data or {}).get("ms_h", 0)),
                    "draw": float((data.odds_data or {}).get("ms_d", 0)),
                    "away": float((data.odds_data or {}).get("ms_a", 0)),
                }
                triple_value = {}
                for outcome_key, band_key, odds_key in [
                    ("home", "home", "home"),
                    ("away", "away", "away"),
                ]:
                    v27_prob = (v27_ms or {}).get(outcome_key, 0)
                    band_rate = (odds_band_ms_home if band_key == "home"
                                 else odds_band_ms_away)["win_rate"]
                    mkt_odds = ms_odds.get(odds_key, 0)
                    implied_prob = (1.0 / mkt_odds) if mkt_odds > 1.0 else 0.33

                    combined_prob = (v27_prob + band_rate) / 2.0 if v27_prob > 0 else band_rate
                    edge = combined_prob - implied_prob
                    band_sample = (odds_band_ms_home if band_key == "home"
                                   else odds_band_ms_away)["sample"]

                    v27_confirms = v27_prob > implied_prob
                    band_confirms = band_rate > implied_prob
                    confirmation_count = sum([v27_confirms, band_confirms])

                    triple_value[outcome_key] = {
                        "v27_prob": round(v27_prob, 4),
                        "band_rate": round(band_rate, 4),
                        "implied_prob": round(implied_prob, 4),
                        "combined_prob": round(combined_prob, 4),
                        "edge": round(edge, 4),
                        "band_sample": band_sample,
                        "confirmations": confirmation_count,
                        "is_value": (
                            confirmation_count >= 2
                            and edge > 0.05
                            and band_sample >= 8
                        ),
                    }

                # OU25 triple value
                ou25_over_odds = float((data.odds_data or {}).get("ou25_o", 0))
                v27_ou25_over = (v27_ou25 or {}).get("over", 0) if v27_ou25 else 0
                ou25_band_rate = odds_band_ou25["over_rate"]
                ou25_implied = (1.0 / ou25_over_odds) if ou25_over_odds > 1.0 else 0.50
                ou25_combined = (v27_ou25_over + ou25_band_rate) / 2.0 if v27_ou25_over > 0 else ou25_band_rate
                ou25_edge = ou25_combined - ou25_implied
                ou25_v27_confirms = v27_ou25_over > ou25_implied
                ou25_band_confirms = ou25_band_rate > ou25_implied
                ou25_conf_count = sum([ou25_v27_confirms, ou25_band_confirms])

                triple_value["ou25_over"] = {
                    "v27_prob": round(v27_ou25_over, 4),
                    "band_rate": round(ou25_band_rate, 4),
                    "implied_prob": round(ou25_implied, 4),
                    "combined_prob": round(ou25_combined, 4),
                    "edge": round(ou25_edge, 4),
                    "band_sample": odds_band_ou25["sample"],
                    "confirmations": ou25_conf_count,
                    "is_value": (
                        ou25_conf_count >= 2
                        and ou25_edge > 0.05
                        and odds_band_ou25["sample"] >= 8
                    ),
                }

                # BTTS triple value
                btts_yes_odds = float((data.odds_data or {}).get("btts_y", 0))
                btts_implied = (1.0 / btts_yes_odds) if btts_yes_odds > 1.0 else 0.50
                btts_band_rate = odds_band_btts["yes_rate"]
                btts_combined = btts_band_rate
                btts_edge = btts_combined - btts_implied
                btts_band_confirms = btts_band_rate > btts_implied

                triple_value["btts_yes"] = {
                    "band_rate": round(btts_band_rate, 4),
                    "implied_prob": round(btts_implied, 4),
                    "combined_prob": round(btts_combined, 4),
                    "edge": round(btts_edge, 4),
                    "band_sample": odds_band_btts["sample"],
                    "confirmations": 1 if btts_band_confirms else 0,
                    "is_value": (
                        btts_band_confirms
                        and btts_edge > 0.05
                        and odds_band_btts["sample"] >= 8
                    ),
                }

                # ── Band-only value for new markets ───────────────────
                def _band_value(label, band_rate, odds_key, sample):
                    o = float((data.odds_data or {}).get(odds_key, 0))
                    imp = (1.0 / o) if o > 1.0 else 0.50
                    e = band_rate - imp
                    conf = band_rate > imp
                    return {
                        "band_rate": round(band_rate, 4),
                        "implied_prob": round(imp, 4),
                        "edge": round(e, 4),
                        "band_sample": sample,
                        "is_value": conf and e > 0.05 and sample >= 8,
                    }

                triple_value["ou15_over"] = _band_value(
                    "ou15", odds_band_ou15["over_rate"], "ou15_o", odds_band_ou15["sample"])
                triple_value["ou35_over"] = _band_value(
                    "ou35", odds_band_ou35["over_rate"], "ou35_o", odds_band_ou35["sample"])
                triple_value["dc_1x"] = _band_value(
                    "dc1x", odds_band_dc["1x_rate"], "dc_1x", odds_band_dc["1x_sample"])
                triple_value["dc_x2"] = _band_value(
                    "dcx2", odds_band_dc["x2_rate"], "dc_x2", odds_band_dc["x2_sample"])
                triple_value["dc_12"] = _band_value(
                    "dc12", odds_band_dc["12_rate"], "dc_12", odds_band_dc["12_sample"])
                triple_value["ht_home"] = _band_value(
                    "ht_h", odds_band_ht_home["win_rate"], "ht_h", odds_band_ht_home["sample"])
                triple_value["ht_away"] = _band_value(
                    "ht_a", odds_band_ht_away["win_rate"], "ht_a", odds_band_ht_away["sample"])
                triple_value["ht_ou05_over"] = _band_value(
                    "htou05", odds_band_ht_ou05["over_rate"], "ht_ou05_o", odds_band_ht_ou05["sample"])
                triple_value["ht_ou15_over"] = _band_value(
                    "htou15", odds_band_ht_ou15["over_rate"], "ht_ou15_o", odds_band_ht_ou15["sample"])
                triple_value["oe_odd"] = _band_value(
                    "oe", odds_band_oe["odd_rate"], "oe_odd", odds_band_oe["sample"])

                # Cards triple value — composite (hakem + takım)
                triple_value["cards_over"] = _band_value(
                    "cards", odds_band_cards["combined_over_rate"], "cards_o",
                    odds_band_cards["sample"])

                # HTFT triple value — 9 combinations
                for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"):
                    htft_combo_data = odds_band_htft.get(combo, {})
                    triple_value[f"htft_{combo}"] = _band_value(
                        f"htft_{combo}", htft_combo_data.get("rate", 0.11),
                        f"htft_{combo}", htft_combo_data.get("sample", 0))

                # Attach to package
                base_package["v27_engine"] = {
                    "version": "v28-pro-max",
                    "approach": "odds-free fundamentals + full odds-band analytics + cards + htft",
                    "predictions": {
                        "ms": v27_ms or {},
                        "ou25": v27_ou25 or {},
                    },
                    "divergence": {
                        "ms": ms_divergence,
                        "ou25": ou25_divergence,
                    },
                    "value_edge": {
                        "ms": ms_value,
                        "ou25": ou25_value,
                    },
                    "odds_band": {
                        "ms_home": odds_band_ms_home,
                        "ms_away": odds_band_ms_away,
                        "ou25": odds_band_ou25,
                        "ou15": odds_band_ou15,
                        "ou35": odds_band_ou35,
                        "btts": odds_band_btts,
                        "dc": odds_band_dc,
                        "ht_home": odds_band_ht_home,
                        "ht_away": odds_band_ht_away,
                        "ht_ou05": odds_band_ht_ou05,
                        "ht_ou15": odds_band_ht_ou15,
                        "oe": odds_band_oe,
                        "cards": odds_band_cards,
                        "htft": odds_band_htft,
                    },
                    "triple_value": triple_value,
                }

                # Boost confidence when V27 agrees with V25
                if v27_ms:
                    v27_best = max(v27_ms, key=v27_ms.get)
                    v25_best_map = {"1": "home", "X": "draw", "2": "away"}
                    v25_best_mapped = v25_best_map.get(prediction.ms_pick, "")
                    if v27_best == v25_best_mapped:
                        # Engines agree → boost confidence by up to 5%
                        boost = min(5.0, abs(ms_divergence.get(v27_best, 0)) * 50)
                        # Additional boost if odds-band also confirms
                        band_val = triple_value.get(v25_best_mapped, {})
                        if band_val.get("is_value"):
                            boost = min(8.0, boost + 3.0)  # Triple confirmation extra boost
                        prediction.ms_confidence = min(95.0, prediction.ms_confidence + boost)
                        market_board = base_package.get("market_board")
                        if isinstance(market_board, dict) and isinstance(market_board.get("MS"), dict):
                            market_board["MS"]["confidence"] = round(float(prediction.ms_confidence), 1)
                        base_package["v27_engine"]["consensus"] = "AGREE"
                    else:
                        base_package["v27_engine"]["consensus"] = "DISAGREE"

                # Update analysis details
                base_package.setdefault("analysis_details", {})
                base_package["analysis_details"]["dual_engine"] = True
                base_package["analysis_details"]["v27_loaded"] = True
                base_package["analysis_details"]["odds_band_loaded"] = True
            except Exception as e:
                print(f"[V27] ⚠ Prediction failed (non-fatal): {e}")
                base_package.setdefault("analysis_details", {})
                base_package["analysis_details"]["v27_loaded"] = False

        base_package = self._apply_upper_brain_guards(base_package)

        mode = str(getattr(self, "engine_mode", "v28-pro-max") or "v28-pro-max").lower()
        if mode not in {"v25", "v26", "dual", "v28", "v28-pro-max"}:
            mode = "v25"

        quality = base_package.get("data_quality", self._compute_data_quality(data))
        shadow_package = self._get_v26_shadow_engine().build_package(
            data=data,
            prediction=prediction,
            v25_signal=v25_signal,
            quality=quality,
        )

        if mode == "v26":
            return shadow_package
        if mode == "dual":
            merged = dict(base_package)
            merged.update(
                {
                    "shadow_engine": shadow_package,
                    "shadow_engine_version": shadow_package.get("model_version"),
                    "calibration_version": shadow_package.get("calibration_version"),
                    "decision_trace_id": shadow_package.get("decision_trace_id"),
                    "market_reliability": shadow_package.get("market_reliability", {}),
                }
            )
            return merged
        return base_package

    def _apply_upper_brain_guards(self, package: Dict[str, Any]) -> Dict[str, Any]:
        return BettingBrain().judge(package)

        v27_engine = package.get("v27_engine")
        if not isinstance(v27_engine, dict) or not v27_engine.get("triple_value"):
            return package

        guarded = dict(package)
        vetoed_keys = set()
        guarded_keys = set()

        def mark_guard(item: Dict[str, Any]) -> Dict[str, Any]:
            if not isinstance(item, dict):
                return item

            out = dict(item)
            assessment = self._upper_brain_assessment(out, guarded)
            if not assessment.get("applies"):
                return out

            key = f"{out.get('market')}:{out.get('pick')}"
            guarded_keys.add(key)
            out["upper_brain"] = assessment

            reason_key = "decision_reasons" if "decision_reasons" in out else "reasons"
            reasons = list(out.get(reason_key) or [])
            for reason in assessment.get("reason_codes", []):
                if reason not in reasons:
                    reasons.append(reason)
            out[reason_key] = reasons[:6]

            if assessment.get("veto"):
                vetoed_keys.add(key)
                out["playable"] = False
                out["stake_units"] = 0.0
                out["bet_grade"] = "PASS"
                out["is_guaranteed"] = False
                out["pick_reason"] = "upper_brain_veto"
                if "signal_tier" in out:
                    out["signal_tier"] = "PASS"
            elif assessment.get("downgrade"):
                out["is_guaranteed"] = False
                if out.get("signal_tier") == "CORE":
                    out["signal_tier"] = "LEAN"
                if out.get("pick_reason") == "high_accuracy_market":
                    out["pick_reason"] = "upper_brain_downgraded"

            return out

        main_pick = mark_guard(guarded.get("main_pick") or {})
        value_pick = mark_guard(guarded.get("value_pick") or {}) if guarded.get("value_pick") else None
        supporting = [
            mark_guard(row)
            for row in list(guarded.get("supporting_picks") or [])
            if isinstance(row, dict)
        ]
        bet_summary = [
            mark_guard(row)
            for row in list(guarded.get("bet_summary") or [])
            if isinstance(row, dict)
        ]

        main_safe = bool(main_pick and main_pick.get("playable") and not main_pick.get("upper_brain", {}).get("veto"))
        if not main_safe:
            candidates = [
                row for row in supporting
                if row.get("playable")
                and not row.get("upper_brain", {}).get("veto")
                and float(row.get("odds", 0.0) or 0.0) >= 1.30
            ]
            candidates.sort(key=lambda row: float(row.get("play_score", 0.0) or 0.0), reverse=True)
            if candidates:
                main_pick = dict(candidates[0])
                main_pick["is_guaranteed"] = False
                main_pick["pick_reason"] = "upper_brain_reselected"
                reasons = list(main_pick.get("decision_reasons") or [])
                if "upper_brain_reselected_after_veto" not in reasons:
                    reasons.append("upper_brain_reselected_after_veto")
                main_pick["decision_reasons"] = reasons[:6]
            elif main_pick:
                main_pick["is_guaranteed"] = False
                main_pick["pick_reason"] = "upper_brain_no_safe_pick"

        if main_pick:
            supporting = [
                row for row in supporting
                if not (
                    row.get("market") == main_pick.get("market")
                    and row.get("pick") == main_pick.get("pick")
                )
            ][:6]

        guarded["main_pick"] = main_pick if main_pick else None
        guarded["value_pick"] = value_pick
        guarded["supporting_picks"] = supporting
        guarded["bet_summary"] = bet_summary

        playable = bool(main_pick and main_pick.get("playable") and not main_pick.get("upper_brain", {}).get("veto"))
        advice = dict(guarded.get("bet_advice") or {})
        advice["playable"] = playable
        advice["suggested_stake_units"] = float(main_pick.get("stake_units", 0.0)) if playable else 0.0
        if playable:
            advice["reason"] = "playable_pick_found"
        elif vetoed_keys:
            advice["reason"] = "upper_brain_no_safe_pick"
        else:
            advice["reason"] = "no_bet_conditions_met"
        guarded["bet_advice"] = advice

        guarded["upper_brain"] = {
            "applied": True,
            "guarded_count": len(guarded_keys),
            "vetoed_count": len(vetoed_keys),
            "vetoed": sorted(vetoed_keys)[:8],
            "rules": {
                "min_band_sample": 8,
                "max_v25_v27_divergence": 0.18,
                "dc_requires_triple_value": True,
            },
        }
        guarded.setdefault("analysis_details", {})
        guarded["analysis_details"]["upper_brain_guards_applied"] = True
        guarded["analysis_details"]["upper_brain_vetoed_count"] = len(vetoed_keys)
        return guarded

    def _upper_brain_assessment(
        self,
        item: Dict[str, Any],
        package: Dict[str, Any],
    ) -> Dict[str, Any]:
        market = str(item.get("market") or "")
        pick = str(item.get("pick") or "")
        if not market or not pick:
            return {"applies": False}

        v27_engine = package.get("v27_engine") or {}
        triple_value = v27_engine.get("triple_value") or {}
        model_prob = self._upper_brain_market_probability(item, package)
        v27_prob = self._upper_brain_v27_probability(market, pick, v27_engine)
        triple_key = self._upper_brain_triple_key(market, pick)
        triple = triple_value.get(triple_key) if triple_key else None

        veto = False
        downgrade = False
        reasons: List[str] = []
        divergence = None

        if model_prob is not None and v27_prob is not None:
            divergence = abs(float(model_prob) - float(v27_prob))
            if divergence >= 0.18:
                veto = True
                reasons.append("upper_brain_v25_v27_divergence")
            elif divergence >= 0.12:
                downgrade = True
                reasons.append("upper_brain_v25_v27_warning")

        if isinstance(triple, dict):
            band_sample = int(float(triple.get("band_sample", 0) or 0))
            is_value = bool(triple.get("is_value"))
            if market == "DC":
                if band_sample < 8:
                    veto = True
                    reasons.append("upper_brain_band_sample_too_low")
                elif not is_value:
                    veto = True
                    reasons.append("upper_brain_triple_value_rejected")
            elif market in {"MS", "OU25"} and band_sample > 0 and band_sample < 8:
                downgrade = True
                reasons.append("upper_brain_band_sample_thin")
            elif market in {"OU15", "HT_OU05"} and band_sample < 8:
                downgrade = True
                reasons.append("upper_brain_band_sample_thin")

        consensus = str(v27_engine.get("consensus") or "").upper()
        if consensus == "DISAGREE" and market in {"MS", "DC"} and not veto:
            downgrade = True
            reasons.append("upper_brain_consensus_disagree")

        applies = bool(reasons or triple is not None or v27_prob is not None)
        return {
            "applies": applies,
            "veto": veto,
            "downgrade": downgrade,
            "reason_codes": reasons,
            "model_prob": round(float(model_prob), 4) if model_prob is not None else None,
            "v27_prob": round(float(v27_prob), 4) if v27_prob is not None else None,
            "divergence": round(float(divergence), 4) if divergence is not None else None,
            "triple_key": triple_key,
            "triple_value": triple,
        }

    def _upper_brain_market_probability(
        self,
        item: Dict[str, Any],
        package: Dict[str, Any],
    ) -> Optional[float]:
        raw_prob = item.get("probability")
        if raw_prob is not None:
            try:
                return float(raw_prob)
            except (TypeError, ValueError):
                pass

        market = str(item.get("market") or "")
        pick = str(item.get("pick") or "")
        board = package.get("market_board") or {}
        payload = board.get(market) if isinstance(board, dict) else None
        probs = payload.get("probs") if isinstance(payload, dict) else None
        if not isinstance(probs, dict):
            return None

        prob_key = self._upper_brain_prob_key(market, pick)
        if prob_key is None:
            return None
        try:
            return float(probs.get(prob_key))
        except (TypeError, ValueError):
            return None

    def _upper_brain_v27_probability(
        self,
        market: str,
        pick: str,
        v27_engine: Dict[str, Any],
    ) -> Optional[float]:
        predictions = v27_engine.get("predictions") or {}
        ms = predictions.get("ms") or {}
        ou25 = predictions.get("ou25") or {}

        if market == "MS":
            return self._safe_float(ms.get({"1": "home", "X": "draw", "2": "away"}.get(pick, "")))
        if market == "DC":
            if pick == "1X":
                return self._safe_float(ms.get("home"), 0.0) + self._safe_float(ms.get("draw"), 0.0)
            if pick == "X2":
                return self._safe_float(ms.get("draw"), 0.0) + self._safe_float(ms.get("away"), 0.0)
            if pick == "12":
                return self._safe_float(ms.get("home"), 0.0) + self._safe_float(ms.get("away"), 0.0)
        if market == "OU25":
            prob_key = self._upper_brain_prob_key(market, pick)
            return self._safe_float(ou25.get(prob_key)) if prob_key else None
        return None

    @staticmethod
    def _upper_brain_prob_key(market: str, pick: str) -> Optional[str]:
        pick_norm = str(pick or "").strip().casefold()
        if market in {"MS", "HT", "HCAP"}:
            return pick if pick in {"1", "X", "2"} else None
        if market == "DC":
            return pick.upper() if pick.upper() in {"1X", "X2", "12"} else None
        if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
            if "over" in pick_norm or "st" in pick_norm:
                return "over"
            if "under" in pick_norm or "alt" in pick_norm:
                return "under"
        if market == "BTTS":
            if "yes" in pick_norm or "var" in pick_norm:
                return "yes"
            if "no" in pick_norm or "yok" in pick_norm:
                return "no"
        if market == "OE":
            if "odd" in pick_norm or "tek" in pick_norm:
                return "odd"
            if "even" in pick_norm or "ift" in pick_norm:
                return "even"
        if market == "HTFT" and "/" in pick:
            return pick
        return None

    def _upper_brain_triple_key(self, market: str, pick: str) -> Optional[str]:
        prob_key = self._upper_brain_prob_key(market, pick)
        if market == "MS":
            return {"1": "home", "2": "away"}.get(pick)
        if market == "DC":
            return f"dc_{pick.lower()}" if pick.upper() in {"1X", "X2", "12"} else None
        if market in {"OU15", "OU25", "OU35"} and prob_key == "over":
            return f"{market.lower()}_over"
        if market == "BTTS" and prob_key == "yes":
            return "btts_yes"
        if market == "HT":
            return {"1": "ht_home", "2": "ht_away"}.get(pick)
        if market in {"HT_OU05", "HT_OU15"} and prob_key == "over":
            return f"{market.lower()}_over"
        if market == "OE" and prob_key == "odd":
            return "oe_odd"
        if market == "CARDS" and prob_key == "over":
            return "cards_over"
        if market == "HTFT" and "/" in pick:
            return f"htft_{pick.replace('/', '').lower()}"
        return None

    @staticmethod
    def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:
        try:
            return float(value)
        except (TypeError, ValueError):
            return default

    def analyze_match_htms(self, match_id: str) -> Optional[Dict[str, Any]]:
        """
        HT/MS focused response for upset-hunting workflows.

        This endpoint is intentionally additive and does not mutate the
        standard /v20plus/analyze package contract.
        """
        data = self._load_match_data(match_id)
        if data is None:
            return None

        if str(data.sport or "").lower() != "football":
            return {
                "status": "skip",
                "match_id": match_id,
                "reason": "unsupported_sport",
                "engine_used": "htms_router",
            }

        is_top_league = self._is_top_league(data.league_id)
        engine_used = "v20plus_top_htms"

        # Hard gate: HT/MS upset model is trained on top leagues only.
        if not is_top_league:
            return {
                "status": "skip",
                "match_id": match_id,
                "reason": "out_of_training_scope",
                "engine_used": engine_used,
                "data_quality": {
                    "label": "LOW",
                    "flags": ["league_out_of_scope"],
                },
            }

        missing_requirements = self._missing_htms_requirements(data)
        if missing_requirements:
            return {
                "status": "skip",
                "match_id": match_id,
                "reason": "missing_critical_data",
                "missing": missing_requirements,
                "engine_used": engine_used,
                "data_quality": {
                    "label": "LOW",
                    "flags": [f"missing_{item}" for item in missing_requirements],
                },
            }

        base_package = self.analyze_match(match_id)
        if not base_package:
            return None
        data_quality = base_package.get("data_quality", {})
        market_board = base_package.get("market_board", {})
        ms_market = market_board.get("MS", {})
        ht_market = market_board.get("HT", {})
        htft_probs = market_board.get("HTFT", {}).get("probs", {})

        reversal_probs = {
            "1/2": float(htft_probs.get("1/2", 0.0)),
            "2/1": float(htft_probs.get("2/1", 0.0)),
            "X/1": float(htft_probs.get("X/1", 0.0)),
            "X/2": float(htft_probs.get("X/2", 0.0)),
        }
        top_reversal = max(reversal_probs.items(), key=lambda item: item[1])

        ms_conf = float(ms_market.get("confidence", 0.0))
        ht_conf = float(ht_market.get("confidence", 0.0))
        base_conf = (ms_conf + ht_conf) / 2.0

        confidence_cap = 100.0
        penalties: List[str] = []
        if data.lineup_source == "probable_xi":
            confidence_cap = min(confidence_cap, 72.0)
            penalties.append("lineup_probable_xi")
        if data.lineup_source == "none":
            confidence_cap = min(confidence_cap, 58.0)
            penalties.append("lineup_unavailable")
        if str(data_quality.get("label", "LOW")).upper() == "LOW":
            confidence_cap = min(confidence_cap, 55.0)
            penalties.append("low_data_quality")

        final_conf = min(base_conf, confidence_cap)

        upset_score = self._compute_htms_upset_score(
            reversal_probs=reversal_probs,
            odds_data=data.odds_data,
            is_top_league=is_top_league,
        )
        upset_threshold = 58.0 if is_top_league else 54.0
        upset_playable = (
            upset_score >= upset_threshold
            and top_reversal[1] >= 0.045
            and final_conf >= 45.0
            and "low_data_quality" not in penalties
        )

        return {
            "status": "ok",
            "engine_used": engine_used,
            "match_info": base_package.get("match_info", {}),
            "data_quality": data_quality,
            "htms_core": {
                "ms_pick": ms_market.get("pick"),
                "ms_confidence": round(ms_conf, 1),
                "ht_pick": ht_market.get("pick"),
                "ht_confidence": round(ht_conf, 1),
                "combined_confidence": round(final_conf, 1),
                "confidence_cap": round(confidence_cap, 1),
                "penalties": penalties,
            },
            "surprise_hunter": {
                "upset_score": round(upset_score, 1),
                "threshold": upset_threshold,
                "playable": upset_playable,
                "top_reversal_pick": top_reversal[0],
                "top_reversal_prob": round(top_reversal[1], 4),
                "reversal_probs": {
                    key: round(value, 4) for key, value in reversal_probs.items()
                },
            },
            "risk": base_package.get("risk", {}),
            "reasoning_factors": base_package.get("reasoning_factors", []),
        }

    def _is_top_league(self, league_id: Optional[str]) -> bool:
        if not league_id:
            return False
        return str(league_id) in self.top_league_ids

    def _missing_htms_requirements(self, data: MatchData) -> List[str]:
        missing: List[str] = []
        ms_keys = ("ms_h", "ms_d", "ms_a")
        ht_keys = ("ht_h", "ht_d", "ht_a")
        if not all(float(data.odds_data.get(k, 0.0) or 0.0) > 1.0 for k in ms_keys):
            missing.append("ms_odds")
        if not all(float(data.odds_data.get(k, 0.0) or 0.0) > 1.0 for k in ht_keys):
            missing.append("ht_odds")

        return missing

    def _compute_htms_upset_score(
        self,
        reversal_probs: Dict[str, float],
        odds_data: Dict[str, float],
        is_top_league: bool,
    ) -> float:
        ms_h = self._to_float(odds_data.get("ms_h"), 0.0)
        ms_a = self._to_float(odds_data.get("ms_a"), 0.0)
        if ms_h <= 1.0 or ms_a <= 1.0:
            favorite_gap = 0.0
        else:
            favorite_gap = abs(ms_h - ms_a)

        reversal_max = max(reversal_probs.values()) if reversal_probs else 0.0
        reversal_sum = sum(reversal_probs.values())

        # Strong favorite + reversal probability is the core upset signal.
        gap_factor = min(1.0, favorite_gap / 2.0)
        score = (
            (reversal_max * 100.0 * 0.60)
            + (reversal_sum * 100.0 * 0.25)
            + (gap_factor * 100.0 * 0.15)
        )

        if not is_top_league:
            # Non-top leagues are noisier; keep it slightly conservative.
            score *= 0.92
        return max(0.0, min(100.0, score))

    def build_coupon(
        self,
        match_ids: List[str],
        strategy: str = "BALANCED",
        max_matches: Optional[int] = None,
        min_confidence: Optional[float] = None,
    ) -> Dict[str, Any]:
        strategy_name = (strategy or "BALANCED").upper()

        strategy_config = {
            "SAFE": {"max_matches": 4, "min_conf": 66.0},
            "BALANCED": {"max_matches": 5, "min_conf": 58.0},
            "AGGRESSIVE": {"max_matches": 8, "min_conf": 52.0},
            "VALUE": {"max_matches": 8, "min_conf": 48.0},
            "MIRACLE": {"max_matches": 10, "min_conf": 44.0},
        }
        cfg = strategy_config.get(strategy_name, strategy_config["BALANCED"])
        max_allowed = max_matches if max_matches is not None else cfg["max_matches"]
        min_conf = min_confidence if min_confidence is not None else cfg["min_conf"]

        candidates: List[Dict[str, Any]] = []
        rejected: List[Dict[str, Any]] = []

        for match_id in match_ids:
            package = self.analyze_match(match_id)
            if not package:
                rejected.append({"match_id": match_id, "reason": "match_not_found"})
                continue

            risk_level = str(package.get("risk", {}).get("level", "MEDIUM")).upper()
            data_quality = str(package.get("data_quality", {}).get("label", "MEDIUM")).upper()
            match_candidates: List[Dict[str, Any]] = []
            seen_keys: Set[Tuple[str, str]] = set()
            bet_summary = package.get("bet_summary") or []

            raw_picks = []
            for candidate in [
                package.get("main_pick"),
                package.get("value_pick"),
                *(package.get("supporting_picks") or []),
            ]:
                if isinstance(candidate, dict):
                    raw_picks.append(candidate)
            for candidate in bet_summary:
                if isinstance(candidate, dict):
                    raw_picks.append(candidate)

            for candidate in raw_picks:
                market = str(candidate.get("market") or "")
                pick = str(candidate.get("pick") or "")
                if not market or not pick:
                    continue

                dedupe_key = (market, pick)
                if dedupe_key in seen_keys:
                    continue
                seen_keys.add(dedupe_key)

                calibrated_conf = float(
                    candidate.get("calibrated_confidence", candidate.get("confidence", 0.0))
                    or 0.0
                )
                odds = float(candidate.get("odds", 0.0) or 0.0)
                probability = float(candidate.get("probability", 0.0) or 0.0)
                play_score = float(candidate.get("play_score", 0.0) or 0.0)
                ev_edge = float(
                    candidate.get("ev_edge", candidate.get("edge", 0.0)) or 0.0
                )
                playable = bool(candidate.get("playable"))
                bet_grade = str(candidate.get("bet_grade", "PASS")).upper()

                if odds <= 1.01:
                    continue

                strict_candidate = (
                    playable
                    and calibrated_conf >= min_conf
                    and bet_grade != "PASS"
                )

                if strategy_name == "SAFE":
                    strict_pass = strict_candidate
                    if odds > 2.35 or play_score < 60.0 or risk_level in {"HIGH", "EXTREME"}:
                        strict_pass = False
                    if data_quality == "LOW" or ev_edge < 0.01 or bet_grade == "PASS":
                        strict_pass = False
                    strict_score = (
                        calibrated_conf * 1.10
                        + play_score * 0.90
                        + (ev_edge * 180.0)
                        - abs(odds - 1.55) * 12.0
                    )
                    soft_pass = (
                        calibrated_conf >= max(min_conf - 10.0, 56.0)
                        and odds <= 2.70
                        and play_score >= 50.0
                        and risk_level != "EXTREME"
                        and data_quality != "LOW"
                        and ev_edge >= -0.01
                    )
                    soft_score = (
                        calibrated_conf
                        + play_score * 0.85
                        + (ev_edge * 140.0)
                        - abs(odds - 1.65) * 9.0
                    )
                elif strategy_name == "BALANCED":
                    strict_pass = strict_candidate
                    if odds > 3.40 or play_score < 52.0 or risk_level == "EXTREME":
                        strict_pass = False
                    if ev_edge < 0.0 or bet_grade == "PASS":
                        strict_pass = False
                    strict_score = (
                        calibrated_conf
                        + play_score
                        + (ev_edge * 220.0)
                        + min(odds, 3.0) * 3.0
                    )
                    soft_pass = (
                        calibrated_conf >= max(min_conf - 10.0, 48.0)
                        and odds <= 4.20
                        and play_score >= 44.0
                        and risk_level != "EXTREME"
                        and ev_edge >= -0.015
                    )
                    soft_score = (
                        calibrated_conf * 0.95
                        + play_score * 0.90
                        + (ev_edge * 180.0)
                        + min(odds, 3.5) * 3.5
                    )
                elif strategy_name == "AGGRESSIVE":
                    strict_pass = strict_candidate
                    if odds < 1.35 or odds > 7.50 or play_score < 46.0:
                        strict_pass = False
                    if risk_level == "EXTREME" or bet_grade == "PASS":
                        strict_pass = False
                    strict_score = (
                        calibrated_conf * 0.85
                        + play_score * 0.75
                        + (ev_edge * 260.0)
                        + min(odds, 6.0) * 7.0
                    )
                    soft_pass = (
                        calibrated_conf >= max(min_conf - 10.0, 42.0)
                        and 1.25 <= odds <= 8.50
                        and play_score >= 40.0
                        and risk_level != "EXTREME"
                        and ev_edge >= -0.02
                    )
                    soft_score = (
                        calibrated_conf * 0.80
                        + play_score * 0.70
                        + (ev_edge * 210.0)
                        + min(odds, 7.0) * 7.5
                    )
                elif strategy_name == "VALUE":
                    strict_pass = strict_candidate
                    if odds < 1.55 or play_score < 48.0 or ev_edge < 0.03:
                        strict_pass = False
                    if risk_level == "EXTREME" or data_quality == "LOW" or bet_grade == "PASS":
                        strict_pass = False
                    strict_score = (
                        calibrated_conf * 0.75
                        + play_score * 0.85
                        + (ev_edge * 320.0)
                        + min(odds, 6.5) * 8.0
                    )
                    soft_pass = (
                        calibrated_conf >= max(min_conf - 10.0, 40.0)
                        and odds >= 1.35
                        and play_score >= 40.0
                        and risk_level != "EXTREME"
                        and data_quality != "LOW"
                        and ev_edge >= 0.0
                    )
                    soft_score = (
                        calibrated_conf * 0.70
                        + play_score * 0.80
                        + (ev_edge * 260.0)
                        + min(odds, 7.0) * 7.0
                    )
                else:  # MIRACLE
                    strict_pass = strict_candidate
                    if odds < 2.10 or play_score < 40.0 or ev_edge < 0.01:
                        strict_pass = False
                    if risk_level == "EXTREME" or bet_grade == "PASS":
                        strict_pass = False
                    strict_score = (
                        calibrated_conf * 0.55
                        + play_score * 0.60
                        + (ev_edge * 260.0)
                        + min(odds, 10.0) * 10.0
                    )
                    soft_pass = (
                        calibrated_conf >= max(min_conf - 10.0, 36.0)
                        and odds >= 1.60
                        and play_score >= 34.0
                        and risk_level != "EXTREME"
                        and ev_edge >= -0.02
                    )
                    soft_score = (
                        calibrated_conf * 0.50
                        + play_score * 0.55
                        + (ev_edge * 200.0)
                        + min(odds, 10.0) * 9.0
                    )

                fallback_pass = (
                    calibrated_conf >= max(min_conf - 14.0, 34.0)
                    and odds >= 1.20
                    and play_score >= 32.0
                    and risk_level != "EXTREME"
                )
                fallback_score = (
                    calibrated_conf * 0.60
                    + play_score * 0.65
                    + (ev_edge * 120.0)
                    + min(odds, 6.0) * 4.0
                )

                strategy_score = strict_score
                selection_mode = "strict"
                if strict_pass:
                    pass
                elif soft_pass:
                    strategy_score = soft_score
                    selection_mode = "soft"
                elif fallback_pass:
                    strategy_score = fallback_score
                    selection_mode = "fallback"
                else:
                    continue

                match_candidates.append(
                    {
                        "match_id": package["match_info"]["match_id"],
                        "match_name": package["match_info"]["match_name"],
                        "market": market,
                        "pick": pick,
                        "probability": probability,
                        "confidence": calibrated_conf,
                        "odds": odds,
                        "risk_level": risk_level,
                        "data_quality": data_quality,
                        "bet_grade": bet_grade,
                        "playable": playable,
                        "play_score": round(play_score, 1),
                        "ev_edge": round(ev_edge, 4),
                        "selection_mode": selection_mode,
                        "strategy_score": round(strategy_score, 3),
                    }
                )

            if not match_candidates:
                rejected.append(
                    {
                        "match_id": match_id,
                        "reason": "no_strategy_fit",
                        "threshold": min_conf,
                    }
                )
                continue

            match_candidates.sort(
                key=lambda item: (
                    float(item.get("strategy_score", 0.0)),
                    float(item.get("confidence", 0.0)),
                    float(item.get("ev_edge", 0.0)),
                ),
                reverse=True,
            )
            candidates.append(match_candidates[0])

        candidates.sort(
            key=lambda item: (
                float(item.get("strategy_score", 0.0)),
                float(item.get("confidence", 0.0)),
                float(item.get("ev_edge", 0.0)),
            ),
            reverse=True,
        )
        selected = candidates[: max(1, max_allowed)]

        total_odds = 1.0
        win_probability = 1.0
        for pick in selected:
            odd = float(pick.get("odds") or 1.0)
            prob = float(pick.get("probability") or 0.0)
            total_odds *= odd if odd > 1.0 else 1.0
            win_probability *= prob

        return {
            "strategy": strategy_name,
            "generated_at": __import__("datetime").datetime.utcnow().isoformat() + "Z",
            "match_count": len(selected),
            "bets": selected,
            "total_odds": round(total_odds, 2),
            "expected_win_rate": round(win_probability, 4),
            "rejected_matches": rejected,
        }

    def get_daily_bankers(self, count: int = 3) -> List[Dict[str, Any]]:
        with psycopg2.connect(self.dsn) as conn:
            with conn.cursor(cursor_factory=RealDictCursor) as cur:
                cur.execute(
                    """
                    SELECT id
                    FROM live_matches
                    WHERE mst_utc > EXTRACT(EPOCH FROM NOW()) * 1000
                      AND mst_utc < EXTRACT(EPOCH FROM NOW() + INTERVAL '24 hours') * 1000
                    ORDER BY mst_utc ASC
                    LIMIT 60
                    """,
                )
                ids = [row["id"] for row in cur.fetchall()]

        if not ids:
            return []

        coupon = self.build_coupon(
            match_ids=ids,
            strategy="SAFE",
            max_matches=max(1, count),
            min_confidence=78.0,
        )
        return coupon.get("bets", [])[: max(1, count)]

    def get_reversal_watchlist(
        self,
        count: int = 20,
        horizon_hours: int = 72,
        min_score: float = 45.0,
        top_leagues_only: bool = False,
    ) -> Dict[str, Any]:
        safe_count = max(1, min(100, int(count)))
        safe_horizon = max(6, min(168, int(horizon_hours)))
        safe_min_score = max(0.0, min(100.0, float(min_score)))
        now_ms = int(time.time() * 1000)
        horizon_ms = now_ms + (safe_horizon * 60 * 60 * 1000)

        with psycopg2.connect(self.dsn) as conn:
            with conn.cursor(cursor_factory=RealDictCursor) as cur:
                cur.execute(
                    """
                    SELECT
                        lm.id,
                        lm.home_team_id,
                        lm.away_team_id,
                        lm.league_id,
                        lm.mst_utc
                    FROM live_matches lm
                    WHERE lm.sport = 'football'
                      AND lm.mst_utc >= %s
                      AND lm.mst_utc <= %s
                    ORDER BY lm.mst_utc ASC
                    LIMIT 200
                    """,
                    (now_ms, horizon_ms),
                )
                raw_candidates = cur.fetchall()

                candidates = [
                    row
                    for row in raw_candidates
                    if row.get("home_team_id") and row.get("away_team_id")
                ]
                if top_leagues_only:
                    candidates = [
                        row for row in candidates if self._is_top_league(row.get("league_id"))
                    ]

                team_ids: Set[str] = set()
                pair_keys: Set[Tuple[str, str]] = set()
                for row in candidates:
                    home_id = str(row["home_team_id"])
                    away_id = str(row["away_team_id"])
                    team_ids.add(home_id)
                    team_ids.add(away_id)
                    pair_keys.add(tuple(sorted((home_id, away_id))))

                team_cycle = self._fetch_team_reversal_cycle_metrics(cur, team_ids, now_ms)
                h2h_ctx = self._fetch_h2h_reversal_context(cur, pair_keys, now_ms)

        watch_items_all: List[Dict[str, Any]] = []
        scanned = 0
        for row in candidates:
            match_id = str(row["id"])
            data = self._load_match_data(match_id)
            if data is None:
                continue

            package = self.analyze_match(match_id)
            if not package:
                continue

            scanned += 1
            htft_probs = package.get("market_board", {}).get("HTFT", {}).get("probs", {})
            prob_12 = float(htft_probs.get("1/2", 0.0))
            prob_21 = float(htft_probs.get("2/1", 0.0))
            if prob_12 <= 0.0 and prob_21 <= 0.0:
                continue
            overall_htft_pick = None
            overall_htft_prob = 0.0
            if htft_probs:
                overall_htft_pick, overall_htft_prob = max(
                    htft_probs.items(),
                    key=lambda item: float(item[1]),
                )

            reversal_sum = prob_12 + prob_21
            reversal_max = max(prob_12, prob_21)
            top_pick = "2/1" if prob_21 >= prob_12 else "1/2"
            top_prob = prob_21 if top_pick == "2/1" else prob_12

            ms_h = self._to_float(data.odds_data.get("ms_h"), 0.0)
            ms_a = self._to_float(data.odds_data.get("ms_a"), 0.0)
            gap = abs(ms_h - ms_a) if ms_h > 1.0 and ms_a > 1.0 else 0.0
            favorite_odd = min(ms_h, ms_a) if ms_h > 1.0 and ms_a > 1.0 else 0.0

            # Reversal events are rare (~5% baseline), so convert raw probs to a more useful
            # watchlist scale where p in [0.02, 0.08] becomes meaningfully separable.
            base_score = (reversal_max * 100.0 * 8.0) + (reversal_sum * 100.0 * 4.0)

            balance_bonus = 0.0
            if gap > 0.0:
                balance_bonus = max(0.0, (1.0 - min(gap, 1.2) / 1.2) * 7.0)
            elif ms_h > 1.0 and ms_a > 1.0:
                balance_bonus = 2.0

            favorite_bonus = 0.0
            if favorite_odd > 0.0 and favorite_odd <= 1.70 and reversal_max >= 0.02:
                favorite_bonus = min(8.0, (1.70 - favorite_odd) * 12.0)

            home_metrics = team_cycle.get(data.home_team_id, {})
            away_metrics = team_cycle.get(data.away_team_id, {})
            cycle_pressure = max(
                float(home_metrics.get("cycle_pressure", 0.0)),
                float(away_metrics.get("cycle_pressure", 0.0)),
            )
            cycle_bonus = cycle_pressure * 10.0

            pair_key = tuple(sorted((data.home_team_id, data.away_team_id)))
            pair_ctx = h2h_ctx.get(pair_key, {})
            blowout_bonus = 0.0
            last_diff = int(pair_ctx.get("goal_diff", 0))
            if abs(last_diff) >= 3:
                blowout_bonus = 6.0
                if abs(last_diff) >= 5:
                    blowout_bonus += 3.0

            ou25_o = self._to_float(data.odds_data.get("ou25_o"), 0.0)
            tempo_bonus = 0.0
            if ou25_o > 1.0 and ou25_o <= 1.72:
                tempo_bonus = 2.5

            watch_score = max(
                0.0,
                min(
                    100.0,
                    base_score + balance_bonus + favorite_bonus + cycle_bonus + blowout_bonus + tempo_bonus,
                ),
            )
            reason_codes: List[str] = []
            if top_prob >= 0.045:
                reason_codes.append("reversal_prob_hot")
            elif top_prob >= 0.030:
                reason_codes.append("reversal_prob_warm")
            if gap > 0.0 and gap <= 0.80:
                reason_codes.append("balanced_matchup")
            if favorite_bonus > 0.0:
                reason_codes.append("strong_favorite_reversal_window")
            if cycle_pressure >= 0.55:
                reason_codes.append("team_reversal_cycle_pressure")
            if blowout_bonus > 0.0:
                reason_codes.append("h2h_blowout_rematch")
            if tempo_bonus > 0.0:
                reason_codes.append("high_tempo_profile")
            if not reason_codes:
                reason_codes.append("model_signal_only")

            item = (
                {
                    "match_id": data.match_id,
                    "match_name": f"{data.home_team_name} vs {data.away_team_name}",
                    "match_date_ms": data.match_date_ms,
                    "league_id": data.league_id,
                    "league": data.league_name,
                    "risk_band": self._watchlist_risk_band(watch_score),
                    "watch_score": round(watch_score, 2),
                    "top_pick": top_pick,
                    "top_pick_prob": round(top_prob, 4),
                    "top_pick_scope": "reversal_only",
                    "overall_htft_pick": overall_htft_pick,
                    "overall_htft_pick_prob": round(float(overall_htft_prob), 4),
                    "reversal_probs": {
                        "1/2": round(prob_12, 4),
                        "2/1": round(prob_21, 4),
                    },
                    "odds_snapshot": {
                        "ms_h": round(ms_h, 2) if ms_h > 0 else None,
                        "ms_a": round(ms_a, 2) if ms_a > 0 else None,
                        "ms_gap": round(gap, 3),
                        "favorite_odd": round(favorite_odd, 2) if favorite_odd > 0 else None,
                    },
                    "pattern_signals": {
                        "home_cycle_pressure": round(float(home_metrics.get("cycle_pressure", 0.0)), 3),
                        "away_cycle_pressure": round(float(away_metrics.get("cycle_pressure", 0.0)), 3),
                        "home_matches_since_last_reversal": int(home_metrics.get("matches_since_last_reversal", 99)),
                        "away_matches_since_last_reversal": int(away_metrics.get("matches_since_last_reversal", 99)),
                        "h2h_last_goal_diff": last_diff if pair_ctx else None,
                        "h2h_last_result": pair_ctx.get("result"),
                    },
                    "reason_codes": reason_codes,
                }
            )
            watch_items_all.append(item)

        watch_items_all.sort(
            key=lambda item: (
                float(item.get("watch_score", 0.0)),
                float(item.get("top_pick_prob", 0.0)),
            ),
            reverse=True,
        )

        selected = [
            item for item in watch_items_all if float(item.get("watch_score", 0.0)) >= safe_min_score
        ][:safe_count]
        preview = watch_items_all[: min(5, len(watch_items_all))]
        return {
            "engine": "v28.main",
            "generated_at": __import__("datetime").datetime.utcnow().isoformat() + "Z",
            "horizon_hours": safe_horizon,
            "min_score": round(safe_min_score, 2),
            "top_leagues_only": bool(top_leagues_only),
            "scanned_matches": scanned,
            "candidate_matches": len(candidates),
            "listed_matches": len(selected),
            "watchlist": selected,
            "top_candidates_preview": preview,
        }

    def _fetch_team_reversal_cycle_metrics(
        self,
        cur: RealDictCursor,
        team_ids: Set[str],
        now_ms: int,
    ) -> Dict[str, Dict[str, float]]:
        if not team_ids:
            return {}

        cur.execute(
            """
            WITH team_matches AS (
                SELECT
                    m.home_team_id AS team_id,
                    m.mst_utc,
                    CASE
                        WHEN m.ht_score_home > m.ht_score_away THEN 'L'
                        WHEN m.ht_score_home < m.ht_score_away THEN 'T'
                        ELSE 'D'
                    END AS ht_state,
                    CASE
                        WHEN m.score_home > m.score_away THEN 'W'
                        WHEN m.score_home < m.score_away THEN 'L'
                        ELSE 'D'
                    END AS ft_state
                FROM matches m
                WHERE m.status = 'FT'
                  AND m.score_home IS NOT NULL
                  AND m.score_away IS NOT NULL
                  AND m.ht_score_home IS NOT NULL
                  AND m.ht_score_away IS NOT NULL
                  AND m.home_team_id = ANY(%s)
                  AND m.mst_utc < %s
                UNION ALL
                SELECT
                    m.away_team_id AS team_id,
                    m.mst_utc,
                    CASE
                        WHEN m.ht_score_away > m.ht_score_home THEN 'L'
                        WHEN m.ht_score_away < m.ht_score_home THEN 'T'
                        ELSE 'D'
                    END AS ht_state,
                    CASE
                        WHEN m.score_away > m.score_home THEN 'W'
                        WHEN m.score_away < m.score_home THEN 'L'
                        ELSE 'D'
                    END AS ft_state
                FROM matches m
                WHERE m.status = 'FT'
                  AND m.score_home IS NOT NULL
                  AND m.score_away IS NOT NULL
                  AND m.ht_score_home IS NOT NULL
                  AND m.ht_score_away IS NOT NULL
                  AND m.away_team_id = ANY(%s)
                  AND m.mst_utc < %s
            ),
            ranked AS (
                SELECT
                    team_id,
                    mst_utc,
                    ht_state,
                    ft_state,
                    ROW_NUMBER() OVER (PARTITION BY team_id ORDER BY mst_utc DESC) AS rn
                FROM team_matches
            )
            SELECT team_id, mst_utc, ht_state, ft_state
            FROM ranked
            WHERE rn <= 80
            ORDER BY team_id ASC, mst_utc DESC
            """,
            (list(team_ids), now_ms, list(team_ids), now_ms),
        )
        rows = cur.fetchall()

        by_team: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
        for row in rows:
            by_team[str(row["team_id"])].append(row)

        out: Dict[str, Dict[str, float]] = {}
        for team_id in team_ids:
            team_rows = by_team.get(str(team_id), [])
            if not team_rows:
                out[str(team_id)] = {
                    "recent_reversal_rate": 0.0,
                    "matches_since_last_reversal": 99.0,
                    "avg_gap_matches": 12.0,
                    "cycle_pressure": 0.0,
                }
                continue

            reversal_indexes: List[int] = []
            recent_reversal = 0
            recent_n = min(15, len(team_rows))
            for idx, row in enumerate(team_rows, start=1):
                ht_state = str(row.get("ht_state") or "")
                ft_state = str(row.get("ft_state") or "")
                is_reversal = (ht_state == "L" and ft_state == "L") or (ht_state == "T" and ft_state == "W")
                if idx <= recent_n and is_reversal:
                    recent_reversal += 1
                if is_reversal:
                    reversal_indexes.append(idx)

            recent_rate = (recent_reversal / recent_n) if recent_n > 0 else 0.0
            since_last = float(reversal_indexes[0]) if reversal_indexes else 99.0

            gaps: List[float] = []
            if len(reversal_indexes) >= 2:
                for i in range(1, len(reversal_indexes)):
                    gaps.append(float(reversal_indexes[i] - reversal_indexes[i - 1]))
            avg_gap = (sum(gaps) / len(gaps)) if gaps else 12.0
            if avg_gap <= 0:
                avg_gap = 12.0

            cycle_pressure = 0.0
            if reversal_indexes:
                tolerance = max(3.0, avg_gap * 0.7)
                diff = abs(since_last - avg_gap)
                cycle_pressure = max(0.0, 1.0 - (diff / tolerance))

            out[str(team_id)] = {
                "recent_reversal_rate": round(recent_rate, 4),
                "matches_since_last_reversal": round(since_last, 2),
                "avg_gap_matches": round(avg_gap, 2),
                "cycle_pressure": round(cycle_pressure, 4),
            }
        return out

    def _fetch_h2h_reversal_context(
        self,
        cur: RealDictCursor,
        pair_keys: Set[Tuple[str, str]],
        now_ms: int,
    ) -> Dict[Tuple[str, str], Dict[str, Any]]:
        if not pair_keys:
            return {}

        team_ids = sorted({team_id for pair in pair_keys for team_id in pair})
        cur.execute(
            """
            SELECT
                m.home_team_id,
                m.away_team_id,
                m.score_home,
                m.score_away,
                m.ht_score_home,
                m.ht_score_away,
                m.mst_utc
            FROM matches m
            WHERE m.status = 'FT'
              AND m.score_home IS NOT NULL
              AND m.score_away IS NOT NULL
              AND m.home_team_id = ANY(%s)
              AND m.away_team_id = ANY(%s)
              AND m.mst_utc < %s
            ORDER BY m.mst_utc DESC
            LIMIT 4000
            """,
            (team_ids, team_ids, now_ms),
        )
        rows = cur.fetchall()

        out: Dict[Tuple[str, str], Dict[str, Any]] = {}
        for row in rows:
            home_id = str(row["home_team_id"])
            away_id = str(row["away_team_id"])
            key = tuple(sorted((home_id, away_id)))
            if key not in pair_keys or key in out:
                continue

            score_home = int(row["score_home"])
            score_away = int(row["score_away"])
            goal_diff = score_home - score_away
            out[key] = {
                "goal_diff": goal_diff,
                "result": f"{score_home}-{score_away}",
                "match_date_ms": int(row["mst_utc"] or 0),
            }
            if len(out) >= len(pair_keys):
                break

        return out

    @staticmethod
    def _watchlist_risk_band(score: float) -> str:
        if score >= 68.0:
            return "HIGH"
        if score >= 54.0:
            return "MEDIUM"
        return "LOW"

    def _load_match_data(self, match_id: str) -> Optional[MatchData]:
        with psycopg2.connect(self.dsn) as conn:
            with conn.cursor(cursor_factory=RealDictCursor) as cur:
                row = self._fetch_live_match(cur, match_id)
                if not row:
                    row = self._fetch_hist_match(cur, match_id)
                if not row:
                    return None

                home_team_id = row.get("home_team_id")
                away_team_id = row.get("away_team_id")
                if not home_team_id or not away_team_id:
                    # Hard gate: predictions with unknown teams are noisy and misleading.
                    return None

                odds_data = self._extract_odds(cur, row)
                home_lineup, away_lineup, lineup_source, lineup_confidence = self._extract_lineups(cur, row)
                sidelined = self._parse_json_dict(row.get("sidelined"))
                match_date_ms = int(row.get("match_date_ms") or 0)
                league_id = str(row.get("league_id")) if row.get("league_id") else None
                home_id_str = str(home_team_id)
                away_id_str = str(away_team_id)

                home_goals_avg, home_conceded_avg = self._calculate_team_form(
                    cur=cur,
                    team_id=home_id_str,
                    before_date_ms=match_date_ms,
                )
                away_goals_avg, away_conceded_avg = self._calculate_team_form(
                    cur=cur,
                    team_id=away_id_str,
                    before_date_ms=match_date_ms,
                )
                home_position = self._estimate_league_position(
                    cur=cur,
                    team_id=home_id_str,
                    league_id=league_id,
                    before_date_ms=match_date_ms,
                )
                away_position = self._estimate_league_position(
                    cur=cur,
                    team_id=away_id_str,
                    league_id=league_id,
                    before_date_ms=match_date_ms,
                )

                return MatchData(
                    match_id=str(row["match_id"]),
                    home_team_id=home_id_str,
                    away_team_id=away_id_str,
                    home_team_name=row.get("home_team_name") or "Home",
                    away_team_name=row.get("away_team_name") or "Away",
                    match_date_ms=match_date_ms,
                    sport=str(row.get("sport") or "football").lower(),
                    league_id=league_id,
                    league_name=row.get("league_name") or "",
                    referee_name=row.get("referee_name"),
                    odds_data=odds_data,
                    home_lineup=home_lineup,
                    away_lineup=away_lineup,
                    sidelined_data=sidelined,
                    home_goals_avg=home_goals_avg,
                    home_conceded_avg=home_conceded_avg,
                    away_goals_avg=away_goals_avg,
                    away_conceded_avg=away_conceded_avg,
                    home_position=home_position,
                    away_position=away_position,
                    lineup_source=lineup_source,
                    status=str(row.get("status") or ""),
                    state=row.get("state"),
                    substate=row.get("substate"),
                    lineup_confidence=lineup_confidence,
                    current_score_home=(
                        int(row.get("score_home"))
                        if row.get("score_home") is not None
                        else None
                    ),
                    current_score_away=(
                        int(row.get("score_away"))
                        if row.get("score_away") is not None
                        else None
                    ),
                )

    def _fetch_live_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]:
        cur.execute(
            """
            SELECT
                lm.id as match_id,
                lm.home_team_id,
                lm.away_team_id,
                lm.league_id,
                lm.sport,
                lm.mst_utc as match_date_ms,
                lm.status,
                lm.state,
                lm.substate,
                lm.score_home,
                lm.score_away,
                lm.odds,
                lm.lineups,
                lm.sidelined,
                lm.referee_name,
                ht.name as home_team_name,
                at.name as away_team_name,
                l.name as league_name
            FROM live_matches lm
            LEFT JOIN teams ht ON ht.id = lm.home_team_id
            LEFT JOIN teams at ON at.id = lm.away_team_id
            LEFT JOIN leagues l ON l.id = lm.league_id
            WHERE lm.id = %s
            LIMIT 1
            """,
            (match_id,),
        )
        return cur.fetchone()

    def _fetch_hist_match(self, cur: RealDictCursor, match_id: str) -> Optional[Dict[str, Any]]:
        cur.execute(
            """
            SELECT
                m.id as match_id,
                m.home_team_id,
                m.away_team_id,
                m.league_id,
                m.sport,
                m.mst_utc as match_date_ms,
                m.status,
                m.state,
                NULL::text as substate,
                m.score_home,
                m.score_away,
                NULL::jsonb as odds,
                NULL::jsonb as lineups,
                NULL::jsonb as sidelined,
                ref.name as referee_name,
                ht.name as home_team_name,
                at.name as away_team_name,
                l.name as league_name
            FROM matches m
            LEFT JOIN teams ht ON ht.id = m.home_team_id
            LEFT JOIN teams at ON at.id = m.away_team_id
            LEFT JOIN leagues l ON l.id = m.league_id
            LEFT JOIN match_officials ref ON ref.match_id = m.id AND ref.role_id = 1
            WHERE m.id = %s
            LIMIT 1
            """,
            (match_id,),
        )
        return cur.fetchone()

    def _extract_odds(self, cur: RealDictCursor, row: Dict[str, Any]) -> Dict[str, float]:
        odds_data = self._parse_odds_json(row.get("odds"))
        sport_key = str(row.get("sport") or "football").lower()

        missing_relational_keys = [k for k in self.RELATIONAL_ODDS_KEYS if k not in odds_data]
        if missing_relational_keys:
            # fallback to relational odds tables when live odds JSON is incomplete
            cur.execute(
                """
                SELECT oc.name as category_name, os.name as selection_name, os.odd_value
                FROM odd_categories oc
                JOIN odd_selections os ON os.odd_category_db_id = oc.db_id
                WHERE oc.match_id = %s
                ORDER BY oc.db_id ASC, os.db_id ASC
                """,
                (row["match_id"],),
            )
            relational_rows = cur.fetchall()
            rel_odds = self._parse_relational_odds(relational_rows)
            if rel_odds:
                for key, value in rel_odds.items():
                    odds_data.setdefault(key, value)

        if sport_key == "basketball":
            # Reuse football aliases when source only publishes generic match-result naming.
            if "ml_h" not in odds_data and "ms_h" in odds_data:
                odds_data["ml_h"] = float(odds_data["ms_h"])
            if "ml_a" not in odds_data and "ms_a" in odds_data:
                odds_data["ml_a"] = float(odds_data["ms_a"])

            if "ml_h" not in odds_data:
                odds_data["ml_h"] = 1.90
            if "ml_a" not in odds_data:
                odds_data["ml_a"] = 1.90

            if "tot_line" in odds_data and "tot_o" not in odds_data:
                odds_data["tot_o"] = 1.90
            if "tot_line" in odds_data and "tot_u" not in odds_data:
                odds_data["tot_u"] = 1.90
        else:
            if "ms_h" not in odds_data:
                odds_data["ms_h"] = self.DEFAULT_MS_H
            if "ms_d" not in odds_data:
                odds_data["ms_d"] = self.DEFAULT_MS_D
            if "ms_a" not in odds_data:
                odds_data["ms_a"] = self.DEFAULT_MS_A

        return odds_data

    def _extract_lineups(
        self,
        cur: RealDictCursor,
        row: Dict[str, Any],
    ) -> Tuple[Optional[List[str]], Optional[List[str]], str, float]:
        live_lineups = row.get("lineups")
        status_upper = str(row.get("status") or "").upper()
        state_upper = str(row.get("state") or "").upper()
        substate_upper = str(row.get("substate") or "").upper()
        can_trust_feed_lineups = (
            status_upper in {"LIVE", "1H", "2H", "HT", "FT", "FINISHED"}
            or state_upper in {"LIVE", "FIRSTHALF", "SECONDHALF", "POSTGAME", "POST_GAME"}
            or substate_upper in {"LIVE", "FIRSTHALF", "SECONDHALF"}
        )
        home, away = self._parse_lineups_json(live_lineups) if can_trust_feed_lineups else (None, None)
        if (home and len(home) >= 9) and (away and len(away) >= 9):
            return home, away, "confirmed_live", 1.0

        home_id = str(row["home_team_id"])
        away_id = str(row["away_team_id"])

        # fallback 1: current match participation table.
        # Trust this only for live/finished matches; pre-match rows can be stale feed snapshots.
        if can_trust_feed_lineups:
            cur.execute(
                """
                SELECT team_id, player_id
                FROM match_player_participation
                WHERE match_id = %s
                  AND is_starting = true
                """,
                (row["match_id"],),
            )
            rows = cur.fetchall()
            if rows:
                home_players = [str(r["player_id"]) for r in rows if str(r["team_id"]) == home_id]
                away_players = [str(r["player_id"]) for r in rows if str(r["team_id"]) == away_id]
                if not home and home_players:
                    home = home_players
                if not away and away_players:
                    away = away_players
                if (home and len(home) >= 9) and (away and len(away) >= 9):
                    return home, away, "confirmed_participation", 0.98

        # fallback 2: probable XI from historical starts before match date
        before_date_ms = int(row.get("match_date_ms") or 0)
        sidelined = self._parse_json_dict(row.get("sidelined")) or {}
        home_excluded = self._sidelined_player_ids(sidelined.get("homeTeam"))
        away_excluded = self._sidelined_player_ids(sidelined.get("awayTeam"))
        used_probable = False
        home_conf = 0.0
        away_conf = 0.0
        if not home or len(home) < 9:
            home, home_conf = self._build_probable_xi(
                cur,
                home_id,
                before_date_ms,
                excluded_player_ids=home_excluded,
            )
            used_probable = used_probable or bool(home)
        if not away or len(away) < 9:
            away, away_conf = self._build_probable_xi(
                cur,
                away_id,
                before_date_ms,
                excluded_player_ids=away_excluded,
            )
            used_probable = used_probable or bool(away)

        if used_probable:
            inferred_conf = min(
                home_conf if home else 0.0,
                away_conf if away else 0.0,
            )
            return home, away, "probable_xi", inferred_conf
        return home, away, "none", 0.0

    def _calculate_team_form(
        self,
        cur: RealDictCursor,
        team_id: str,
        before_date_ms: int,
        limit: int = 5,
    ) -> Tuple[float, float]:
        if not team_id:
            return 1.5, 1.2
        cur.execute(
            """
            SELECT
                m.home_team_id,
                m.away_team_id,
                m.score_home,
                m.score_away
            FROM matches m
            WHERE (m.home_team_id = %s OR m.away_team_id = %s)
              AND m.status = 'FT'
              AND m.score_home IS NOT NULL
              AND m.score_away IS NOT NULL
              AND m.mst_utc < %s
            ORDER BY m.mst_utc DESC
            LIMIT %s
            """,
            (team_id, team_id, before_date_ms, limit),
        )
        rows = cur.fetchall()
        if not rows:
            return 1.5, 1.2

        weighted_for = 0.0
        weighted_against = 0.0
        total_weight = 0.0
        for idx, row in enumerate(rows):
            weight = float(limit - idx)
            is_home = str(row["home_team_id"]) == team_id
            goals_for = float(row["score_home"] if is_home else row["score_away"])
            goals_against = float(row["score_away"] if is_home else row["score_home"])
            weighted_for += goals_for * weight
            weighted_against += goals_against * weight
            total_weight += weight

        if total_weight <= 0:
            return 1.5, 1.2
        return weighted_for / total_weight, weighted_against / total_weight

    def _estimate_league_position(
        self,
        cur: RealDictCursor,
        team_id: str,
        league_id: Optional[str],
        before_date_ms: int,
    ) -> int:
        if not team_id or not league_id:
            return 10
        try:
            cur.execute(
                """
                SELECT
                    tm.team_id,
                    SUM(tm.points)::int AS points
                FROM (
                    SELECT
                        m.home_team_id AS team_id,
                        CASE
                            WHEN m.score_home > m.score_away THEN 3
                            WHEN m.score_home = m.score_away THEN 1
                            ELSE 0
                        END AS points
                    FROM matches m
                    WHERE m.league_id = %s
                      AND m.status = 'FT'
                      AND m.score_home IS NOT NULL
                      AND m.score_away IS NOT NULL
                      AND m.mst_utc < %s
                    UNION ALL
                    SELECT
                        m.away_team_id AS team_id,
                        CASE
                            WHEN m.score_away > m.score_home THEN 3
                            WHEN m.score_away = m.score_home THEN 1
                            ELSE 0
                        END AS points
                    FROM matches m
                    WHERE m.league_id = %s
                      AND m.status = 'FT'
                      AND m.score_home IS NOT NULL
                      AND m.score_away IS NOT NULL
                      AND m.mst_utc < %s
                ) tm
                GROUP BY tm.team_id
                ORDER BY points DESC
                """,
                (league_id, before_date_ms, league_id, before_date_ms),
            )
            rows = cur.fetchall()
            if not rows:
                return 10
            for idx, row in enumerate(rows, start=1):
                if str(row["team_id"]) == team_id:
                    return idx
            return min(20, len(rows))
        except Exception:
            return 10

    def _build_probable_xi(
        self,
        cur: RealDictCursor,
        team_id: str,
        before_date_ms: int,
        match_limit: int = 5,
        lookback_days: int = 370,
        max_staleness_days: int = 120,
        excluded_player_ids: Optional[Set[str]] = None,
    ) -> Tuple[Optional[List[str]], float]:
        if not team_id:
            return None, 0.0
        min_date_ms = max(0, before_date_ms - (lookback_days * 24 * 60 * 60 * 1000))

        cur.execute(
            """
            SELECT
                mpp.player_id,
                m.id AS match_id,
                m.mst_utc,
                m.home_team_id,
                m.away_team_id
            FROM match_player_participation mpp
            JOIN matches m ON m.id = mpp.match_id
            WHERE mpp.team_id = %s
              AND mpp.is_starting = true
              AND NOT EXISTS (
                  SELECT 1
                  FROM match_player_participation later_mpp
                  JOIN matches later_m ON later_m.id = later_mpp.match_id
                  WHERE later_mpp.player_id = mpp.player_id
                    AND later_mpp.team_id <> %s
                    AND later_m.mst_utc > m.mst_utc
                    AND later_m.mst_utc < %s
                    AND (
                      later_m.status = 'FT'
                      OR later_m.state = 'postGame'
                      OR (later_m.score_home IS NOT NULL AND later_m.score_away IS NOT NULL)
                    )
              )
              AND m.id IN (
                  SELECT m2.id
                  FROM matches m2
                  JOIN match_player_participation recent_mpp
                    ON recent_mpp.match_id = m2.id
                   AND recent_mpp.team_id = %s
                   AND recent_mpp.is_starting = true
                  WHERE (m2.home_team_id = %s OR m2.away_team_id = %s)
                    AND (
                      m2.status = 'FT'
                      OR m2.state = 'postGame'
                      OR (m2.score_home IS NOT NULL AND m2.score_away IS NOT NULL)
                    )
                    AND m2.mst_utc < %s
                    AND m2.mst_utc >= %s
                  GROUP BY m2.id
                  HAVING COUNT(recent_mpp.*) >= 9
                  ORDER BY MAX(m2.mst_utc) DESC
                  LIMIT %s
              )
            ORDER BY m.mst_utc DESC
            """,
            (
                team_id,
                team_id,
                before_date_ms,
                team_id,
                team_id,
                team_id,
                before_date_ms,
                min_date_ms,
                match_limit,
            ),
        )
        rows = cur.fetchall()
        if not rows:
            return None, 0.0

        latest_mst = max(int(row.get("mst_utc") or 0) for row in rows)
        age_days = (before_date_ms - latest_mst) / (24 * 60 * 60 * 1000)
        stale_projection = age_days > max_staleness_days

        excluded = {str(pid) for pid in (excluded_player_ids or set()) if pid}
        match_order: Dict[str, int] = {}
        for row in rows:
            match_id = str(row["match_id"])
            if match_id not in match_order:
                match_order[match_id] = len(match_order)

        player_scores: Dict[str, Dict[str, float]] = {}
        for row in rows:
            player_id = str(row["player_id"])
            if player_id in excluded:
                continue

            idx = match_order.get(str(row["match_id"]), match_limit)
            recency_weight = max(1.0, float(match_limit - idx))
            score = recency_weight
            if idx == 0:
                score += 3.0
            elif idx == 1:
                score += 1.5

            stats = player_scores.setdefault(
                player_id,
                {
                    "score": 0.0,
                    "starts": 0.0,
                    "last_seen_rank": float(idx),
                },
            )
            stats["score"] += score
            stats["starts"] += 1.0
            stats["last_seen_rank"] = min(stats["last_seen_rank"], float(idx))

        if not player_scores:
            return None, 0.0

        ranked = sorted(
            player_scores.items(),
            key=lambda item: (
                item[1]["score"],
                item[1]["starts"],
                -item[1]["last_seen_rank"],
            ),
            reverse=True,
        )
        lineup = [player_id for player_id, _ in ranked[:11]]

        coverage = min(1.0, len(lineup) / 11.0)
        available_matches = max(1, len(match_order))
        history_score = min(1.0, available_matches / float(match_limit))
        core_stability = 0.0
        if ranked:
            stable_core = sum(1 for _, stats in ranked[:11] if stats["starts"] >= 2.0)
            core_stability = stable_core / 11.0

        staleness_factor = max(
            0.35,
            min(1.0, float(max_staleness_days) / max(age_days, 1.0)),
        )
        confidence = (
            (coverage * 0.45) + (history_score * 0.25) + (core_stability * 0.30)
        ) * staleness_factor
        if excluded:
            confidence *= 0.92

        confidence_cap = 0.58 if stale_projection else 0.88
        return lineup or None, round(max(0.0, min(confidence_cap, confidence)), 3)

    @staticmethod
    def _sidelined_player_ids(team_data: Any) -> Set[str]:
        if not isinstance(team_data, dict):
            return set()
        players = team_data.get("players")
        if not isinstance(players, list):
            return set()

        ids: Set[str] = set()
        for player in players:
            if not isinstance(player, dict):
                continue
            player_id = (
                player.get("playerId")
                or player.get("player_id")
                or player.get("id")
                or player.get("personId")
            )
            if player_id:
                ids.add(str(player_id))
        return ids

    def _parse_odds_json(self, odds_json: Any) -> Dict[str, float]:
        odds_json = self._parse_json_dict(odds_json)
        if odds_json is None:
            return {}

        parsed: Dict[str, float] = {}
        for category, selections in odds_json.items():
            if not isinstance(selections, dict):
                continue
            category_text = str(category or "")
            category_norm = self._normalize_text(category)

            if category_norm in ("ms", "maç sonucu", "mac sonucu"):
                parsed["ms_h"] = self._selection_value(selections, ("1",), 0.0)
                parsed["ms_d"] = self._selection_value(selections, ("x", "0"), 0.0)
                parsed["ms_a"] = self._selection_value(selections, ("2",), 0.0)
            elif "maç sonucu (uzt. dahil)" in category_norm or "mac sonucu (uzt. dahil)" in category_norm:
                parsed["ml_h"] = self._selection_value(selections, ("1",), 0.0)
                parsed["ml_a"] = self._selection_value(selections, ("2",), 0.0)
            elif category_norm in ("1. yarı sonucu", "1. yari sonucu", "ilk yarı sonucu", "ilk yari sonucu", "iy sonucu"):
                parsed["ht_h"] = self._selection_value(selections, ("1",), 0.0)
                parsed["ht_d"] = self._selection_value(selections, ("x", "0"), 0.0)
                parsed["ht_a"] = self._selection_value(selections, ("2",), 0.0)
            elif self._is_first_half_ou05_category(category_norm):
                parsed["ht_ou05_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
                parsed["ht_ou05_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
            elif self._is_first_half_ou15_category(category_norm):
                parsed["ht_ou15_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
                parsed["ht_ou15_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
            elif category_norm in ("2.5 alt/üst", "2,5 alt/üst"):
                parsed["ou25_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
                parsed["ou25_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
            elif category_norm in ("1.5 alt/üst", "1,5 alt/üst"):
                parsed["ou15_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
                parsed["ou15_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
            elif category_norm in ("3.5 alt/üst", "3,5 alt/üst"):
                parsed["ou35_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
                parsed["ou35_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
            elif category_norm in ("karşılıklı gol", "karsilikli gol", "kg"):
                parsed["btts_y"] = self._selection_value(selections, ("var", "yes"), 0.0)
                parsed["btts_n"] = self._selection_value(selections, ("yok", "no"), 0.0)
            elif category_norm in ("çifte şans", "cifte sans"):
                parsed["dc_1x"] = self._selection_value(selections, ("1-x", "1x"), 0.0)
                parsed["dc_x2"] = self._selection_value(selections, ("x-2", "x2"), 0.0)
                parsed["dc_12"] = self._selection_value(selections, ("1-2", "12"), 0.0)
            elif category_norm in ("tek/çift", "tek/cift"):
                parsed["oe_odd"] = self._selection_value(selections, ("tek", "odd"), 0.0)
                parsed["oe_even"] = self._selection_value(selections, ("çift", "cift", "even"), 0.0)
            elif self._is_cards_ou_category(category_norm):
                parsed["cards_o"] = self._selection_value(selections, ("üst", "ust", "over"), 0.0)
                parsed["cards_u"] = self._selection_value(selections, ("alt", "under"), 0.0)
            elif category_norm in (
                "ilk yarı/maç sonucu",
                "ilk yari/mac sonucu",
                "iy/ms",
            ):
                for sel_key, sel_val in selections.items():
                    norm_sel = self._normalize_text(sel_key)
                    if "/" in norm_sel:
                        odds_key = f"htft_{norm_sel.replace('/', '').lower()}"
                        parsed[odds_key] = self._to_float(sel_val, 0.0)

            # Basketball full-game total line, e.g. "Alt/Üst (163,5)"
            if self._is_basketball_total_category(category_norm):
                if "tot_line" not in parsed:
                    line = self._extract_parenthesized_number(category_text)
                    if line is not None:
                        parsed["tot_line"] = line
                parsed.setdefault("tot_o", self._selection_value(selections, ("üst", "ust", "over"), 0.0))
                parsed.setdefault("tot_u", self._selection_value(selections, ("alt", "under"), 0.0))

            # Basketball spread, e.g. "Hnd. MS (0:5,5)"
            if (
                "hnd. ms" in category_norm
                or "hand. ms" in category_norm
                or "hnd ms" in category_norm
            ):
                home_line = self._parse_handicap_home_line(category_text)
                if home_line is not None and "spread_home_line" not in parsed:
                    parsed["spread_home_line"] = home_line
                if home_line is not None:
                    self._set_basketball_handicap_odds(parsed, selections, home_line)
            elif self._is_football_handicap_category(category_norm):
                self._set_football_handicap_odds(parsed, selections)
        return parsed

    def _parse_relational_odds(self, rows: List[Dict[str, Any]]) -> Dict[str, float]:
        parsed: Dict[str, float] = {}
        for row in rows:
            category_name = str(row.get("category_name") or "")
            selection_name = str(row.get("selection_name") or "")
            category_norm = self._normalize_text(category_name)
            selection_norm = self._normalize_text(selection_name)
            odd_val = self._to_float(row.get("odd_value"), 0.0)
            if odd_val <= 0:
                continue

            if category_norm in ("maç sonucu", "mac sonucu", "ms"):
                if selection_norm == "1":
                    parsed["ms_h"] = odd_val
                elif selection_norm in ("x", "0"):
                    parsed["ms_d"] = odd_val
                elif selection_norm == "2":
                    parsed["ms_a"] = odd_val
            elif "maç sonucu (uzt. dahil)" in category_norm or "mac sonucu (uzt. dahil)" in category_norm:
                if selection_norm == "1":
                    parsed.setdefault("ml_h", odd_val)
                elif selection_norm == "2":
                    parsed.setdefault("ml_a", odd_val)
            elif category_norm in ("1. yarı sonucu", "1. yari sonucu", "ilk yarı sonucu", "ilk yari sonucu", "iy sonucu"):
                if selection_norm == "1":
                    parsed["ht_h"] = odd_val
                elif selection_norm in ("x", "0"):
                    parsed["ht_d"] = odd_val
                elif selection_norm == "2":
                    parsed["ht_a"] = odd_val
            elif self._is_first_half_ou05_category(category_norm):
                if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
                    parsed["ht_ou05_o"] = odd_val
                elif "alt" in selection_norm or "under" in selection_norm:
                    parsed["ht_ou05_u"] = odd_val
            elif self._is_first_half_ou15_category(category_norm):
                if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
                    parsed["ht_ou15_o"] = odd_val
                elif "alt" in selection_norm or "under" in selection_norm:
                    parsed["ht_ou15_u"] = odd_val
            elif category_norm in ("2,5 alt/üst", "2.5 alt/üst"):
                if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
                    parsed["ou25_o"] = odd_val
                elif "alt" in selection_norm or "under" in selection_norm:
                    parsed["ou25_u"] = odd_val
            elif category_norm in ("1,5 alt/üst", "1.5 alt/üst"):
                if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
                    parsed["ou15_o"] = odd_val
                elif "alt" in selection_norm or "under" in selection_norm:
                    parsed["ou15_u"] = odd_val
            elif category_norm in ("3,5 alt/üst", "3.5 alt/üst"):
                if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
                    parsed["ou35_o"] = odd_val
                elif "alt" in selection_norm or "under" in selection_norm:
                    parsed["ou35_u"] = odd_val
            elif category_norm in ("karşılıklı gol", "karsilikli gol", "kg"):
                if selection_norm == "var" or "yes" in selection_norm:
                    parsed["btts_y"] = odd_val
                elif selection_norm == "yok" or "no" in selection_norm:
                    parsed["btts_n"] = odd_val
            elif category_norm in ("çifte şans", "cifte sans"):
                if selection_norm in ("1-x", "1x"):
                    parsed["dc_1x"] = odd_val
                elif selection_norm in ("x-2", "x2"):
                    parsed["dc_x2"] = odd_val
                elif selection_norm in ("1-2", "12"):
                    parsed["dc_12"] = odd_val
            elif category_norm in ("tek/çift", "tek/cift"):
                if selection_norm in ("tek", "odd"):
                    parsed["oe_odd"] = odd_val
                elif selection_norm in ("çift", "cift", "even"):
                    parsed["oe_even"] = odd_val
            elif self._is_cards_ou_category(category_norm):
                if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
                    parsed["cards_o"] = odd_val
                elif "alt" in selection_norm or "under" in selection_norm:
                    parsed["cards_u"] = odd_val
            elif category_norm in (
                "ilk yarı/maç sonucu",
                "ilk yari/mac sonucu",
                "iy/ms",
            ):
                if "/" in selection_norm:
                    odds_key = f"htft_{selection_norm.replace('/', '').lower()}"
                    parsed[odds_key] = odd_val

            if self._is_basketball_total_category(category_norm):
                if "tot_line" not in parsed:
                    line = self._extract_parenthesized_number(category_name)
                    if line is not None:
                        parsed["tot_line"] = line
                if "üst" in selection_norm or "ust" in selection_norm or "over" in selection_norm:
                    parsed.setdefault("tot_o", odd_val)
                elif "alt" in selection_norm or "under" in selection_norm:
                    parsed.setdefault("tot_u", odd_val)

            if (
                "hnd. ms" in category_norm
                or "hand. ms" in category_norm
                or "hnd ms" in category_norm
            ):
                home_line = self._parse_handicap_home_line(category_name)
                if home_line is not None and "spread_home_line" not in parsed:
                    parsed["spread_home_line"] = home_line
                if home_line is not None:
                    sel_map = {selection_name: odd_val}
                    self._set_basketball_handicap_odds(parsed, sel_map, home_line)
            elif self._is_football_handicap_category(category_norm):
                self._set_football_handicap_odds(parsed, {selection_name: odd_val})
        return parsed

    def _is_basketball_total_category(self, category_norm: str) -> bool:
        if "alt/üst" not in category_norm and "alt/ust" not in category_norm:
            return False
        banned = (
            "1. yarı",
            "1. yari",
            "periyot",
            "ev sahibi",
            "deplasman",
        )
        return not any(token in category_norm for token in banned)

    def _is_first_half_ou05_category(self, category_norm: str) -> bool:
        if "alt/üst" not in category_norm and "alt/ust" not in category_norm:
            return False
        if not any(
            token in category_norm
            for token in ("1. yarı", "1. yari", "ilk yarı", "ilk yari")
        ):
            if not re.search(r"\biy\b", category_norm):
                return False
        # Exclude team-specific first-half totals (home/away) and non-goal props.
        if any(token in category_norm for token in ("ev sahibi", "deplasman", "korner", "kart")):
            return False
        # Match only exact 0.5 line (avoid false positives like 100,5 / 90,5 in basketball totals).
        for token in re.findall(r"\d+(?:[.,]\d+)?", category_norm):
            try:
                if abs(float(token.replace(",", ".")) - 0.5) < 1e-9:
                    return True
            except Exception:
                continue
        return False

    def _is_first_half_ou15_category(self, category_norm: str) -> bool:
        if "alt/üst" not in category_norm and "alt/ust" not in category_norm:
            return False
        if not any(
            token in category_norm
            for token in ("1. yarı", "1. yari", "ilk yarı", "ilk yari")
        ):
            if not re.search(r"\biy\b", category_norm):
                return False
        if any(token in category_norm for token in ("ev sahibi", "deplasman", "korner", "kart")):
            return False
        for token in re.findall(r"\d+(?:[.,]\d+)?", category_norm):
            try:
                if abs(float(token.replace(",", ".")) - 1.5) < 1e-9:
                    return True
            except Exception:
                continue
        return False

    def _is_cards_ou_category(self, category_norm: str) -> bool:
        if "kart" not in category_norm and "card" not in category_norm:
            return False
        return "alt/üst" in category_norm or "alt/ust" in category_norm

    def _is_football_handicap_category(self, category_norm: str) -> bool:
        if any(token in category_norm for token in ("hnd. ms", "hand. ms", "hnd ms")):
            return False
        return any(
            token in category_norm
            for token in (
                "handikapli maç sonucu",
                "handikapli mac sonucu",
                "handikaplı maç sonucu",
                "hnd. maç sonucu",
                "hnd. mac sonucu",
                "hnd maç sonucu",
                "hnd mac sonucu",
            )
        )

    def _extract_parenthesized_number(self, category_name: str) -> Optional[float]:
        if not category_name:
            return None
        try:
            left = category_name.find("(")
            right = category_name.find(")", left + 1)
            if left < 0 or right < 0:
                return None
            raw = category_name[left + 1 : right].strip().replace(",", ".")
            out = float(raw)
            return out if out > 0 else None
        except Exception:
            return None

    def _parse_handicap_home_line(self, category_name: str) -> Optional[float]:
        if not category_name:
            return None
        try:
            left = category_name.find("(")
            right = category_name.find(")", left + 1)
            if left < 0 or right < 0:
                return None
            payload = category_name[left + 1 : right].strip().replace(",", ".")
            if ":" not in payload:
                return None
            home_raw, away_raw = payload.split(":", 1)
            home_hcp = float(home_raw.strip())
            away_hcp = float(away_raw.strip())
            if abs(home_hcp) < 1e-6 and away_hcp > 0:
                return -away_hcp
            if home_hcp > 0 and abs(away_hcp) < 1e-6:
                return home_hcp
            if abs(home_hcp - away_hcp) < 1e-6 and home_hcp > 0:
                return 0.0
        except Exception:
            return None
        return None

    def _set_basketball_handicap_odds(
        self,
        out: Dict[str, float],
        selections: Dict[str, Any],
        home_line: float,
    ) -> None:
        if not isinstance(selections, dict):
            return

        has_home_plus = False
        home_plus_odd = 0.0
        one_odd = 0.0
        two_odd = 0.0

        for key, value in selections.items():
            norm_key = self._normalize_text(key)
            odd = self._to_float(value, 0.0)
            if odd <= 1.0:
                continue
            if norm_key == "1":
                one_odd = odd
            elif norm_key == "2":
                two_odd = odd
            if "+h" in norm_key or norm_key.endswith("h"):
                has_home_plus = True
                home_plus_odd = odd

        if home_line < 0:
            # Home gives points. \"1\" normally means home -line covers.
            if one_odd > 1.0:
                out.setdefault("spread_h", one_odd)
            if home_plus_odd > 1.0:
                out.setdefault("spread_a", home_plus_odd)
            elif two_odd > 1.0:
                out.setdefault("spread_a", two_odd)
        elif home_line > 0:
            # Home receives points. +h entry or \"1\" means home side.
            if home_plus_odd > 1.0:
                out.setdefault("spread_h", home_plus_odd)
            elif one_odd > 1.0:
                out.setdefault("spread_h", one_odd)
            if two_odd > 1.0:
                out.setdefault("spread_a", two_odd)
        else:
            if one_odd > 1.0:
                out.setdefault("spread_h", one_odd)
            if two_odd > 1.0:
                out.setdefault("spread_a", two_odd)

    def _set_football_handicap_odds(
        self,
        out: Dict[str, float],
        selections: Dict[str, Any],
    ) -> None:
        if not isinstance(selections, dict):
            return

        for key, value in selections.items():
            norm_key = self._normalize_text(key)
            odd = self._to_float(value, 0.0)
            if odd <= 1.0:
                continue
            if norm_key == "1":
                out["hcap_h"] = odd
            elif norm_key in ("x", "0"):
                out["hcap_d"] = odd
            elif norm_key == "2":
                out["hcap_a"] = odd

    def _parse_lineups_json(
        self,
        lineups_json: Any,
    ) -> Tuple[Optional[List[str]], Optional[List[str]]]:
        if isinstance(lineups_json, str):
            try:
                lineups_json = json.loads(lineups_json)
            except Exception:
                lineups_json = None

        if not isinstance(lineups_json, dict):
            return None, None

        def parse_side(side: str) -> Optional[List[str]]:
            # Try direct access first (home/away at root level)
            side_obj = lineups_json.get(side)
            
            # Fallback: Check if inside "stats" key (Mackolik format)
            if not isinstance(side_obj, (dict, list)):
                stats = lineups_json.get("stats")
                if isinstance(stats, dict):
                    side_obj = stats.get(side)
            
            if not isinstance(side_obj, (dict, list)):
                return None

            # Try standard formats (xi, starting, lineup)
            entries = None
            if isinstance(side_obj, dict):
                entries = side_obj.get("xi") or side_obj.get("starting") or side_obj.get("lineup")
                # If the dict itself contains player dicts (no wrapper keys)
                if not entries and "position" in side_obj:
                    # side_obj is likely a single player dict, wrap it
                    entries = [side_obj]
            elif isinstance(side_obj, list):
                # side_obj is already a list of players
                entries = side_obj

            if not isinstance(entries, list):
                return None

            ids: List[str] = []
            for p in entries:
                if isinstance(p, dict):
                    player_id = p.get("id") or p.get("playerId") or p.get("personId")
                    if player_id:
                        ids.append(str(player_id))
                elif p:
                    ids.append(str(p))
            return ids or None

        return parse_side("home"), parse_side("away")

    def _build_prediction_package(
        self,
        data: MatchData,
        prediction: FullMatchPrediction,
        v25_signal: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        quality = self._compute_data_quality(data)

        raw_market_rows = self._build_market_rows(data, prediction, v25_signal)
        raw_market_rows = self._apply_market_consistency(
            raw_market_rows,
            data,
            prediction,
        )
        market_rows = [
            self._decorate_market_row(data, prediction, quality, row)
            for row in raw_market_rows
        ]
        market_rows.sort(
            key=lambda row: (
                1 if row.get("playable") else 0,
                float(row.get("play_score", 0.0)),
            ),
            reverse=True,
        )

        playable_rows = [row for row in market_rows if row.get("playable")]
        
        # GUARANTEED PICK LOGIC (V32 - Calibration-aware):
        # Runtime replay insights:
        #   - Trust only markets that remain robust after pre-match replay.
        #   - Current strongest football markets: DC, OU15, HT_OU05.
        #
        # Priority 1: High-accuracy market (DC/OU15/HT_OU05/OU25) + Odds >= 1.30 + Conf >= 44%
        # Priority 2: Any playable + Odds >= 1.30 + Conf >= 44%
        # Priority 3: Playable + Odds >= 1.30
        # Priority 4: Best non-playable (fallback)
        MIN_ODDS = 1.30
        MIN_CONFIDENCE = 44.0  # V32: lowered from 52 to match new calibration
        
        # High-accuracy markets from backtest (prioritize these)
        HIGH_ACCURACY_MARKETS = {"DC", "OU15", "HT_OU05"}
        
        # Priority 1: High-accuracy markets with good odds and confidence
        high_accuracy_picks = [
            row for row in playable_rows
            if row.get("market") in HIGH_ACCURACY_MARKETS
            and float(row.get("odds", 0.0)) >= MIN_ODDS
            and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
        ]
        
        if high_accuracy_picks:
            # Sort by play_score, pick the best
            high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
            main_pick = high_accuracy_picks[0]
            main_pick["is_guaranteed"] = True
            main_pick["pick_reason"] = "high_accuracy_market"
        else:
            # Priority 2: Any playable with odds >= 1.30 and confidence >= 40%
            guaranteed_picks = [
                row for row in playable_rows
                if float(row.get("odds", 0.0)) >= MIN_ODDS
                and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
            ]
            
            if guaranteed_picks:
                guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
                main_pick = guaranteed_picks[0]
                main_pick["is_guaranteed"] = True
                main_pick["pick_reason"] = "confidence_threshold_met"
            else:
                # Priority 3: Fallback - playable with odds >= 1.30
                playable_with_odds = [
                    row for row in playable_rows
                    if float(row.get("odds", 0.0)) >= MIN_ODDS
                ]
                if playable_with_odds:
                    playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
                    main_pick = playable_with_odds[0]
                    main_pick["is_guaranteed"] = False
                    main_pick["pick_reason"] = "odds_only_fallback"
                else:
                    # Priority 4: Last resort - any playable or first market WITH ODDS > 0
                    fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
                    main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None))
                    if main_pick:
                        main_pick["is_guaranteed"] = False
                        main_pick["pick_reason"] = "last_resort"

        aggressive_pick = None
        htft_probs = prediction.ht_ft_probs or {}
        aggressive_candidates = [
            ("1/2", float(htft_probs.get("1/2", 0.0))),
            ("2/1", float(htft_probs.get("2/1", 0.0))),
            ("X/1", float(htft_probs.get("X/1", 0.0))),
            ("X/2", float(htft_probs.get("X/2", 0.0))),
        ]
        aggressive_candidates.sort(key=lambda item: item[1], reverse=True)
        if (
            aggressive_candidates
            and aggressive_candidates[0][1] > 0.03
            and self._market_has_real_pick_odds("HTFT", aggressive_candidates[0][0], data.odds_data or {})
        ):
            aggressive_pick = {
                "market": "HT/FT",
                "pick": aggressive_candidates[0][0],
                "probability": round(aggressive_candidates[0][1], 4),
                "confidence": round(aggressive_candidates[0][1] * 100, 1),
                "odds": None,
            }

        value_pick = None
        # Esnek/Değerli (Value) Pick: Yüksek oran (>= 1.60) ve fena olmayan güven (>= %40)
        value_candidates = [
            row for row in playable_rows
            if float(row.get("odds", 0.0)) >= 1.60
            and float(row.get("calibrated_confidence", 0.0)) >= 40.0
        ]
        if value_candidates:
            # Score them by (play_score * odds) to reward higher odds
            value_candidates.sort(key=lambda r: float(r.get("play_score", 0.0)) * float(r.get("odds", 1.0)), reverse=True)
            for v_cand in value_candidates:
                if not main_pick or (v_cand["market"] != main_pick["market"] or v_cand["pick"] != main_pick["pick"]):
                    value_pick = v_cand
                    break

        supporting: List[Dict[str, Any]] = []
        for row in market_rows:
            if main_pick and row["market"] == main_pick["market"] and row["pick"] == main_pick["pick"]:
                continue
            supporting.append(row)
        supporting = supporting[:6]
        bet_summary = [self._to_bet_summary_item(row) for row in market_rows]

        reasons = self._build_reasoning_factors(data, prediction, quality)

        market_board = {
            "MS": {
                "pick": prediction.ms_pick,
                "confidence": round(float(prediction.ms_confidence), 1),
                "probs": {
                    "1": round(float(prediction.ms_home_prob), 4),
                    "X": round(float(prediction.ms_draw_prob), 4),
                    "2": round(float(prediction.ms_away_prob), 4),
                },
            },
            "DC": {
                "pick": prediction.dc_pick,
                "confidence": round(float(prediction.dc_confidence), 1),
                "probs": {
                    "1X": round(float(prediction.dc_1x_prob), 4),
                    "X2": round(float(prediction.dc_x2_prob), 4),
                    "12": round(float(prediction.dc_12_prob), 4),
                },
            },
            "OU15": {
                "pick": prediction.ou15_pick,
                "confidence": round(float(prediction.ou15_confidence), 1),
                "probs": {
                    "over": round(float(prediction.over_15_prob), 4),
                    "under": round(float(prediction.under_15_prob), 4),
                },
            },
            "OU25": {
                "pick": prediction.ou25_pick,
                "confidence": round(float(prediction.ou25_confidence), 1),
                "probs": {
                    "over": round(float(prediction.over_25_prob), 4),
                    "under": round(float(prediction.under_25_prob), 4),
                },
            },
            "OU35": {
                "pick": prediction.ou35_pick,
                "confidence": round(float(prediction.ou35_confidence), 1),
                "probs": {
                    "over": round(float(prediction.over_35_prob), 4),
                    "under": round(float(prediction.under_35_prob), 4),
                },
            },
            "BTTS": {
                "pick": prediction.btts_pick,
                "confidence": round(float(prediction.btts_confidence), 1),
                "probs": {
                    "yes": round(float(prediction.btts_yes_prob), 4),
                    "no": round(float(prediction.btts_no_prob), 4),
                },
            },
            "HT": {
                "pick": prediction.ht_pick,
                "confidence": round(float(prediction.ht_confidence), 1),
                "probs": {
                    "1": round(float(prediction.ht_home_prob), 4),
                    "X": round(float(prediction.ht_draw_prob), 4),
                    "2": round(float(prediction.ht_away_prob), 4),
                },
            },
            "HTFT": {
                "probs": {k: round(float(v), 4) for k, v in htft_probs.items()},
            },
            "OE": {
                "pick": prediction.odd_even_pick,
                "probs": {
                    "odd": round(float(prediction.odd_prob), 4),
                    "even": round(float(prediction.even_prob), 4),
                },
            },
            "HT_OU05": {
                "pick": prediction.ht_ou_pick,
                "confidence": round(float(max(prediction.ht_over_05_prob, prediction.ht_under_05_prob) * 100), 1),
                "probs": {
                    "over": round(float(prediction.ht_over_05_prob), 4),
                    "under": round(float(prediction.ht_under_05_prob), 4),
                },
            },
            "HT_OU15": {
                "pick": prediction.ht_ou15_pick,
                "confidence": round(float(max(prediction.ht_over_15_prob, prediction.ht_under_15_prob) * 100), 1),
                "probs": {
                    "over": round(float(prediction.ht_over_15_prob), 4),
                    "under": round(float(prediction.ht_under_15_prob), 4),
                },
            },
            "CARDS": {
                "pick": prediction.card_pick,
                "confidence": round(float(prediction.cards_confidence), 1),
                "total": round(float(prediction.total_cards_pred), 1),
                "probs": {
                    "over": round(float(prediction.cards_over_prob), 4),
                    "under": round(float(prediction.cards_under_prob), 4),
                },
            },
            "HCAP": {
                "pick": prediction.handicap_pick,
                "confidence": round(float(prediction.handicap_confidence), 1),
                "probs": {
                    "1": round(float(prediction.handicap_home_prob), 4),
                    "X": round(float(prediction.handicap_draw_prob), 4),
                    "2": round(float(prediction.handicap_away_prob), 4),
                },
            },
        }
        if v25_signal:
            market_board = self._merge_v25_market_board(market_board, v25_signal)

        available_markets = {str(row.get("market") or "") for row in market_rows}
        market_board = {
            market: payload
            for market, payload in market_board.items()
            if market in available_markets
        }

        # Determine simulation mode for the response
        _resp_status = str(data.status or "").upper()
        _resp_state  = str(data.state  or "").upper()
        is_simulation = _resp_status in {"FT", "FINISHED"} or _resp_state in {"POSTGAME", "POST_GAME"}

        return {
            "model_version": "v28-pro-max",
            "simulation_mode": "pre_match" if is_simulation else None,
            "match_info": {
                "match_id": data.match_id,
                "match_name": f"{data.home_team_name} vs {data.away_team_name}",
                "home_team": data.home_team_name,
                "away_team": data.away_team_name,
                "league": data.league_name,
                "match_date_ms": data.match_date_ms,
                "sport": data.sport,
            },
            "data_quality": quality,
            "risk": {
                "level": prediction.risk_level,
                "score": round(float(prediction.risk_score), 1),
                "is_surprise_risk": bool(prediction.is_surprise_risk),
                "surprise_type": prediction.surprise_type,
                "surprise_score": round(float(getattr(prediction, "surprise_score", 0.0) or 0.0), 1),
                "surprise_comment": str(getattr(prediction, "surprise_comment", "") or ""),
                "surprise_reasons": list(getattr(prediction, "surprise_reasons", []) or []),
                "warnings": prediction.risk_warnings,
            },
            "engine_breakdown": {
                "team": round(float(prediction.team_confidence), 1),
                "player": round(float(prediction.player_confidence), 1),
                "odds": round(float(prediction.odds_confidence), 1),
                "referee": round(float(prediction.referee_confidence), 1),
            },
            "main_pick": main_pick,
            "value_pick": value_pick,
            "bet_advice": {
                "playable": bool(main_pick and main_pick.get("playable")),
                "suggested_stake_units": float(main_pick.get("stake_units", 0.0)) if (main_pick and main_pick.get("playable")) else 0.0,
                "reason": "playable_pick_found" if (main_pick and main_pick.get("playable")) else "no_bet_conditions_met",
            },
            "bet_summary": bet_summary,
            "supporting_picks": supporting,
            "aggressive_pick": aggressive_pick,
            "scenario_top5": prediction.ft_scores_top5,
            "score_prediction": {
                "ft": prediction.predicted_ft_score,
                "ht": prediction.predicted_ht_score,
                "xg_home": round(float(prediction.home_xg), 2),
                "xg_away": round(float(prediction.away_xg), 2),
                "xg_total": round(float(prediction.total_xg), 2),
            },
            "market_board": market_board,
            "others": {
                "handicap": prediction.handicap_pick,
                "cards": {
                    "total": round(float(prediction.total_cards_pred), 1),
                    "pick": prediction.card_pick,
                },
            },
            "v25_signal": {
                "available": v25_signal is not None,
                "markets": v25_signal if v25_signal else None,
                "value_bets": v25_signal.get('value_bets', []) if v25_signal else [],
                "ensemble_weights": {"v25": 1.0},
            },
            "reasoning_factors": reasons,
        }

    def _build_basketball_prediction_package(
        self,
        data: MatchData,
        prediction: Dict[str, Any],
    ) -> Dict[str, Any]:
        quality = self._compute_data_quality(data)

        raw_market_rows = self._build_basketball_market_rows(data, prediction)
        market_rows = [
            self._decorate_basketball_market_row(data, prediction, quality, row)
            for row in raw_market_rows
        ]
        market_rows.sort(
            key=lambda row: (
                1 if row.get("playable") else 0,
                float(row.get("play_score", 0.0)),
            ),
            reverse=True,
        )

        playable_rows = [row for row in market_rows if row.get("playable")]
        
        # GUARANTEED PICK LOGIC (Optimized - same as football)
        MIN_ODDS = 1.30
        MIN_CONFIDENCE = 40.0
        HIGH_ACCURACY_MARKETS = {"ML", "TOT", "SPREAD"}
        
        high_accuracy_picks = [
            row for row in playable_rows
            if row.get("market_type") in HIGH_ACCURACY_MARKETS
            and float(row.get("odds", 0.0)) >= MIN_ODDS
            and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
        ]
        
        if high_accuracy_picks:
            high_accuracy_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
            main_pick = high_accuracy_picks[0]
            main_pick["is_guaranteed"] = True
            main_pick["pick_reason"] = "high_accuracy_market"
        else:
            guaranteed_picks = [
                row for row in playable_rows
                if float(row.get("odds", 0.0)) >= MIN_ODDS
                and float(row.get("calibrated_confidence", 0.0)) >= MIN_CONFIDENCE
            ]
            
            if guaranteed_picks:
                guaranteed_picks.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
                main_pick = guaranteed_picks[0]
                main_pick["is_guaranteed"] = True
                main_pick["pick_reason"] = "confidence_threshold_met"
            else:
                playable_with_odds = [
                    row for row in playable_rows
                    if float(row.get("odds", 0.0)) >= MIN_ODDS
                ]
                if playable_with_odds:
                    playable_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
                    main_pick = playable_with_odds[0]
                    main_pick["is_guaranteed"] = False
                    main_pick["pick_reason"] = "odds_only_fallback"
                else:
                    fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
                    main_pick = playable_rows[0] if playable_rows else (fallback_with_odds[0] if fallback_with_odds else (market_rows[0] if market_rows else None))
                    if main_pick:
                        main_pick["is_guaranteed"] = False
                        main_pick["pick_reason"] = "last_resort"

        supporting: List[Dict[str, Any]] = []
        for row in market_rows:
            if main_pick and row["market"] == main_pick["market"] and row["pick"] == main_pick["pick"]:
                continue
            supporting.append(row)
        supporting = supporting[:5]

        bet_summary = [self._to_bet_summary_item(row) for row in market_rows]
        scenarios = self._build_basketball_scenarios(prediction)
        reasons = self._build_basketball_reasoning_factors(data, prediction, quality)

        aggressive_pick: Optional[Dict[str, Any]] = None
        risk_level = prediction.get("risk_level", "MEDIUM")
        risk_score = float(prediction.get("risk_score", 50.0) or 50.0)
        
        # Build aggressive pick if available from Spreak in market_board
        board = prediction.get("market_board", {})
        if risk_level in ("LOW", "MEDIUM") and "Spread" in board:
            spr_data = board["Spread"]
            probs = list(spr_data.values())
            keys = list(spr_data.keys())
            if len(probs) >= 2:
                prob_a = float(str(probs[0]).replace('%', '')) / 100.0
                prob_h = float(str(probs[1]).replace('%', '')) / 100.0
                max_prob = max(prob_a, prob_h)
                
                spr_pick = "Home" if prob_h >= prob_a else "Away"
                
                conf = 50.0
                line_str = "Spread"
                for b in prediction.get("bet_summary", []):
                    if b["market"] == "Spread": 
                        conf = float(b["confidence"])
                        line_str = b["pick"]
                        
                aggressive_pick = {
                    "market": "SPREAD",
                    "pick": line_str,
                    "probability": round(max_prob, 4),
                    "confidence": round(conf, 1),
                    "odds": round(
                        float(
                            data.odds_data.get(
                                "spread_h" if spr_pick == "Home" else "spread_a", 0.0
                            )
                        ),
                        2,
                    ),
                }

        scores = prediction.get("score_prediction", {})
        home_score = scores.get("home_expected", 80.0)
        away_score = scores.get("away_expected", 80.0)
        total_score = scores.get("total_expected", 160.0)

        mb_out = {
            "PLAYER_TOP": board.get("PLAYER_TOP", []),
        }
        
        if "ML" in board:
            ml_data = board["ML"]
            keys = list(ml_data.keys())
            if len(keys) >= 2:
                mb_out["ML"] = {
                    "pick": prediction.get("main_pick", ""),
                    "confidence": 60.0,
                    "probs": {
                        "1": round(float(str(ml_data[keys[0]]).replace('%', '')) / 100.0, 4),
                        "2": round(float(str(ml_data[keys[1]]).replace('%', '')) / 100.0, 4),
                    },
                }
                
        if "Totals" in board:
            tot_data = board["Totals"]
            keys = list(tot_data.keys())
            if len(keys) >= 2:
                mb_out["TOTAL"] = {
                    "line": 160.5,
                    "pick": prediction.get("main_pick", ""),
                    "confidence": 60.0,
                    "probs": {
                        "under": round(float(str(tot_data[keys[0]]).replace('%', '')) / 100.0, 4),
                        "over": round(float(str(tot_data[keys[1]]).replace('%', '')) / 100.0, 4),
                    },
                }

        if "Spread" in board:
            spr_data = board["Spread"]
            keys = list(spr_data.keys())
            if len(keys) >= 2:
                mb_out["SPREAD"] = {
                    "line_home": 0.0,
                    "pick": prediction.get("main_pick", ""),
                    "confidence": 60.0,
                    "probs": {
                        "away_cover": round(float(str(spr_data[keys[0]]).replace('%', '')) / 100.0, 4),
                        "home_cover": round(float(str(spr_data[keys[1]]).replace('%', '')) / 100.0, 4),
                    },
                }

        return {
            "model_version": str(prediction.get("engine_version") or "v28.main.basketball"),
            "match_info": {
                "match_id": data.match_id,
                "match_name": f"{data.home_team_name} vs {data.away_team_name}",
                "home_team": data.home_team_name,
                "away_team": data.away_team_name,
                "league": data.league_name,
                "match_date_ms": data.match_date_ms,
                "sport": data.sport,
            },
            "data_quality": quality,
            "risk": {
                "level": risk_level,
                "score": round(risk_score, 1),
                "is_surprise_risk": False,
                "surprise_type": "",
                "warnings": [],
            },
            "engine_breakdown": prediction.get("engine_breakdown")
            or {
                "team": 60.0,
                "player": 60.0,
                "odds": 80.0,
                "referee": 50.0,
            },
            "main_pick": main_pick,
            "bet_advice": {
                "playable": bool(main_pick and main_pick.get("playable")),
                "suggested_stake_units": float(main_pick.get("stake_units", 0.0))
                if (main_pick and main_pick.get("playable"))
                else 0.0,
                "reason": "playable_pick_found"
                if (main_pick and main_pick.get("playable"))
                else "no_bet_conditions_met",
            },
            "bet_summary": bet_summary,
            "supporting_picks": supporting,
            "aggressive_pick": aggressive_pick,
            "scenario_top5": scenarios,
            "score_prediction": {
                "ft": f"{int(round(home_score))}-{int(round(away_score))}",
                "ht": f"{int(round(home_score * 0.52))}-{int(round(away_score * 0.52))}",
                "xg_home": round(float(home_score), 2),
                "xg_away": round(float(away_score), 2),
                "xg_total": round(float(total_score), 2),
            },
            "market_board": mb_out,
            "reasoning_factors": reasons,
        }

    def _build_basketball_market_rows(
        self,
        data: MatchData,
        pred: Dict[str, Any],
    ) -> List[Dict[str, Any]]:
        odds = data.odds_data
        
        market_board = pred.get("market_board", {})
        
        # 1. Moneyline
        ml_row = None
        if "ML" in market_board:
            ml_data = market_board["ML"]
            # To get specific pick (MS 1 or MS 2), look at the probability values 
            probs = list(ml_data.values())
            keys = list(ml_data.keys())
            if len(probs) >= 2:
                prob_1 = float(str(probs[0]).replace('%', '')) / 100.0
                prob_2 = float(str(probs[1]).replace('%', '')) / 100.0
                max_prob = max(prob_1, prob_2)
                
                # Derive pick string
                ml_pick_val = keys[0] if prob_1 >= prob_2 else keys[1]
                ml_pick = "1" if "1" in ml_pick_val else "2"
                ml_odd_key = "ml_h" if ml_pick == "1" else "ml_a"
                
                # Find confidence from bet summary
                conf = 50.0
                for b in pred.get("bet_summary", []):
                    if b["market"] == "Moneyline": conf = float(b["confidence"])
                    
                ml_row = {
                    "market": "ML",
                    "pick": ml_pick,
                    "probability": round(max_prob, 4),
                    "confidence": round(conf, 1),
                    "odds": round(float(odds.get(ml_odd_key, 0.0)), 2),
                }

        # 2. Totals
        tot_row = None
        if "Totals" in market_board:
            tot_data = market_board["Totals"]
            probs = list(tot_data.values())
            keys = list(tot_data.keys())
            if len(probs) >= 2:
                prob_u = float(str(probs[0]).replace('%', '')) / 100.0
                prob_o = float(str(probs[1]).replace('%', '')) / 100.0
                max_prob = max(prob_u, prob_o)
                
                pick_str = keys[1] if prob_o >= prob_u else keys[0]
                tot_pick = "Over" if "Over" in pick_str else "Under"
                line_val = pick_str.replace("Over", "").replace("Under", "").strip()
                
                conf = 50.0
                for b in pred.get("bet_summary", []):
                    if b["market"] == "Totals": conf = float(b["confidence"])
                    
                tot_row = {
                    "market": "TOTAL",
                    "pick": f"{tot_pick} {line_val}",
                    "probability": round(max_prob, 4),
                    "confidence": round(conf, 1),
                    "odds": round(float(odds.get("tot_o" if tot_pick == "Over" else "tot_u", 0.0)), 2),
                }

        # 3. Spread
        spr_row = None
        if "Spread" in market_board:
            spr_data = market_board["Spread"]
            probs = list(spr_data.values())
            keys = list(spr_data.keys())
            if len(probs) >= 2:
                prob_a = float(str(probs[0]).replace('%', '')) / 100.0
                prob_h = float(str(probs[1]).replace('%', '')) / 100.0
                max_prob = max(prob_a, prob_h)
                
                spr_pick = "Home" if prob_h >= prob_a else "Away"
                
                conf = 50.0
                line_str = ""
                for b in pred.get("bet_summary", []):
                    if b["market"] == "Spread": 
                        conf = float(b["confidence"])
                        line_str = b["pick"]
                        
                spr_row = {
                    "market": "SPREAD",
                    "pick": spr_pick + " " + line_str,
                    "probability": round(max_prob, 4),
                    "confidence": round(conf, 1),
                    "odds": round(float(odds.get("spread_h" if spr_pick == "Home" else "spread_a", 0.0)), 2),
                }
                
        # Return valid rows
        rows = []
        if ml_row: rows.append(ml_row)
        if tot_row: rows.append(tot_row)
        if spr_row: rows.append(spr_row)
        return rows

    def _decorate_basketball_market_row(
        self,
        data: MatchData,
        prediction: Dict[str, Any],
        quality: Dict[str, Any],
        row: Dict[str, Any],
    ) -> Dict[str, Any]:
        market = str(row.get("market") or "")
        raw_conf = float(row.get("confidence") or 0.0)
        prob = float(row.get("probability") or 0.0)
        odd = float(row.get("odds") or 0.0)

        calibration = {"ML": 0.90, "TOTAL": 0.88, "SPREAD": 0.86}.get(market, 0.88)
        min_conf = {"ML": 55.0, "TOTAL": 56.0, "SPREAD": 55.0}.get(market, 55.0)

        calibrated_conf = max(1.0, min(99.0, raw_conf * calibration))
        implied_prob = (1.0 / odd) if odd > 1.0 else 0.0
        edge = prob - implied_prob if implied_prob > 0 else 0.0

        risk_level = str(prediction.get("risk_level", "MEDIUM")).upper()
        risk_penalty = {"LOW": 0.0, "MEDIUM": 3.0, "HIGH": 8.0, "EXTREME": 12.0}.get(
            risk_level,
            4.0,
        )
        quality_label = str(quality.get("label") or "MEDIUM").upper()
        quality_penalty = {"HIGH": 0.0, "MEDIUM": 2.0, "LOW": 6.0}.get(
            quality_label,
            4.0,
        )

        base_score = calibrated_conf + (edge * 100.0)
        play_score = max(0.0, min(100.0, base_score - risk_penalty - quality_penalty))

        reasons: List[str] = []
        playable = True

        min_play_score = self.market_min_play_score.get(market, 68.0)
        min_edge = self.market_min_edge.get(market, 0.02)

        if calibrated_conf < min_conf:
            playable = False
            reasons.append("below_calibrated_conf_threshold")
        if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01:
            playable = False
            reasons.append("market_odds_missing")
        if risk_level in ("HIGH", "EXTREME") and quality_label == "LOW":
            playable = False
            reasons.append("high_risk_low_data_quality")
        if odd > 1.0 and edge < -0.05:
            playable = False
            reasons.append("negative_model_edge")

        if not reasons:
            reasons.append("market_passed_all_gates")

        if not playable:
            grade = "PASS"
            stake_units = 0.0
        elif play_score >= 72:
            grade = "A"
            stake_units = 1.0
        elif play_score >= 61:
            grade = "B"
            stake_units = 0.5
        else:
            grade = "C"
            stake_units = 0.25

        out = dict(row)
        out.update(
            {
                "raw_confidence": round(raw_conf, 1),
                "calibrated_confidence": round(calibrated_conf, 1),
                "min_required_confidence": round(min_conf, 1),
                "edge": round(edge, 4),
                "play_score": round(play_score, 1),
                "playable": playable,
                "bet_grade": grade,
                "stake_units": stake_units,
                "decision_reasons": reasons[:3],
            },
        )
        return out

    def _build_basketball_scenarios(
        self,
        prediction: Dict[str, Any],
    ) -> List[Dict[str, Any]]:
        scores = prediction.get("score_prediction", {})
        home = float(scores.get("home_expected", 80.0))
        away = float(scores.get("away_expected", 80.0))
        templates = [
            (0.00, 0.23),
            (+3.5, 0.20),
            (-3.5, 0.19),
            (+6.0, 0.16),
            (-6.0, 0.14),
        ]
        out: List[Dict[str, Any]] = []
        for delta, prob in templates:
            h = int(round(home + delta))
            a = int(round(away - delta))
            out.append({"score": f"{h}-{a}", "prob": prob})
        return out

    def _build_basketball_reasoning_factors(
        self,
        data: MatchData,
        prediction: Dict[str, Any],
        quality: Dict[str, Any],
    ) -> List[str]:
        factors: List[str] = []
        
        # XGBoost models are odds-aware, weight it heavily
        factors.append("market_signal_dominant")

        if quality.get("label") in ("HIGH", "MEDIUM"):
            factors.append("player_form_signal_strong")
        else:
            factors.append("player_form_signal_limited")

        if prediction.get("is_surprise_risk"):
            factors.append("upset_risk_detected")
        if quality.get("label") == "LOW":
            factors.append("limited_data_confidence")
            
        factors.append("basketball_points_model")
        return factors

    def _real_market_odds(self, odds_data: Dict[str, Any], key: str) -> float:
        """
        Return the odds value for a given key, but 1.0 if it's a known default or missing.

        The prediction engine needs default odds (2.65/3.20) as ML features,
        but market rows must NOT use them for EV edge / Kelly calculations.
        Returning 1.0 acts as a neutral multiplier, avoiding zero-out errors.
        """
        val = float(odds_data.get(key, 1.0))
        if val <= 1.01:
            return 1.0
        _DEFAULTS: Dict[str, float] = {
            "ms_h": self.DEFAULT_MS_H,
            "ms_d": self.DEFAULT_MS_D,
            "ms_a": self.DEFAULT_MS_A,
            "ml_h": 1.90,
            "ml_a": 1.90,
            "ht_h": 2.4,
            "ht_d": 1.9,
            "ht_a": 3.1,
            "ht_ou05_o": 1.9,
            "ht_ou05_u": 1.9,
            "ht_ou15_o": 2.4,
            "ht_ou15_u": 1.5,
            "ou15_o": 1.4,
            "ou15_u": 2.6,
            "ou25_o": 1.9,
            "ou25_u": 1.9,
            "ou35_o": 2.7,
            "ou35_u": 1.4,
            "btts_y": 1.9,
            "btts_n": 1.9,
            "dc_1x": 1.2,
            "dc_x2": 1.4,
            "dc_12": 1.2,
            "oe_odd": 1.9,
            "oe_even": 1.9,
            "cards_o": 1.9,
            "cards_u": 1.9,
            "tot_o": 1.90,
            "tot_u": 1.90,
        }
        if key in _DEFAULTS and abs(val - _DEFAULTS[key]) < 1e-6:
            return 1.0
        return val

    def _sanitize_v25_odds(self, odds_data: Dict[str, Any]) -> Dict[str, float]:
        sanitized: Dict[str, float] = {}
        for key in self.V25_ODDS_FEATURE_KEYS:
            sanitized[key] = self._real_market_odds(odds_data, key)
        for key in ("dc_1x", "dc_x2", "dc_12", "oe_odd", "oe_even", "cards_o", "cards_u", "hcap_h", "hcap_d", "hcap_a"):
            if key in odds_data:
                sanitized[key] = self._real_market_odds(odds_data, key)
        return sanitized

    @staticmethod
    def _v25_pick_to_market_pick(market: str, pick: str) -> str:
        if market == "BTTS":
            return "KG Var" if pick == "Yes" else "KG Yok" if pick == "No" else pick
        if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
            return "Üst" if pick == "Over" else "Alt" if pick == "Under" else pick
        if market == "OE":
            return "Tek" if pick == "Odd" else "Çift" if pick == "Even" else pick
        return pick

    def _v25_market_odds(self, odds: Dict[str, Any], market: str, pick: str) -> float:
        normalized_pick = str(pick or "").strip()
        if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
            normalized_pick = "Over" if ("Üst" in normalized_pick or "Over" in normalized_pick) else "Under"
        elif market == "BTTS":
            normalized_pick = "Yes" if normalized_pick in {"KG Var", "Var", "Yes"} else "No"
        elif market == "OE":
            normalized_pick = "Odd" if normalized_pick in {"Tek", "Odd"} else "Even"
        elif market == "DC":
            normalized_pick = normalized_pick.replace("-", "").upper()
        elif market == "HCAP" and normalized_pick.startswith("Handikap"):
            if " 1" in normalized_pick:
                normalized_pick = "1"
            elif " X" in normalized_pick:
                normalized_pick = "X"
            elif " 2" in normalized_pick:
                normalized_pick = "2"

        key_map = {
            "MS": {"1": "ms_h", "X": "ms_d", "2": "ms_a"},
            "DC": {"1X": "dc_1x", "X2": "dc_x2", "12": "dc_12"},
            "OU15": {"Over": "ou15_o", "Under": "ou15_u"},
            "OU25": {"Over": "ou25_o", "Under": "ou25_u"},
            "OU35": {"Over": "ou35_o", "Under": "ou35_u"},
            "BTTS": {"Yes": "btts_y", "No": "btts_n"},
            "HT": {"1": "ht_h", "X": "ht_d", "2": "ht_a"},
            "HT_OU05": {"Over": "ht_ou05_o", "Under": "ht_ou05_u"},
            "HT_OU15": {"Over": "ht_ou15_o", "Under": "ht_ou15_u"},
            "OE": {"Odd": "oe_odd", "Even": "oe_even"},
            "CARDS": {"Over": "cards_o", "Under": "cards_u"},
            "HCAP": {"1": "hcap_h", "X": "hcap_d", "2": "hcap_a"},
        }
        if market == "HTFT":
            return round(float(odds.get(f"htft_{normalized_pick.replace('/', '').lower()}", 1.0)), 2)
        odds_key = key_map.get(market, {}).get(normalized_pick)
        if not odds_key:
            return 1.0
        return round(self._real_market_odds(odds, odds_key), 2)

    def _market_has_real_pick_odds(self, market: str, pick: str, odds: Dict[str, Any]) -> bool:
        if market not in self.ODDS_REQUIRED_MARKETS:
            return True
        return self._v25_market_odds(odds, market, pick) > 1.01

    @staticmethod
    def _goal_line_for_market(market: str) -> Optional[float]:
        return {
            "OU15": 1.5,
            "OU25": 2.5,
            "OU35": 3.5,
            "HT_OU05": 0.5,
            "HT_OU15": 1.5,
            "CARDS": 4.5,
        }.get(market)

    def _is_live_match(self, data: MatchData) -> bool:
        status = str(data.status or "").upper()
        if status in {"NS", "FT", "POSTPONED", "CANC", "ABD"}:
            return False
        return data.current_score_home is not None and data.current_score_away is not None

    def _apply_market_consistency(
        self,
        rows: List[Dict[str, Any]],
        data: MatchData,
        prediction: FullMatchPrediction,
    ) -> List[Dict[str, Any]]:
        if not rows:
            return rows

        is_live = self._is_live_match(data)
        current_goals = (
            int(data.current_score_home or 0) + int(data.current_score_away or 0)
            if is_live
            else 0
        )
        both_scored = (
            bool(data.current_score_home and data.current_score_home > 0)
            and bool(data.current_score_away and data.current_score_away > 0)
        )
        predicted_total = float(getattr(prediction, "total_xg", 0.0) or 0.0)
        over25_prob = float(getattr(prediction, "over_25_prob", 0.0) or 0.0)
        over35_prob = float(getattr(prediction, "over_35_prob", 0.0) or 0.0)
        btts_yes_prob = float(getattr(prediction, "btts_yes_prob", 0.0) or 0.0)
        home_xg = float(getattr(prediction, "home_xg", 0.0) or 0.0)
        away_xg = float(getattr(prediction, "away_xg", 0.0) or 0.0)
        xg_gap = abs(home_xg - away_xg)
        ht_under05_prob = float(getattr(prediction, "ht_under_05_prob", 0.0) or 0.0)
        ht_over05_prob = float(getattr(prediction, "ht_over_05_prob", 0.0) or 0.0)
        ht_home_prob = float(getattr(prediction, "ht_home_prob", 0.0) or 0.0)
        ht_draw_prob = float(getattr(prediction, "ht_draw_prob", 0.0) or 0.0)
        ht_away_prob = float(getattr(prediction, "ht_away_prob", 0.0) or 0.0)
        htft_probs = getattr(prediction, "ht_ft_probs", {}) or {}
        first_half_goal_from_htft = float(
            sum(
                float(prob or 0.0)
                for outcome, prob in htft_probs.items()
                if str(outcome).startswith(("1/", "2/"))
            )
        )

        adjusted: List[Dict[str, Any]] = []
        for row in rows:
            market = str(row.get("market") or "")
            pick = str(row.get("pick") or "")
            probability = float(row.get("probability") or 0.0)
            confidence = float(row.get("confidence") or (probability * 100.0))
            reasons = list(row.get("consistency_reasons") or [])
            impossible = False

            if is_live:
                if market == "BTTS" and pick == "KG Yok" and both_scored:
                    impossible = True
                    reasons.append("live_state_impossible_market")
                line = self._goal_line_for_market(market)
                if line is not None and "Alt" in pick and current_goals > line:
                    impossible = True
                    reasons.append("live_score_exceeds_under_line")

            if impossible:
                continue

            penalty = 0.0
            line = self._goal_line_for_market(market)
            if line is not None:
                if "Alt" in pick and predicted_total > (line + 0.35):
                    penalty += min(32.0, (predicted_total - line) * 18.0)
                    reasons.append("score_model_conflicts_with_under_pick")
                if "Üst" in pick and predicted_total < (line - 0.35):
                    penalty += min(24.0, (line - predicted_total) * 16.0)
                    reasons.append("score_model_conflicts_with_over_pick")

            if market == "OU35" and "Alt" in pick:
                if over25_prob >= 0.78:
                    penalty += 14.0
                    reasons.append("market_stack_conflict_over25")
                if btts_yes_prob >= 0.74:
                    penalty += 10.0
                    reasons.append("market_stack_conflict_btts")
                if is_live and current_goals >= 3:
                    penalty += 24.0
                    reasons.append("live_total_goals_close_to_line")

            if market == "BTTS" and pick == "KG Yok" and predicted_total >= 2.8:
                penalty += 16.0
                reasons.append("score_model_conflicts_with_btts_no")

            if market == "MS":
                if pick == "X" and xg_gap >= 0.95:
                    penalty += 18.0
                    reasons.append("score_model_conflicts_with_draw_pick")
                if pick == "1" and (away_xg - home_xg) >= 0.85:
                    penalty += 20.0
                    reasons.append("score_model_conflicts_with_home_pick")
                if pick == "2" and (home_xg - away_xg) >= 0.85:
                    penalty += 20.0
                    reasons.append("score_model_conflicts_with_away_pick")

            if market == "HT_OU05":
                if "Alt" in pick:
                    if max(ht_home_prob, ht_away_prob) >= 0.42:
                        penalty += 22.0
                        reasons.append("first_half_result_conflicts_with_goalless_half")
                    if first_half_goal_from_htft >= 0.45:
                        penalty += 20.0
                        reasons.append("first_half_htft_conflicts_with_goalless_half")
                if "Üst" in pick and ht_draw_prob >= 0.56 and ht_under05_prob >= 0.54:
                    penalty += 14.0
                    reasons.append("first_half_draw_conflicts_with_goal_pick")

            if market == "HT" and pick in {"1", "2"} and ht_under05_prob >= 0.56:
                penalty += 28.0
                reasons.append("first_half_goalless_conflicts_with_result_pick")

            if market == "HTFT":
                htft_first_half = pick.split("/")[0] if "/" in pick else ""
                if htft_first_half in {"1", "2"} and ht_under05_prob >= 0.56:
                    penalty += 34.0
                    reasons.append("first_half_goalless_conflicts_with_htft_pick")
                if htft_first_half == "X" and ht_over05_prob >= 0.68:
                    penalty += 16.0
                    reasons.append("first_half_goal_pressure_conflicts_with_htft_draw")

            if penalty > 0:
                probability *= max(0.35, 1.0 - (penalty / 100.0))
                confidence = max(1.0, confidence - penalty)

            next_row = dict(row)
            next_row["probability"] = round(probability, 4)
            next_row["confidence"] = round(confidence, 1)
            if reasons:
                next_row["consistency_reasons"] = reasons
            adjusted.append(next_row)

        return adjusted

    def _build_surprise_profile(
        self,
        data: MatchData,
        prediction: FullMatchPrediction,
    ) -> Dict[str, Any]:
        reasons: List[str] = []
        score = 22.0

        ms_home = float(getattr(prediction, "ms_home_prob", 0.0) or 0.0)
        ms_draw = float(getattr(prediction, "ms_draw_prob", 0.0) or 0.0)
        ms_away = float(getattr(prediction, "ms_away_prob", 0.0) or 0.0)
        top_prob = max(ms_home, ms_draw, ms_away)
        second_prob = sorted([ms_home, ms_draw, ms_away], reverse=True)[1]
        parity_gap = top_prob - second_prob
        total_xg = float(getattr(prediction, "total_xg", 0.0) or 0.0)
        btts_yes = float(getattr(prediction, "btts_yes_prob", 0.0) or 0.0)
        over35 = float(getattr(prediction, "over_35_prob", 0.0) or 0.0)

        if parity_gap <= 0.08:
            score += 18.0
            reasons.append("balanced_match_risk")
        if ms_draw >= 0.30:
            score += 14.0
            reasons.append("draw_probability_elevated")
        if total_xg >= 3.25:
            score += 10.0
            reasons.append("high_total_goal_volatility")
        if btts_yes >= 0.68:
            score += 8.0
            reasons.append("mutual_goal_pressure")
        if over35 >= 0.52:
            score += 8.0
            reasons.append("late_goal_swing_risk")
        if data.lineup_source == "probable_xi":
            score += 8.0
            reasons.append("lineup_probable_not_confirmed")
        if data.lineup_source == "none":
            score += 12.0
            reasons.append("lineup_unavailable")
        if not data.referee_name:
            score += 6.0
            reasons.append("missing_referee")
        if self._is_live_match(data):
            current_goals = int(data.current_score_home or 0) + int(data.current_score_away or 0)
            if current_goals >= 3:
                score += 18.0
                reasons.append("live_match_open_state")
            elif current_goals >= 2:
                score += 10.0
                reasons.append("live_match_active_state")

        score = max(0.0, min(100.0, score))
        if score >= 75:
            comment = "Bu maçta sürpriz ve kırılma riski yüksek. Ana tahminler yatabilir; tekli yerine daha temkinli yaklaşım gerekir."
        elif score >= 55:
            comment = "Bu maçta belirgin sürpriz sinyalleri var. Tahminler yön verse de kupon kararında temkinli olunmalı."
        elif score >= 40:
            comment = "Maçta orta seviyede belirsizlik var. Tahminler yorum için faydalı ama güven payı sınırlı."
        else:
            comment = "Sürpriz riski düşük görünüyor. Tahminler normal güven bandında okunabilir."

        return {
            "score": round(score, 1),
            "comment": comment,
            "reasons": list(dict.fromkeys(reasons))[:6],
        }

    @staticmethod
    def _normalize_v25_probs(market: str, probs: Dict[str, Any]) -> Dict[str, float]:
        out: Dict[str, float] = {}
        for key, value in (probs or {}).items():
            if market in {"OU15", "OU25", "OU35", "HT_OU05", "HT_OU15", "CARDS"}:
                norm_key = "over" if key == "Over" else "under" if key == "Under" else str(key).lower()
            elif market == "BTTS":
                norm_key = "yes" if key == "Yes" else "no" if key == "No" else str(key).lower()
            elif market == "OE":
                norm_key = "odd" if key == "Odd" else "even" if key == "Even" else str(key).lower()
            else:
                norm_key = str(key)
            out[norm_key] = round(float(value), 4)
        return out

    def _merge_v25_market_rows(
        self,
        rows: List[Dict[str, Any]],
        odds: Dict[str, Any],
        v25_signal: Optional[Dict[str, Any]],
    ) -> List[Dict[str, Any]]:
        if not v25_signal:
            return rows

        by_market = {row.get("market"): dict(row) for row in rows}
        for market, payload in v25_signal.items():
            if market == "value_bets" or not isinstance(payload, dict):
                continue
            pick = str(payload.get("pick") or "")
            if not self._market_has_real_pick_odds(market, pick, odds):
                continue
            probability = float(payload.get("probability") or 0.0)
            by_market[market] = {
                "market": market,
                "pick": self._v25_pick_to_market_pick(market, pick),
                "probability": round(probability, 4),
                "confidence": round(float(payload.get("confidence") or probability * 100.0), 1),
                "odds": self._v25_market_odds(odds, market, pick),
            }

        preferred_order = [
            "MS", "DC", "OU15", "OU25", "OU35", "BTTS",
            "HT", "HT_OU05", "HT_OU15", "HTFT", "OE", "CARDS", "HCAP",
        ]
        return [by_market[key] for key in preferred_order if key in by_market]

    def _merge_v25_market_board(
        self,
        market_board: Dict[str, Any],
        v25_signal: Optional[Dict[str, Any]],
    ) -> Dict[str, Any]:
        if not v25_signal:
            return market_board

        merged = dict(market_board)
        for market, payload in v25_signal.items():
            if market == "value_bets" or not isinstance(payload, dict):
                continue
            merged[market] = {
                "pick": self._v25_pick_to_market_pick(market, str(payload.get("pick") or "")),
                "confidence": round(float(payload.get("confidence") or 0.0), 1),
                "probs": self._normalize_v25_probs(market, payload.get("probs") or {}),
            }
        return merged

    def _build_market_rows(
        self,
        data: MatchData,
        pred: FullMatchPrediction,
        v25_signal: Optional[Dict[str, Any]] = None,
    ) -> List[Dict[str, Any]]:
        odds = data.odds_data

        rows = [
            {
                "market": "MS",
                "pick": pred.ms_pick,
                "probability": round(
                    float(max(pred.ms_home_prob, pred.ms_draw_prob, pred.ms_away_prob)),
                    4,
                ),
                "confidence": round(float(pred.ms_confidence), 1),
                "odds": round(self._real_market_odds(odds, {"1": "ms_h", "X": "ms_d", "2": "ms_a"}.get(pred.ms_pick, "ms_h")), 2),
            },
            {
                "market": "DC",
                "pick": pred.dc_pick,
                "probability": round(
                    float(max(pred.dc_1x_prob, pred.dc_x2_prob, pred.dc_12_prob)),
                    4,
                ),
                "confidence": round(float(pred.dc_confidence), 1),
                "odds": round(float(odds.get(f"dc_{pred.dc_pick.lower()}", 1.0)), 2),
            },
            {
                "market": "OU15",
                "pick": pred.ou15_pick,
                "probability": round(float(pred.over_15_prob if "Üst" in pred.ou15_pick or "Over" in pred.ou15_pick else pred.under_15_prob), 4),
                "confidence": round(float(pred.ou15_confidence), 1),
                "odds": round(float(odds.get("ou15_o" if "Üst" in pred.ou15_pick or "Over" in pred.ou15_pick else "ou15_u", 1.0)), 2),
            },
            {
                "market": "OU25",
                "pick": pred.ou25_pick,
                "probability": round(float(pred.over_25_prob if "Üst" in pred.ou25_pick or "Over" in pred.ou25_pick else pred.under_25_prob), 4),
                "confidence": round(float(pred.ou25_confidence), 1),
                "odds": round(float(odds.get("ou25_o" if "Üst" in pred.ou25_pick or "Over" in pred.ou25_pick else "ou25_u", 1.0)), 2),
            },
            {
                "market": "OU35",
                "pick": pred.ou35_pick,
                "probability": round(float(pred.over_35_prob if "Üst" in pred.ou35_pick or "Over" in pred.ou35_pick else pred.under_35_prob), 4),
                "confidence": round(float(pred.ou35_confidence), 1),
                "odds": round(float(odds.get("ou35_o" if "Üst" in pred.ou35_pick or "Over" in pred.ou35_pick else "ou35_u", 1.0)), 2),
            },
            {
                "market": "BTTS",
                "pick": pred.btts_pick,
                "probability": round(float(pred.btts_yes_prob if "Var" in pred.btts_pick or "Yes" in pred.btts_pick else pred.btts_no_prob), 4),
                "confidence": round(float(pred.btts_confidence), 1),
                "odds": round(float(odds.get("btts_y" if "Var" in pred.btts_pick or "Yes" in pred.btts_pick else "btts_n", 1.0)), 2),
            },
            {
                "market": "HT",
                "pick": pred.ht_pick,
                "probability": round(float(max(pred.ht_home_prob, pred.ht_draw_prob, pred.ht_away_prob)), 4),
                "confidence": round(float(pred.ht_confidence), 1),
                "odds": round(float(odds.get({"1": "ht_h", "X": "ht_d", "2": "ht_a"}.get(pred.ht_pick, "ht_h"), 1.0)), 2),
            },
            {
                "market": "HT_OU05",
                "pick": pred.ht_ou_pick,
                "probability": round(float(pred.ht_over_05_prob if "Üst" in pred.ht_ou_pick or "Over" in pred.ht_ou_pick else pred.ht_under_05_prob), 4),
                "confidence": round(float(max(pred.ht_over_05_prob, pred.ht_under_05_prob) * 100), 1),
                "odds": round(float(odds.get("ht_ou05_o" if "Üst" in pred.ht_ou_pick or "Over" in pred.ht_ou_pick else "ht_ou05_u", 1.0)), 2),
            },
            {
                "market": "HT_OU15",
                "pick": pred.ht_ou15_pick,
                "probability": round(float(pred.ht_over_15_prob if "Üst" in pred.ht_ou15_pick or "Over" in pred.ht_ou15_pick else pred.ht_under_15_prob), 4),
                "confidence": round(float(max(pred.ht_over_15_prob, pred.ht_under_15_prob) * 100), 1),
                "odds": round(float(odds.get("ht_ou15_o" if "Üst" in pred.ht_ou15_pick or "Over" in pred.ht_ou15_pick else "ht_ou15_u", 1.0)), 2),
            },
            {
                "market": "OE",
                "pick": pred.odd_even_pick,
                "probability": round(float(pred.odd_prob if "Tek" in pred.odd_even_pick else pred.even_prob), 4),
                "confidence": round(float(max(pred.odd_prob, pred.even_prob) * 100), 1),
                "odds": round(float(odds.get("oe_odd" if "Tek" in pred.odd_even_pick else "oe_even", 1.0)), 2),
            },
            {
                "market": "CARDS",
                "pick": pred.card_pick,
                "probability": round(float(pred.cards_over_prob if "Üst" in pred.card_pick or "Over" in pred.card_pick else pred.cards_under_prob), 4),
                "confidence": round(float(pred.cards_confidence), 1),
                "odds": round(float(odds.get("cards_o" if "Üst" in pred.card_pick or "Over" in pred.card_pick else "cards_u", 1.0)), 2),
            },
            {
                "market": "HCAP",
                "pick": pred.handicap_pick,
                "probability": round(float(
                    pred.handicap_home_prob if pred.handicap_pick == "1"
                    else pred.handicap_draw_prob if pred.handicap_pick == "X"
                    else pred.handicap_away_prob
                ), 4),
                "confidence": round(float(pred.handicap_confidence), 1),
                "odds": round(float(
                    odds.get(
                        {"1": "hcap_h", "X": "hcap_d", "2": "hcap_a"}.get(pred.handicap_pick, "hcap_h"),
                        1.0,
                    )
                ), 2),
            },
        ]

        # HT/FT Market - 9 possible outcomes
        htft_probs = pred.ht_ft_probs or {}
        if htft_probs:
            # Find the highest probability HT/FT outcome
            htft_labels = ("1/1", "1/X", "1/2", "X/1", "X/X", "X/2", "2/1", "2/X", "2/2")
            best_htft = max(htft_labels, key=lambda x: float(htft_probs.get(x, 0.0)))
            best_htft_prob = float(htft_probs.get(best_htft, 0.0))
            
            # Map HT/FT labels to odds keys
            htft_odds_key = f"htft_{best_htft.replace('/', '').lower()}"  # e.g., htft_11, htft_1x, htft_12
            htft_odds = float(odds.get(htft_odds_key, 1.0))
            
            rows.append({
                "market": "HTFT",
                "pick": best_htft,
                "probability": round(best_htft_prob, 4),
                "confidence": round(best_htft_prob * 100, 1),
                "odds": round(htft_odds, 2),
            })

        rows = [
            row for row in rows
            if self._market_has_real_pick_odds(
                str(row.get("market") or ""),
                str(row.get("pick") or ""),
                odds,
            )
        ]

        return self._merge_v25_market_rows(rows, odds, v25_signal)

    def _decorate_market_row(
        self,
        data: MatchData,
        prediction: FullMatchPrediction,
        quality: Dict[str, Any],
        row: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Decorate a raw market row with playability, grading, and staking.

        V20+Quant hybrid:
        - All existing V20+ safety gates preserved (lineup, risk, quality, conf)
        - Edge: EV formula  →  (prob × odds) - 1.0  (not simple prob - implied)
        - Staking: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll)
        - Grading: Edge-based → A(>10%), B(>5%), C(>2%), PASS
        """
        market = str(row.get("market") or "")
        raw_conf = float(row.get("confidence") or 0.0)
        prob = float(row.get("probability") or 0.0)
        odd = float(row.get("odds") or 0.0)

        calibration = self.market_calibration.get(market, 0.85)
        calibrated_conf = max(1.0, min(99.0, raw_conf * calibration))
        min_conf = self.market_min_conf.get(market, 55.0)

        # ── V2 Quant: EV Edge formula ──────────────────────────────────
        # Old: edge = prob - (1/odd)   ← simple probability difference
        # New: edge = (prob × odd) - 1 ← Expected Value (what a quant uses)
        implied_prob = (1.0 / odd) if odd > 1.0 else 0.0
        ev_edge = (prob * odd) - 1.0 if odd > 1.0 else 0.0
        simple_edge = prob - implied_prob if implied_prob > 0 else 0.0

        # ── V31: League-specific odds reliability ──────────────────────
        # Higher reliability → trust odds-based edge more in play_score
        # Lower reliability → lean more on model confidence, less on edge
        odds_rel = self.league_reliability.get(
            str(data.league_id or ""), 0.35  # default for unknown leagues
        )
        # Edge weight: reliable league → edge matters more (up to 120%)
        #              unreliable league → edge matters less (down to 60%)
        edge_multiplier = 0.60 + (odds_rel * 0.60)  # range: 0.60 – 1.20

        risk_level = str(prediction.risk_level or "MEDIUM").upper()
        risk_penalty = {"LOW": 0.0, "MEDIUM": 3.0, "HIGH": 8.0, "EXTREME": 12.0}.get(
            risk_level,
            5.0,
        )
        quality_label = str(quality.get("label") or "MEDIUM").upper()
        quality_penalty = {"HIGH": 0.0, "MEDIUM": 3.0, "LOW": 7.0}.get(
            quality_label,
            5.0,
        )

        home_n = len(data.home_lineup or [])
        away_n = len(data.away_lineup or [])
        lineup_missing = home_n < 9 or away_n < 9
        lineup_sensitive = market in ("MS", "BTTS", "HT", "HTFT")
        lineup_penalty = 5.0 if lineup_missing and lineup_sensitive else 0.0
        if data.lineup_source == "probable_xi" and lineup_sensitive:
            lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
            lineup_penalty += max(1.0, (1.0 - lineup_conf) * 5.0)

        # V31: edge contribution weighted by league odds reliability
        base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier)
        play_score = max(
            0.0,
            min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty),
        )

        # ── V20+ Safety gates (PRESERVED) ─────────────────────────────
        min_play_score = self.market_min_play_score.get(market, 68.0)
        min_edge = self.market_min_edge.get(market, 0.02)
        reasons: List[str] = []
        playable = True

        if calibrated_conf < min_conf:
            playable = False
            reasons.append("below_calibrated_conf_threshold")
        if market in self.ODDS_REQUIRED_MARKETS and odd <= 1.01:
            playable = False
            reasons.append("market_odds_missing")
        if risk_level in ("HIGH", "EXTREME") and quality_label == "LOW":
            playable = False
            reasons.append("high_risk_low_data_quality")
        if lineup_missing and lineup_sensitive:
            # V32: Don't hard-block, apply heavy penalty instead
            # This allows high-confidence predictions to still surface
            lineup_penalty += 8.0
            reasons.append("lineup_insufficient_for_market")
        if data.lineup_source == "probable_xi" and lineup_sensitive:
            # V32: Penalty instead of hard block
            # Most pre-match predictions use probable_xi — blocking kills all output
            lineup_penalty += 6.0
            reasons.append("lineup_probable_xi_penalty")
        # V31: negative edge threshold adapts to league reliability
        # Reliable league: stricter (-0.03), unreliable: looser (-0.08)
        neg_edge_threshold = -0.03 - (1.0 - odds_rel) * 0.05
        if odd > 1.0 and simple_edge < neg_edge_threshold:
            playable = False
            reasons.append(f"negative_model_edge_{simple_edge:+.3f}")
        if odd > 1.0 and ev_edge < min_edge:
            playable = False
            reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
        if play_score < min_play_score:
            playable = False
            reasons.append("insufficient_play_score")

        if not reasons:
            reasons.append("market_passed_all_gates")
        consistency_reasons = [
            str(reason)
            for reason in row.get("consistency_reasons", [])
            if reason
        ]
        if consistency_reasons:
            reasons.extend(consistency_reasons)
            reasons = list(dict.fromkeys(reasons))

        # ── V2 Quant: Edge-based grading (replaces play_score bands) ──
        if not playable:
            grade = "PASS"
            stake_units = 0.0
        elif ev_edge > 0.10:
            grade = "A"
            # V2 Quant: Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll)
            stake_units = self._kelly_stake(prob, odd)
            reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A")
        elif ev_edge > 0.05:
            grade = "B"
            stake_units = self._kelly_stake(prob, odd)
            reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B")
        elif ev_edge > 0.02:
            grade = "C"
            stake_units = self._kelly_stake(prob, odd)
            reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_C")
        else:
            # Passes all V20+ gates but no mathematical edge over bookie
            grade = "C"
            stake_units = 0.25  # minimum stake (conservative)
            reasons.append("no_ev_edge_minimum_stake")

        out = dict(row)
        out.update(
            {
                "raw_confidence": round(raw_conf, 1),
                "calibrated_confidence": round(calibrated_conf, 1),
                "min_required_confidence": round(min_conf, 1),
                "min_required_play_score": round(min_play_score, 1),
                "min_required_edge": round(min_edge, 4),
                "edge": round(ev_edge, 4),
                "implied_prob": round(implied_prob, 4),
                "ev_edge": round(ev_edge, 4),
                "odds_reliability": round(odds_rel, 4),
                "play_score": round(play_score, 1),
                "playable": playable,
                "bet_grade": grade,
                "stake_units": stake_units,
                "decision_reasons": reasons[:5],
            },
        )
        return out

    @staticmethod
    def _kelly_stake(true_prob: float, decimal_odds: float) -> float:
        """
        Fractional Kelly Criterion (¼ Kelly, 10-unit bankroll).

        Full Kelly: f* = ((b × p) - q) / b
          where b = odds - 1, p = true_prob, q = 1 - p

        Quarter-Kelly reduces variance and ruin risk on noisy sports data.
        Returns stake in units, capped at 3.0.
        """
        if decimal_odds <= 1.0 or true_prob <= 0.0 or true_prob >= 1.0:
            return 0.25  # minimum fallback

        b = decimal_odds - 1.0
        p = true_prob
        q = 1.0 - p
        f_star = ((b * p) - q) / b

        if f_star <= 0.0:
            return 0.25  # minimum fallback

        kelly_fraction = 0.25  # quarter-Kelly
        bankroll_units = 10.0
        stake = f_star * kelly_fraction * bankroll_units
        stake = min(stake, 3.0)  # cap
        return round(max(0.25, stake), 1)

    @staticmethod
    def _to_bet_summary_item(row: Dict[str, Any]) -> Dict[str, Any]:
        return {
            "market": row.get("market"),
            "pick": row.get("pick"),
            "raw_confidence": row.get("raw_confidence", row.get("confidence")),
            "calibrated_confidence": row.get("calibrated_confidence", row.get("confidence")),
            "bet_grade": row.get("bet_grade", "PASS"),
            "playable": bool(row.get("playable")),
            "stake_units": float(row.get("stake_units", 0.0)),
            "play_score": row.get("play_score", 0.0),
            "ev_edge": row.get("ev_edge", row.get("edge", 0.0)),
            "implied_prob": row.get("implied_prob", 0.0),
            "odds_reliability": row.get("odds_reliability", 0.35),
            "odds": row.get("odds", 0.0),
            "reasons": row.get("decision_reasons", []),
        }

    def _compute_data_quality(self, data: MatchData) -> Dict[str, Any]:
        if str(data.sport or "football").lower() == "basketball":
            return self._compute_basketball_data_quality(data)

        flags: List[str] = []

        ms_keys = ("ms_h", "ms_d", "ms_a")
        has_ms = all(k in data.odds_data for k in ms_keys)
        has_market_depth = any(k not in ms_keys for k in data.odds_data.keys())
        is_default_ms = (
            abs(float(data.odds_data.get("ms_h", 0.0)) - self.DEFAULT_MS_H) < 1e-6 and
            abs(float(data.odds_data.get("ms_d", 0.0)) - self.DEFAULT_MS_D) < 1e-6 and
            abs(float(data.odds_data.get("ms_a", 0.0)) - self.DEFAULT_MS_A) < 1e-6
        )
        has_real_ms = has_ms and (has_market_depth or (not is_default_ms))
        odds_score = 1.0 if has_real_ms else (0.6 if has_ms else 0.4)
        if odds_score < 1.0:
            flags.append("missing_full_ms_odds")

        home_n = len(data.home_lineup or [])
        away_n = len(data.away_lineup or [])
        lineup_score = min(home_n, away_n) / 11.0 if min(home_n, away_n) > 0 else 0.0
        if data.lineup_source == "probable_xi":
            lineup_conf = max(0.0, min(1.0, float(getattr(data, "lineup_confidence", 0.0) or 0.0)))
            lineup_score *= max(0.45, min(0.88, lineup_conf))
            flags.append("lineup_probable_not_confirmed")
            if lineup_conf < 0.65:
                flags.append("lineup_projection_low_confidence")
        elif data.lineup_source == "none":
            flags.append("lineup_unavailable")
        if lineup_score < 0.7:
            flags.append("lineup_incomplete")

        ref_score = 1.0 if data.referee_name else 0.6
        if not data.referee_name:
            flags.append("missing_referee")

        total_score = (odds_score * 0.45) + (lineup_score * 0.45) + (ref_score * 0.10)

        if total_score >= 0.8:
            label = "HIGH"
        elif total_score >= 0.55:
            label = "MEDIUM"
        else:
            label = "LOW"

        return {
            "label": label,
            "score": round(total_score, 3),
            "home_lineup_count": home_n,
            "away_lineup_count": away_n,
            "lineup_source": data.lineup_source,
            "lineup_confidence": round(float(getattr(data, "lineup_confidence", 0.0) or 0.0), 3),
            "flags": flags,
        }

    def _compute_basketball_data_quality(self, data: MatchData) -> Dict[str, Any]:
        flags: List[str] = []

        has_ml = float(data.odds_data.get("ml_h", 0.0)) > 1.0 and float(data.odds_data.get("ml_a", 0.0)) > 1.0
        has_total = (
            float(data.odds_data.get("tot_line", 0.0)) > 0.0
            and float(data.odds_data.get("tot_o", 0.0)) > 1.0
            and float(data.odds_data.get("tot_u", 0.0)) > 1.0
        )
        has_spread = (
            "spread_home_line" in data.odds_data
            and float(data.odds_data.get("spread_h", 0.0)) > 1.0
            and float(data.odds_data.get("spread_a", 0.0)) > 1.0
        )

        odds_components = [has_ml, has_total, has_spread]
        odds_score = sum(1.0 for x in odds_components if x) / 3.0
        if not has_ml:
            flags.append("missing_moneyline_odds")
        if not has_total:
            flags.append("missing_total_odds")
        if not has_spread:
            flags.append("missing_spread_odds")

        # Basketball live lineup/referee coverage is structurally lower in this project.
        # Keep neutral baseline and rely mostly on odds depth.
        lineup_score = 0.7
        ref_score = 0.7

        total_score = (odds_score * 0.75) + (lineup_score * 0.15) + (ref_score * 0.10)
        if total_score >= 0.75:
            label = "HIGH"
        elif total_score >= 0.52:
            label = "MEDIUM"
        else:
            label = "LOW"

        return {
            "label": label,
            "score": round(total_score, 3),
            "home_lineup_count": len(data.home_lineup or []),
            "away_lineup_count": len(data.away_lineup or []),
            "lineup_source": data.lineup_source,
            "flags": flags,
        }

    def _build_reasoning_factors(
        self,
        data: MatchData,
        prediction: FullMatchPrediction,
        quality: Dict[str, Any],
    ) -> List[str]:
        factors: List[str] = []

        if prediction.odds_confidence >= prediction.team_confidence:
            factors.append("market_signal_dominant")
        else:
            factors.append("team_form_signal_dominant")

        if prediction.player_confidence >= 60:
            factors.append("lineup_signal_strong")
        elif not data.home_lineup or not data.away_lineup:
            factors.append("lineup_signal_weak")
        if data.lineup_source == "probable_xi":
            factors.append("lineup_probable_xi_used")

        if prediction.is_surprise_risk:
            factors.append("upset_risk_detected")

        if quality["label"] == "LOW":
            factors.append("limited_data_confidence")

        if prediction.risk_warnings:
            factors.extend([f"risk:{w}" for w in prediction.risk_warnings[:2]])

        return factors

    @staticmethod
    def _to_float(value: Any, default: float) -> float:
        try:
            if value is None:
                return default
            return float(value)
        except Exception:
            return default

    @staticmethod
    def _normalize_text(value: Any) -> str:
        text = str(value or "").casefold().replace("i̇", "i")
        return " ".join(text.split())

    def _selection_value(
        self,
        selections: Dict[str, Any],
        aliases: Tuple[str, ...],
        default: float,
    ) -> float:
        if not isinstance(selections, dict):
            return default

        normalized_aliases = {self._normalize_text(alias) for alias in aliases}
        for key, value in selections.items():
            key_norm = self._normalize_text(key)
            if key_norm in normalized_aliases:
                return self._to_float(value, default)

        # Secondary match for entries like "2,5 Üst" or "Toplam Alt"
        for key, value in selections.items():
            key_norm = self._normalize_text(key)
            if any(alias in key_norm for alias in normalized_aliases):
                return self._to_float(value, default)

        return default

    def _parse_json_dict(self, payload: Any) -> Optional[Dict[str, Any]]:
        if isinstance(payload, str):
            try:
                payload = json.loads(payload)
            except Exception:
                return None
        return payload if isinstance(payload, dict) else None

    def get_daily_bankers(self, count: int = 3) -> List[Dict[str, Any]]:
        """
        Identifies the safest, highest value bets for the next 24 hours.
        """
        now_ms = int(time.time() * 1000)
        horizon_ms = now_ms + (24 * 60 * 60 * 1000)

        with psycopg2.connect(self.dsn) as conn:
            with conn.cursor(cursor_factory=RealDictCursor) as cur:
                cur.execute("""
                    SELECT m.id, m.match_name, m.mst_utc
                    FROM matches m
                    WHERE m.mst_utc >= %s AND m.mst_utc <= %s
                      AND m.status = 'NS'
                      AND EXISTS (SELECT 1 FROM odd_categories oc WHERE oc.match_id = m.id)
                    ORDER BY m.mst_utc ASC
                    LIMIT 50
                """, (now_ms, horizon_ms))
                matches = cur.fetchall()

        potential_bankers = []
        print(f"🔍 Scanning {len(matches)} upcoming matches for Bankers...")

        for match in matches:
            try:
                data = self._load_match_data(match['id'])
                if data is None: continue

                result = self.analyze_match(match['id'])

                if result and 'main_pick' in result:
                    pick = result['main_pick']
                    conf = pick.get('calibrated_confidence', pick.get('confidence', 0))
                    odds = pick.get('odds', 0)
                    market = pick.get('market', '')
                    pick_name = pick.get('pick', '')
                    
                    # Banker Criteria: High Confidence (>75%) AND Decent Odds (>1.30)
                    if conf >= 75.0 and odds >= 1.30:
                         score = conf * (odds - 1.0)
                         potential_bankers.append({
                             "match_id": match['id'],
                             "match_name": match['match_name'] or f"{data.home_team_name} vs {data.away_team_name}",
                             "league": data.league_name,
                             "pick": f"{market} - {pick_name}",
                             "confidence": conf,
                             "odds": odds,
                             "value_score": score
                         })
            except Exception:
                pass

        potential_bankers.sort(key=lambda x: x['value_score'], reverse=True)
        return potential_bankers[:count]


_orchestrator: Optional[SingleMatchOrchestrator] = None


def get_single_match_orchestrator() -> SingleMatchOrchestrator:
    global _orchestrator
    if _orchestrator is None:
        _orchestrator = SingleMatchOrchestrator()
    return _orchestrator
