"""Market Board Mixin — market rows, decoration, surprise profile, data quality.

Auto-extracted mixin module — split from services/single_match_orchestrator.py.
All methods here are composed into SingleMatchOrchestrator via inheritance.
`self` attributes (self.dsn, self.enrichment, self.v25_predictor, etc.) are
initialised in the main __init__.
"""

from __future__ import annotations

import json
import re
import time
import math
import os
import pickle
from collections import defaultdict
from typing import Any, Dict, List, Optional, Set, Tuple, overload

import pandas as pd
import numpy as np

import psycopg2
from psycopg2.extras import RealDictCursor

from data.db import get_clean_dsn
from schemas.prediction import FullMatchPrediction
from schemas.match_data import MatchData
from models.v25_ensemble import V25Predictor, get_v25_predictor
try:
    from models.v27_predictor import V27Predictor, compute_divergence, compute_value_edge
except ImportError:
    class V27Predictor:  # type: ignore[no-redef]
        def __init__(self): self.models = {}
        def load_models(self): return False
        def predict_all(self, features): return {}
    def compute_divergence(*args, **kwargs):
        return {}
    def compute_value_edge(*args, **kwargs):
        return {}
from features.odds_band_analyzer import OddsBandAnalyzer
try:
    from models.basketball_v25 import (
        BasketballMatchPrediction,
        get_basketball_v25_predictor,
    )
except ImportError:
    BasketballMatchPrediction = Any  # type: ignore[misc]
    def get_basketball_v25_predictor() -> Any:
        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 services.match_commentary import generate_match_commentary
from utils.top_leagues import load_top_league_ids
from utils.league_reliability import load_league_reliability
from config.config_loader import build_threshold_dict, get_threshold_default
from models.calibration import get_calibrator, get_final_recalibrator
from models.market_anchor import devig, apply_corrections
from models.score_matrix import build_calibrated_score_package
from models.live_matrix import build_live_projection, estimate_minute

# ── V30: Post-calibration trust factors ─────────────────────────────
# Controls how much to trust isotonic calibrator vs raw model output.
# trust=1.0 → use calibrator fully; trust=0.0 → bypass, use raw model.
# Derived from calibrator_metrics.json analysis (mean_predicted vs mean_actual):
#   MS calibrators: gap < 0.5%  → excellent, full trust
#   BTTS:           gap = +14.4% → calibrator broken, bypass
#   OU25:           gap = +5.3%  → over-inflates, mostly bypass
#   OU35:           gap = +3.6%  → moderate inflation, dampen
#   OU15:           gap = +1.5%  → slight, mostly trust
#   HT:             mixed        → moderate trust
#   DC/HT_FT:       < 30 samples → unreliable, bypass
POST_CAL_TRUST: Dict[str, float] = {
    "ms_home": 1.0,
    "ms_draw": 1.0,
    "ms_away": 1.0,
    "btts":    0.0,
    "ou25":    0.15,
    "ou35":    0.30,
    "ou15":    0.70,
    "ht_home": 0.50,
    "ht_draw": 0.30,
    "ht_away": 0.50,
    "dc":      0.0,
    "ht_ft":   0.0,
}


class MarketBoardMixin:
    def _league_confidence_for(self, league_id: Optional[str]) -> Optional[Dict[str, Any]]:
        """Return the backtest-derived confidence record for a league, or None.

        Shape: {"label": high|medium|low, "bet_roi": float, "bet_n": int,
        "hit": float}. None → league absent or too few bets ('unknown') → FE
        shows no badge. Never raises (missing artifact = graceful None)."""
        if not league_id:
            return None
        lookup = getattr(self, "league_confidence", None) or {}
        info = lookup.get(str(league_id))
        if not isinstance(info, dict):
            return None
        label = info.get("label")
        if label in (None, "unknown"):
            return None
        return {
            "label": label,
            "bet_roi": info.get("bet_roi"),
            "bet_n": info.get("bet_n"),
            "hit": info.get("hit"),
        }

    def _is_national_match(self, league_id: Optional[str]) -> bool:
        """True if this league is an A-milli (senior men's) national competition."""
        if not league_id:
            return False
        natl = getattr(self, "national_leagues", None) or set()
        return str(league_id) in natl

    def _competition_type_for(
        self, league_id: Optional[str], league_name: Optional[str]
    ) -> Optional[str]:
        """For national matches, classify HAZIRLIK/ELEME/TURNUVA from the league
        name. None for non-national leagues (clubs don't use this)."""
        if not self._is_national_match(league_id):
            return None
        try:
            from utils.national_leagues import classify_competition
            return classify_competition(league_name or "")
        except Exception:
            return None

    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")]
        
        MIN_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("ev_edge", 0.0)),
                    float(r.get("play_score", 0.0)),
                ),
                reverse=True,
            )
            main_pick = playable_with_odds[0]
            main_pick["is_guaranteed"] = False
            main_pick["pick_reason"] = "positive_ev_after_odds_band_gate"
        else:
            fallback_with_odds = [r for r in market_rows if float(r.get("odds", 0.0)) > 1.0]
            fallback_with_odds.sort(key=lambda r: float(r.get("play_score", 0.0)), reverse=True)
            main_pick = 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["playable"] = False
                main_pick["stake_units"] = 0.0
                main_pick["bet_grade"] = "PASS"
                main_pick["pick_reason"] = "no_playable_value_after_odds_band_gate"

        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
            # V34: Lowered min calibrated_confidence for value candidates from 40.0 to 25.0 
            # to allow high-odds value bets (which naturally have lower probabilities).
            and float(row.get("calibrated_confidence", 0.0)) >= 25.0
        ]
        if value_candidates:
            # Score them by (ev_edge) to reward actual mathematical value
            value_candidates.sort(key=lambda r: float(r.get("ev_edge", 0.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
        }

        # V35: anchor the DISPLAYED per-market probabilities to the de-vigged
        # market price (+ proven home-favourite correction). The model's own
        # numbers were measured ~25-30% mis-calibrated; the de-vigged market is
        # ~1.5% (out-of-sample). This only rewrites what the user sees.
        market_board = self._apply_market_anchor(market_board, data)

        # V35b: make the DISPLAYED confidence/edge fields on every pick object
        # consistent with the calibrated board (Güven Skoru, Güven Aralığı,
        # Model%/Teorik-avantaj), then drop a "value pick" that has no real edge
        # once priced honestly — no fabricated value bets.
        self._apply_anchor_to_picks(
            market_board, main_pick, value_pick, aggressive_pick, supporting, bet_summary,
        )
        if value_pick is not None and float(value_pick.get("ev_edge", 0.0) or 0.0) <= 0.0:
            value_pick = None

        # V36: derive the score card (score_prediction + scenario_top5) from the
        # SAME anchored probabilities, so it can never contradict the MS card.
        # Validated on 63,681 real-odds matches: modal-score hit 12.6% vs stated
        # 13.1%, top-5 coverage 51%, per-score gaps <1.2pt.
        cal_score = self._build_calibrated_score(market_board)

        # V38: while the match is LIVE, also project score/minute-conditioned
        # probabilities (P(side scores again), live 1X2, comeback, scenarios).
        # OOS-validated on 70,410 reconstructed live moments: ECE 0.5-0.8%;
        # "one-goal lead at 80'" case: said 21.7% vs actual 23.0%.
        live_projection = self._build_live_projection(market_board, data)

        # 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,
                "league_id": data.league_id,
                # Backtest-derived per-league confidence (ROI + sample size).
                # None when the league has too little data to judge → FE shows no badge.
                "league_confidence": self._league_confidence_for(data.league_id),
                # National-team match flags (drive betting_brain's national gate).
                "is_national": self._is_national_match(data.league_id),
                "competition_type": self._competition_type_for(data.league_id, data.league_name),
                "match_date_ms": data.match_date_ms,
                "sport": data.sport,
                # Live snapshot — match_commentary uses this to detect upset-in-progress
                "status": data.status,
                "state": data.state,
                "is_live": self._is_live_match(data),
                "current_score_home": data.current_score_home,
                "current_score_away": data.current_score_away,
            },
            "prediction_freshness": {
                "generated_at_ms": int(time.time() * 1000),
                "is_pre_match_snapshot": True,
                # Stale when the match is already underway — UI should warn the user.
                "is_stale_for_live": self._is_live_match(data),
            },
            "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 []),
                "surprise_breakdown": list(getattr(prediction, "surprise_breakdown", []) or []),
                "warnings": prediction.risk_warnings,
            },
            "engine_breakdown": self._build_engine_breakdown(prediction),
            "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": (
                cal_score["scenario_top5"] if cal_score else prediction.ft_scores_top5
            ),
            "score_prediction": (
                cal_score["score_prediction"]
                if cal_score
                else {
                    "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,
            # V38: score/minute-aware live probabilities (None when not live or
            # no real odds). FE can render "deplasman gol atar: %X / dönme: %Y".
            "live_projection": live_projection,
            "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 _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

    @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

    def _odds_band_verdict(
        self,
        data: MatchData,
        market: str,
        pick: str,
        implied_prob: float,
    ) -> Dict[str, Any]:
        features = getattr(data, "odds_band_features", {}) or {}
        market_key = str(market or "").upper()
        if not isinstance(features, dict) or implied_prob <= 0.0:
            return {
                "required": market_key in self.odds_band_min_sample,
                "available": False,
                "band_prob": 0.0,
                "band_sample": 0.0,
                "band_edge": 0.0,
                "aligned": False,
                "reason": "odds_band_unavailable",
            }

        pick_key = self._normalize_pick_token(pick)
        band_prob = 0.0
        sample = 0.0

        if market_key == "MS":
            if pick_key == "1":
                band_prob = float(features.get("home_band_ms_win_rate", 0.0) or 0.0)
                sample = float(features.get("home_band_ms_sample", 0.0) or 0.0)
            elif pick_key == "2":
                band_prob = float(features.get("away_band_ms_win_rate", 0.0) or 0.0)
                sample = float(features.get("away_band_ms_sample", 0.0) or 0.0)
            elif pick_key in {"X", "0"}:
                home_draw = float(features.get("home_band_ms_draw_rate", 0.0) or 0.0)
                away_draw = float(features.get("away_band_ms_draw_rate", 0.0) or 0.0)
                band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw)
                sample = max(
                    float(features.get("home_band_ms_sample", 0.0) or 0.0),
                    float(features.get("away_band_ms_sample", 0.0) or 0.0),
                )
        elif market_key == "DC":
            dc_key = pick_key.replace("-", "").lower()
            band_prob = float(features.get(f"band_dc_{dc_key}_rate", 0.0) or 0.0)
            sample = float(features.get(f"band_dc_{dc_key}_sample", 0.0) or 0.0)
        elif market_key in {"OU15", "OU25", "OU35"}:
            suffix = {"OU15": "ou15", "OU25": "ou25", "OU35": "ou35"}[market_key]
            rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate"
            band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0)
            sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0)
        elif market_key == "BTTS":
            is_yes = "VAR" in pick_key or "YES" in pick_key or pick_key == "Y"
            band_prob = float(features.get(f"band_btts_{'yes' if is_yes else 'no'}_rate", 0.0) or 0.0)
            sample = float(features.get("band_btts_sample", 0.0) or 0.0)
        elif market_key == "HT":
            if pick_key == "1":
                band_prob = float(features.get("home_band_ht_win_rate", 0.0) or 0.0)
                sample = float(features.get("home_band_ht_sample", 0.0) or 0.0)
            elif pick_key == "2":
                band_prob = float(features.get("away_band_ht_win_rate", 0.0) or 0.0)
                sample = float(features.get("away_band_ht_sample", 0.0) or 0.0)
            elif pick_key in {"X", "0"}:
                home_draw = float(features.get("home_band_ht_draw_rate", 0.0) or 0.0)
                away_draw = float(features.get("away_band_ht_draw_rate", 0.0) or 0.0)
                band_prob = (home_draw + away_draw) / 2.0 if home_draw and away_draw else max(home_draw, away_draw)
                sample = max(
                    float(features.get("home_band_ht_sample", 0.0) or 0.0),
                    float(features.get("away_band_ht_sample", 0.0) or 0.0),
                )
        elif market_key in {"HT_OU05", "HT_OU15"}:
            suffix = "ht_ou05" if market_key == "HT_OU05" else "ht_ou15"
            rate_key = "over_rate" if self._pick_is_over(pick_key) else "under_rate"
            band_prob = float(features.get(f"band_{suffix}_{rate_key}", 0.0) or 0.0)
            sample = float(features.get(f"band_{suffix}_sample", 0.0) or 0.0)

        band_edge = band_prob - implied_prob if band_prob > 0.0 else 0.0
        required_sample = float(self.odds_band_min_sample.get(market_key, 0.0))
        required_edge = float(self.odds_band_min_edge.get(market_key, 0.0))
        available = band_prob > 0.0 and sample >= required_sample
        aligned = available and band_edge >= required_edge

        reason = "odds_band_confirms_value"
        if required_sample > 0.0 and sample < required_sample:
            reason = "odds_band_sample_too_low"
        elif band_prob <= 0.0:
            reason = "odds_band_missing_probability"
        elif band_edge < required_edge:
            reason = f"odds_band_no_value_{band_edge:+.3f}"

        return {
            "required": market_key in self.odds_band_min_sample,
            "available": available,
            "band_prob": band_prob,
            "band_sample": sample,
            "band_edge": band_edge,
            "aligned": aligned,
            "reason": reason,
        }

    @staticmethod
    def _normalize_pick_token(pick: str) -> str:
        return (
            str(pick or "")
            .strip()
            .upper()
            .replace("İ", "I")
            .replace("Ü", "U")
            .replace("Ş", "S")
            .replace("Ğ", "G")
            .replace("Ö", "O")
            .replace("Ç", "C")
        )

    @staticmethod
    def _pick_is_over(pick_key: str) -> bool:
        return "UST" in pick_key or "OVER" in pick_key

    @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]:
        """
        Produces an explainable surprise profile.

        Each factor pushes the base score and contributes:
          - a human-readable Turkish reason
          - a `breakdown` entry with code, points, label
        """
        BASE_SCORE = 22.0
        breakdown: List[Dict[str, Any]] = []
        reasons: List[str] = []
        score = BASE_SCORE

        def add(code: str, points: float, label: str) -> None:
            nonlocal score
            score += points
            reasons.append(label)
            breakdown.append({"code": code, "points": round(points, 1), "label": label})

        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:
            add("balanced_match_risk", 18.0, "Takımlar birbirine çok yakın — sonuç kırılabilir")
        if ms_draw >= 0.30:
            add("draw_probability_elevated", 14.0, f"Beraberlik olasılığı yüksek (%{ms_draw*100:.0f})")
        if total_xg >= 3.25:
            add("high_total_goal_volatility", 10.0, f"Toplam gol beklentisi yüksek (xG {total_xg:.1f}) — açık skor riski")
        if btts_yes >= 0.68:
            add("mutual_goal_pressure", 8.0, f"Karşılıklı gol baskısı (%{btts_yes*100:.0f})")
        if over35 >= 0.52:
            add("late_goal_swing_risk", 8.0, "Geç gol/skor değişimi riski")

        # Odds-based traps (favorite odds trap from UpsetEngineV2 logic)
        ms_h_odd = self._safe_float((data.odds_data or {}).get("ms_h"), 0.0)
        ms_a_odd = self._safe_float((data.odds_data or {}).get("ms_a"), 0.0)
        ms_d_odd = self._safe_float((data.odds_data or {}).get("ms_d"), 0.0)
        favorite_side = None
        favorite_odd = 0.0
        if ms_h_odd > 1.01 and ms_a_odd > 1.01:
            if ms_h_odd <= ms_a_odd:
                favorite_side, favorite_odd = "home", ms_h_odd
            else:
                favorite_side, favorite_odd = "away", ms_a_odd

        # Favorite odds trap (1.40-1.60 historically %33+ surprise rate)
        if 1.40 <= favorite_odd < 1.60:
            add(
                "favorite_odds_trap",
                12.0,
                f"Favori oranı tuzak aralığında ({favorite_odd:.2f}) — tarihsel sürpriz oranı %30+",
            )
        elif 1.20 <= favorite_odd < 1.30:
            add(
                "low_odds_trap_suspicion",
                6.0,
                f"Favori oranı çok düşük ({favorite_odd:.2f}) — piyasa aşırı güveniyor olabilir",
            )

        # Bookmaker margin
        if ms_h_odd > 1.01 and ms_a_odd > 1.01 and ms_d_odd > 1.01:
            margin = (1 / ms_h_odd + 1 / ms_d_odd + 1 / ms_a_odd) - 1
            if margin > 0.20:
                add(
                    "bookmaker_margin_high",
                    10.0,
                    f"Bookmaker marjı çok yüksek (%{margin*100:.1f}) — bahisçi risk görüyor",
                )
            elif margin > 0.18:
                add(
                    "bookmaker_margin_elevated",
                    6.0,
                    f"Bookmaker marjı yüksek (%{margin*100:.1f})",
                )

        # Away favorite carries inherent extra risk
        if favorite_side == "away" and favorite_odd > 0:
            add(
                "away_favorite_extra_risk",
                6.0,
                "Deplasman favorisi — atmosfer ve seyahat ek risk yaratır",
            )

        if data.lineup_source == "probable_xi":
            add("lineup_probable_not_confirmed", 8.0, "Kadrolar tahmini — kesinleşmemiş")
        if data.lineup_source == "none":
            add("lineup_unavailable", 12.0, "Kadro bilgisi yok — analiz güvenilirliği düştü")
        if not data.referee_name:
            add("missing_referee", 6.0, "Hakem atanmamış — disiplin/avantaj sinyali eksik")

        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:
                add("live_match_open_state", 18.0, f"Maç şu an açık skorlu ({current_goals} gol) — pre-match tahminler riskli")
            elif current_goals >= 2:
                add("live_match_active_state", 10.0, f"Maç canlı ve hareketli ({current_goals} gol)")

            # Live underdog leading (pre-match favorite is losing)
            cur_home = int(data.current_score_home or 0)
            cur_away = int(data.current_score_away or 0)
            if favorite_side == "home" and cur_away > cur_home:
                add(
                    "live_underdog_leading",
                    20.0,
                    "Canlı: deplasman önde, pre-match ev sahibi favorisiydi — sürpriz GERÇEKLEŞİYOR",
                )
            elif favorite_side == "away" and cur_home > cur_away:
                add(
                    "live_underdog_leading",
                    20.0,
                    "Canlı: ev sahibi önde, pre-match deplasman favorisiydi — sürpriz GERÇEKLEŞİYOR",
                )

        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."

        # Deduplicate reasons by text while preserving order
        deduped_reasons = list(dict.fromkeys(reasons))[:8]
        # Same dedup logic for breakdown (by code)
        seen_codes: Set[str] = set()
        deduped_breakdown: List[Dict[str, Any]] = []
        for entry in breakdown:
            if entry["code"] in seen_codes:
                continue
            seen_codes.add(entry["code"])
            deduped_breakdown.append(entry)

        return {
            "score": round(score, 1),
            "comment": comment,
            "reasons": deduped_reasons,
            "breakdown": deduped_breakdown[:10],
            "base_score": BASE_SCORE,
        }

    @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

    # ── V35 market-anchored calibration ────────────────────────────────
    # Maps a board pick label -> the probs key it refers to, so the displayed
    # confidence can be set to the EXISTING pick's now-calibrated probability.
    _ANCHOR_PICK_KEY: Dict[str, Dict[str, str]] = {
        "MS":      {"1": "1", "X": "X", "0": "X", "2": "2"},
        "HT":      {"1": "1", "X": "X", "0": "X", "2": "2"},
        "DC":      {"1X": "1X", "X2": "X2", "12": "12",
                    "1-X": "1X", "X-2": "X2", "1-2": "12"},
        "OU15":    {"Üst": "over", "Alt": "under", "Over": "over", "Under": "under"},
        "OU25":    {"Üst": "over", "Alt": "under", "Over": "over", "Under": "under"},
        "OU35":    {"Üst": "over", "Alt": "under", "Over": "over", "Under": "under"},
        "HT_OU05": {"Üst": "over", "Alt": "under", "Over": "over", "Under": "under"},
        "HT_OU15": {"Üst": "over", "Alt": "under", "Over": "over", "Under": "under"},
        "BTTS":    {"KG Var": "yes", "KG Yok": "no", "Var": "yes", "Yok": "no",
                    "Yes": "yes", "No": "no"},
        "OE":      {"Tek": "odd", "Çift": "even", "Odd": "odd", "Even": "even"},
    }

    def _set_board(
        self,
        market_board: Dict[str, Any],
        market: str,
        probs: Dict[str, float],
    ) -> None:
        """Overwrite one board entry's probs with calibrated values and refresh
        its confidence to the EXISTING pick's now-calibrated probability.

        We recalibrate the NUMBERS, not the pick selection — showing the engine's
        pick alongside its honest probability. Falls back to the most-likely
        outcome only when the pick can't be mapped."""
        entry = market_board.get(market)
        if not isinstance(entry, dict):
            return
        rounded = {k: round(float(v), 4) for k, v in probs.items()}
        if not rounded:
            return
        entry["probs"] = rounded
        pick = str(entry.get("pick") or "")
        key = self._ANCHOR_PICK_KEY.get(market, {}).get(pick)
        if key is None or key not in rounded:
            key = max(rounded, key=rounded.get)
        entry["confidence"] = round(rounded[key] * 100.0, 1)
        entry["calibration_source"] = "market_anchor_v35"

    def _apply_market_anchor(
        self,
        market_board: Dict[str, Any],
        data: MatchData,
    ) -> Dict[str, Any]:
        """Anchor DISPLAYED per-market probabilities to the de-vigged market
        price (+ proven home-favourite correction for MS, and DC derived from
        it for internal consistency).

        Only markets with REAL odds are rewritten — `devig` returns None for any
        missing/placeholder leg, so no-odds markets are left untouched (and are
        already dropped upstream per the product rule: never show fabricated
        numbers for a match without odds). Toggle off with env MARKET_ANCHOR_CAL=0.
        """
        if os.environ.get("MARKET_ANCHOR_CAL", "1") == "0":
            return market_board
        if not isinstance(market_board, dict) or not market_board:
            return market_board
        odds = getattr(data, "odds_data", None) or {}

        def real(key: str) -> Optional[float]:
            val = self._real_market_odds(odds, key)
            return val if val > 1.01 else None

        # MS (3-way) + favourite corrections; DC derived from the same vector
        ms = devig([real("ms_h"), real("ms_d"), real("ms_a")])
        if ms is not None:
            p1, px, p2 = apply_corrections(*ms)
            if "MS" in market_board:
                self._set_board(market_board, "MS", {"1": p1, "X": px, "2": p2})
            if "DC" in market_board:
                self._set_board(
                    market_board, "DC",
                    {"1X": p1 + px, "X2": px + p2, "12": p1 + p2},
                )

        # HT (3-way)
        ht = devig([real("ht_h"), real("ht_d"), real("ht_a")])
        if ht is not None and "HT" in market_board:
            self._set_board(market_board, "HT", {"1": ht[0], "X": ht[1], "2": ht[2]})

        # 2-way markets
        for mk, ko, ku, lo, lu in (
            ("OU15", "ou15_o", "ou15_u", "over", "under"),
            ("OU25", "ou25_o", "ou25_u", "over", "under"),
            ("OU35", "ou35_o", "ou35_u", "over", "under"),
            ("BTTS", "btts_y", "btts_n", "yes", "no"),
            ("OE", "oe_odd", "oe_even", "odd", "even"),
            ("HT_OU05", "ht_ou05_o", "ht_ou05_u", "over", "under"),
            ("HT_OU15", "ht_ou15_o", "ht_ou15_u", "over", "under"),
        ):
            if mk not in market_board:
                continue
            pair = devig([real(ko), real(ku)])
            if pair is not None:
                self._set_board(market_board, mk, {lo: pair[0], lu: pair[1]})

        return market_board

    def _anchored_prob_for(
        self,
        market_board: Dict[str, Any],
        market: str,
        pick: Any,
    ) -> Optional[float]:
        """Look up a pick's calibrated probability from the anchored board.

        Returns None unless the market was actually anchored (real odds) and the
        pick maps to a known outcome — so no-odds picks are never touched."""
        entry = market_board.get(str(market or ""))
        if not isinstance(entry, dict):
            return None
        if entry.get("calibration_source") != "market_anchor_v35":
            return None
        probs = entry.get("probs") or {}
        key = self._ANCHOR_PICK_KEY.get(str(market or ""), {}).get(str(pick or ""))
        if key is None or key not in probs:
            return None
        try:
            return float(probs[key])
        except (TypeError, ValueError):
            return None

    def _recalibrate_pick_display(
        self,
        obj: Optional[Dict[str, Any]],
        market_board: Dict[str, Any],
    ) -> None:
        """Rewrite ONE pick object's displayed confidence/edge fields so they are
        consistent with the calibrated (de-vigged market) probability.

        Fixes Güven Skoru (`calibrated_confidence`/`unified_score`), Güven Aralığı
        (`confidence_interval` recentred on the calibrated confidence), and the
        value card's Model%/Teorik-avantaj (`model_probability`/`ev_edge`/`edge`,
        recomputed honestly against the real price → the vig shows as it truly is,
        no fabricated positive edge). Selection/gates/stake are left untouched."""
        if not isinstance(obj, dict):
            return
        p = self._anchored_prob_for(market_board, obj.get("market"), obj.get("pick"))
        if p is None:
            return
        try:
            odds = float(obj.get("odds") or 0.0)
        except (TypeError, ValueError):
            odds = 0.0
        implied = (1.0 / odds) if odds > 1.0 else 0.0
        conf = round(p * 100.0, 1)
        ev = round(p * odds - 1.0, 4) if odds > 1.0 else 0.0
        obj["calibrated_probability"] = round(p, 4)
        obj["model_probability"] = round(p, 4)
        obj["calibrated_confidence"] = conf
        obj["unified_score"] = conf
        obj["implied_prob"] = round(implied, 4)
        obj["model_edge"] = round(p - implied, 4) if implied > 0.0 else 0.0
        obj["ev_edge"] = ev
        obj["edge"] = ev
        # Recentre the confidence interval on the calibrated confidence, keeping a
        # sensible width (preserve the engine's width when present).
        width = 16.0
        ci = obj.get("confidence_interval")
        if isinstance(ci, dict) and ci.get("lower") is not None and ci.get("upper") is not None:
            try:
                width = max(6.0, float(ci["upper"]) - float(ci["lower"]))
            except (TypeError, ValueError):
                width = 16.0
        half = width / 2.0
        lower = round(max(0.0, conf - half), 1)
        upper = round(min(100.0, conf + half), 1)
        band = "HIGH" if conf >= 60.0 else "MEDIUM" if conf >= 42.0 else "LOW"
        obj["confidence_interval"] = {
            "band": band,
            "lower": lower,
            "upper": upper,
            "width": round(upper - lower, 1),
            "threshold_met": conf >= 50.0,
        }
        obj["confidence_band"] = band
        obj["calibration_source"] = "market_anchor_v35"

    def _apply_anchor_to_picks(
        self,
        market_board: Dict[str, Any],
        main_pick: Optional[Dict[str, Any]],
        value_pick: Optional[Dict[str, Any]],
        aggressive_pick: Optional[Dict[str, Any]],
        supporting: Optional[List[Dict[str, Any]]],
        bet_summary: Optional[List[Dict[str, Any]]],
    ) -> None:
        """Make every DISPLAYED pick object consistent with the anchored board.
        Toggle off with env MARKET_ANCHOR_CAL=0."""
        if os.environ.get("MARKET_ANCHOR_CAL", "1") == "0":
            return
        for obj in (main_pick, value_pick, aggressive_pick):
            self._recalibrate_pick_display(obj, market_board)
        for obj in list(supporting or []):
            self._recalibrate_pick_display(obj, market_board)
        for obj in list(bet_summary or []):
            self._recalibrate_pick_display(obj, market_board)

    def _build_calibrated_score(
        self,
        market_board: Dict[str, Any],
    ) -> Optional[Dict[str, Any]]:
        """V36: score card derived from the anchored MS + OU25 probabilities.

        Returns {"score_prediction": {...}, "scenario_top5": [...]} or None when
        the needed markets weren't anchored (no real odds) — in which case the
        caller keeps the model's own score output. Same kill-switch as V35."""
        if os.environ.get("MARKET_ANCHOR_CAL", "1") == "0":
            return None
        ms = market_board.get("MS") or {}
        ou = market_board.get("OU25") or {}
        if (
            ms.get("calibration_source") != "market_anchor_v35"
            or ou.get("calibration_source") != "market_anchor_v35"
        ):
            return None
        try:
            p1 = float(ms["probs"]["1"])
            px = float(ms["probs"]["X"])
            p2 = float(ms["probs"]["2"])
            p_over = float(ou["probs"]["over"])
        except (KeyError, TypeError, ValueError):
            return None

        ht_probs = None
        ht = market_board.get("HT") or {}
        if ht.get("calibration_source") == "market_anchor_v35":
            try:
                ht_probs = (
                    float(ht["probs"]["1"]),
                    float(ht["probs"]["X"]),
                    float(ht["probs"]["2"]),
                )
            except (KeyError, TypeError, ValueError):
                ht_probs = None

        try:
            pkg = build_calibrated_score_package(p1, px, p2, p_over, ht_probs=ht_probs)
        except (ValueError, ZeroDivisionError, OverflowError):
            return None
        return {
            "score_prediction": {
                "ft": pkg["ft"],
                "ht": pkg["ht"],
                "xg_home": pkg["xg_home"],
                "xg_away": pkg["xg_away"],
                "xg_total": pkg["xg_total"],
                "ht_top3": pkg["ht_top"],
                "calibration_source": pkg["calibration_source"],
            },
            "scenario_top5": pkg["scenario_top5"],
        }

    def _build_live_projection(
        self,
        market_board: Dict[str, Any],
        data: MatchData,
    ) -> Optional[Dict[str, Any]]:
        """V38: score/minute-conditioned live projection from the anchored
        probabilities. None unless the match is live, both MS and OU25 were
        anchored (real odds) and a minute estimate exists. Same kill-switch."""
        if os.environ.get("MARKET_ANCHOR_CAL", "1") == "0":
            return None
        if not self._is_live_match(data):
            return None
        ms = market_board.get("MS") or {}
        ou = market_board.get("OU25") or {}
        if (
            ms.get("calibration_source") != "market_anchor_v35"
            or ou.get("calibration_source") != "market_anchor_v35"
        ):
            return None
        minute = estimate_minute(
            getattr(data, "match_date_ms", None), int(time.time() * 1000)
        )
        if minute is None:
            return None
        try:
            return build_live_projection(
                float(ms["probs"]["1"]),
                float(ms["probs"]["X"]),
                float(ms["probs"]["2"]),
                float(ou["probs"]["over"]),
                int(data.current_score_home or 0),
                int(data.current_score_away or 0),
                minute,
            )
        except (KeyError, TypeError, ValueError, ZeroDivisionError, OverflowError):
            return None

    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)
        pick_str = str(row.get("pick") or "")

        # Trained isotonic calibrator (preferred) — falls back to multiplier if not trained.
        # IMPORTANT: trainer was fed (raw_confidence/100, actual). Orchestrator must feed
        # the same shape — using `prob` (which may differ from raw_conf/100 due to upstream
        # confidence boosting) would give the calibrator an out-of-distribution input.
        calibrator = get_calibrator()
        cal_key = self._calibrator_key(market, pick_str)
        if cal_key and cal_key in calibrator.calibrators:
            cal_input = max(0.001, min(0.999, raw_conf / 100.0))
            cal_prob = calibrator.calibrate(cal_key, cal_input, odds_val=odd if odd > 1.0 else None)
            # V30: Trust-based blending — some calibrators inflate probabilities.
            # Blend isotonic output with raw model based on calibrator accuracy.
            trust = POST_CAL_TRUST.get(cal_key, 0.5)
            cal_prob = trust * cal_prob + (1.0 - trust) * cal_input
            calibrated_conf = max(1.0, min(99.0, cal_prob * 100.0))
        else:
            # V31b: Fallback for markets WITHOUT isotonic calibrator.
            # Old approach used aggressive multipliers (0.58-0.85) causing
            # massive deflation: HT_OU15 -40.5%, HT_OU05 -25.2%, OE -18.3%.
            # New approach: mild damping (0.92) acknowledges slight model
            # overconfidence without destroying probability signal.
            # The tier system (V31b) is the real profitability gatekeeper.
            calibrated_conf = max(1.0, min(99.0, raw_conf * 0.92))

        # ── FINAL-OUTPUT RECALIBRATION (V31e) ──────────────────────────
        # Last-step per-market map: "system says X% -> reality is Y%". ONLY
        # badly-miscalibrated markets carry a map (fit-ECE >= 5: OU15, OU35,
        # HT_OU05, HT_OU15). MS and every already-good market pass through
        # UNCHANGED -> guaranteed no regression. Out-of-sample proven (e.g.
        # HT_OU15 ECE 29.2->0.8) and identity-safe for MS (1.1->1.3).
        # This adjusts ONLY the displayed confidence so users see honest
        # probabilities; all analysis below (probabilities, edges, vetoes,
        # tiers, bands) is preserved, and the pre-recal value is kept for audit.
        pre_recal_conf = calibrated_conf
        calibrated_conf = get_final_recalibrator().recalibrate_conf(market, calibrated_conf)
        min_conf = self.market_min_conf.get(market, 55.0)

        implied_prob = (1.0 / odd) if odd > 1.0 else 0.0
        band_verdict = self._odds_band_verdict(data, market, pick_str, implied_prob)

        # ── 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,
        )
        # V33: Removed probability deflation. Deflating probability breaks normalization 
        # (probs no longer sum to 1) and mathematically guarantees negative EV edge.
        # Data quality and confidence penalties are already applied to play_score.
        model_calibrated_prob = prob
        band_prob = float(band_verdict.get("band_prob", 0.0) or 0.0)
        if bool(band_verdict.get("available")):
            calibrated_probability = (
                (model_calibrated_prob * 0.45)
                + (band_prob * 0.35)
                + (implied_prob * 0.20)
            )
        elif implied_prob > 0.0:
            calibrated_probability = (model_calibrated_prob * 0.65) + (implied_prob * 0.35)
        else:
            calibrated_probability = model_calibrated_prob
        calibrated_probability = max(0.0, min(0.99, calibrated_probability))
        model_edge = model_calibrated_prob - implied_prob if implied_prob > 0 else 0.0
        ev_edge = (calibrated_probability * odd) - 1.0 if odd > 1.0 else 0.0
        simple_edge = calibrated_probability - implied_prob if implied_prob > 0 else 0.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)

        # ── 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
        
        # V29b: Permissive upstream — let betting_brain's tiered system do the real filtering.
        # Old threshold (ev>=0.008 OR conf>=55) let everything through AND bypassed brain vetoes.
        # New approach: let most picks through market_board, but brain's MARKET_ODDS_TIERS
        # + hard vetoes (neg EV, muted, low reliability) handle the intelligent filtering.
        is_value_sniper = calibrated_conf >= 45.0

        if calibrated_conf < min_conf:
            if not is_value_sniper:
                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")
        # V34: Added confidence bonus — high raw model probability gets a boost
        # This prevents over-penalization when edge is near-zero but model is confident
        raw_top_prob = float(row.get("probability", 0.0))
        confidence_bonus = 0.0
        if raw_top_prob >= 0.65:
            confidence_bonus = 15.0
        elif raw_top_prob >= 0.55:
            confidence_bonus = 10.0
        elif raw_top_prob >= 0.45:
            confidence_bonus = 5.0
        base_score = calibrated_conf + (simple_edge * 100.0 * edge_multiplier) + confidence_bonus
        play_score = max(
            0.0,
            min(100.0, base_score - risk_penalty - quality_penalty - lineup_penalty),
        )
        # V34: odds_band gate — only hard-block when band data is AVAILABLE and aligned=False
        # When band data is sparse (available=False), skip alignment check entirely
        band_available = bool(band_verdict.get("available", False))
        if band_available and bool(band_verdict.get("required")) and not bool(band_verdict.get("aligned")):
            if not is_value_sniper:
                playable = False
                reasons.append(str(band_verdict.get("reason") or "odds_band_not_aligned"))
        elif not band_available and bool(band_verdict.get("required")):
            # Sparse data — log but don't block
            reasons.append("odds_band_data_sparse_skipped")
        # V34: REMOVED model_not_above_market gate entirely
        # V25 model is odds-informed BY DESIGN → model output ≈ market-implied probability
        # Requiring model > market is mathematically impossible with this architecture
        # The negative_model_edge gate below still catches truly anti-value picks
        # V34: negative edge threshold relaxed — odds-aware model's edge is naturally near zero
        # Reliable league: -0.08, unreliable: up to -0.15
        # Only blocks truly anti-value picks (model significantly below market)
        neg_edge_threshold = -0.08 - (1.0 - odds_rel) * 0.07
        if odd > 1.0 and simple_edge < neg_edge_threshold:
            if not is_value_sniper:
                playable = False
                reasons.append(f"negative_model_edge_{simple_edge:+.3f}")
        # V34: Added value_sniper bypass — was missing before, causing hard blocks
        if odd > 1.0 and ev_edge < min_edge:
            if not is_value_sniper:
                playable = False
                reasons.append(f"below_market_edge_threshold_{ev_edge:+.3f}")
        if play_score < min_play_score:
            if not is_value_sniper:
                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(calibrated_probability, odd)
            reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_A")
        elif ev_edge > 0.05:
            grade = "B"
            stake_units = self._kelly_stake(calibrated_probability, odd)
            reasons.append(f"ev_edge_{ev_edge:+.1%}_grade_B")
        elif ev_edge > 0.02:
            grade = "C"
            stake_units = self._kelly_stake(calibrated_probability, 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")

        # ── V30: Birleşik Güven Skoru (BGS) ────────────────────────────
        # A single, honest metric for users: quality-adjusted win probability.
        # Combines calibrated probability with data quality signals.
        # Correlation analysis: model_gap r=-0.12, trap negative, reliability weak positive.
        bgs = calibrated_conf  # POST_CAL_TRUST corrected base
        model_gap = prob - implied_prob if implied_prob > 0 else 0.0
        # Penalty when model overestimates vs market (r=-0.12 correlation)
        if model_gap > 0.05:
            bgs -= 8.0
        elif model_gap > 0.0:
            bgs -= 3.0
        # Trap market detection: implied prob significantly above historical band rate
        is_trap_signal = False
        if band_available and band_prob > 0 and implied_prob > 0:
            is_trap_signal = (implied_prob - band_prob) > 0.10
        if is_trap_signal:
            bgs -= 7.0
        # League reliability adjustment (±2)
        bgs += (odds_rel - 0.50) * 4.0
        # Band alignment
        if band_available:
            if bool(band_verdict.get("aligned")):
                bgs += 2.0
            else:
                bgs -= 3.0
        # BGS label for frontend
        bgs = max(1.0, min(99.0, bgs))
        if bgs >= 70:
            bgs_label = "very_reliable"
        elif bgs >= 55:
            bgs_label = "reliable"
        elif bgs >= 40:
            bgs_label = "moderate"
        else:
            bgs_label = "low"

        out = dict(row)
        out.update(
            {
                "raw_confidence": round(raw_conf, 1),
                "calibrated_confidence": round(calibrated_conf, 1),
                "calibrated_confidence_pre_recal": round(pre_recal_conf, 1),
                "unified_score": round(bgs, 1),
                "unified_score_label": bgs_label,
                "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),
                "model_probability": round(prob, 4),
                "model_edge": round(model_edge, 4),
                "calibrated_probability": round(calibrated_probability, 4),
                "implied_prob": round(implied_prob, 4),
                "ev_edge": round(ev_edge, 4),
                "is_value_sniper": is_value_sniper,
                "odds_band_probability": round(float(band_verdict.get("band_prob", 0.0) or 0.0), 4),
                "odds_band_sample": round(float(band_verdict.get("band_sample", 0.0) or 0.0), 1),
                "odds_band_edge": round(float(band_verdict.get("band_edge", 0.0) or 0.0), 4),
                "odds_band_aligned": bool(band_verdict.get("aligned")),
                "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")),
            "unified_score": row.get("unified_score", row.get("calibrated_confidence", 0.0)),
            "unified_score_label": row.get("unified_score_label", "moderate"),
            "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)),
            "is_value_sniper": bool(row.get("is_value_sniper")),
            "model_probability": row.get("model_probability", row.get("probability", 0.0)),
            "model_edge": row.get("model_edge", 0.0),
            "calibrated_probability": row.get("calibrated_probability", row.get("probability", 0.0)),
            "implied_prob": row.get("implied_prob", 0.0),
            "odds_band_probability": row.get("odds_band_probability", 0.0),
            "odds_band_sample": row.get("odds_band_sample", 0.0),
            "odds_band_edge": row.get("odds_band_edge", 0.0),
            "odds_band_aligned": bool(row.get("odds_band_aligned")),
            "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")
        if data.source_table == "live_matches":
            flags.append("live_match_pre_match_features")
        feature_source = str(getattr(data, "feature_source", "") or "")
        if feature_source == "live_prematch_enrichment":
            flags.append("ai_features_inferred_from_history")

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

        # When statistical features are inferred (not from pre-computed table),
        # ELO/form/H2H data reliability is unknown — cap quality at MEDIUM.
        if feature_source == "live_prematch_enrichment":
            total_score = min(total_score, 0.74)

        if total_score >= 0.8:
            label = "HIGH"
        elif total_score >= 0.55:
            label = "MEDIUM"
        else:
            label = "LOW"
        if label == "HIGH" and (
            data.lineup_source == "probable_xi" or not data.referee_name
            or feature_source == "live_prematch_enrichment"
        ):
            label = "MEDIUM"

        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),
            "feature_source": feature_source or "unknown",
            "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
