"""
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 pickle
import pandas as pd
import numpy as np
from collections import defaultdict
from typing import Any, Dict, List, Optional, Set, Tuple, overload

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:
        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
    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 utils.league_confidence import load_league_confidence
from utils.national_leagues import load_national_leagues
from config.config_loader import build_threshold_dict, get_threshold_default, get_config
from models.calibration import get_calibrator

# Refactor note (post-V28):
# The original 5786-line monolith was split into focused mixin modules under
# services/orchestrator/. This file is the slim composition layer plus the
# main entry points (__init__, analyze_match, predictor lifecycle).
from services.orchestrator import (
    DataLoaderMixin,
    FeatureBuilderMixin,
    PredictionMixin,
    BasketballMixin,
    UpperBrainMixin,
    HtmsMixin,
    CouponMixin,
    ReversalMixin,
    MarketBoardMixin,
    UtilsMixin,
)


# MRO note: the original file contained two `_safe_float` definitions where
# the second silently overrode the first per Python class-scope rules.
# Both are preserved in UtilsMixin in their original order, so the override
# behaviour is identical.
class SingleMatchOrchestrator(
    DataLoaderMixin,
    FeatureBuilderMixin,
    PredictionMixin,
    BasketballMixin,
    UpperBrainMixin,
    HtmsMixin,
    CouponMixin,
    ReversalMixin,
    MarketBoardMixin,
    UtilsMixin,
):
    """Main V20+ application service used by API endpoints."""
    _cfg = get_config()
    DEFAULT_MS_H: float = float(_cfg.get('model_ensemble.default_ms_odds.home', 2.65))
    DEFAULT_MS_D: float = float(_cfg.get('model_ensemble.default_ms_odds.draw', 3.20))
    DEFAULT_MS_A: float = float(_cfg.get('model_ensemble.default_ms_odds.away', 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._v27: Optional[V27Predictor] = 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.league_confidence = load_league_confidence()
        self.national_leagues = load_national_leagues()
        self.enrichment = FeatureEnrichmentService()
        self.odds_band_analyzer = OddsBandAnalyzer()
        # ── Market Thresholds (loaded from config/market_thresholds.json) ──
        # All values are centralized in a single JSON file for easy tuning
        # without code changes. See config/market_thresholds.json for details.
        self.market_calibration: Dict[str, float] = build_threshold_dict("calibration")
        self.market_min_conf: Dict[str, float] = build_threshold_dict("min_conf")
        self.market_min_play_score: Dict[str, float] = build_threshold_dict("min_play_score")
        self.market_min_edge: Dict[str, float] = build_threshold_dict("min_edge")
        self.odds_band_min_sample: Dict[str, float] = build_threshold_dict("odds_band_min_sample")
        self.odds_band_min_edge: Dict[str, float] = build_threshold_dict("odds_band_min_edge")

    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 not hasattr(self, "v26_shadow_engine") or self.v26_shadow_engine 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 V27Predictor is None:
            return None
        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 _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 ────────────────────────────
        # Force all matches (live and finished) into pre-match state so the 
        # engine purely predicts based on pre-match odds and context, ignoring
        # current live scores and preventing live state penalties.
        _status_upper = str(data.status or "").upper()
        if _status_upper not in {"NS", "POSTPONED", "CANC", "ABD"}:
            data.status = "NS"
            data.state = "preGame"
            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)
        # ── DEBUG: log critical feature values to diagnose absurd predictions ──
        _debug_keys = [
            'home_overall_elo', 'away_overall_elo', 'elo_diff',
            'odds_ms_h', 'odds_ms_d', 'odds_ms_a',
            'implied_home', 'implied_draw', 'implied_away',
            'home_goals_avg', 'away_goals_avg',
            'home_conceded_avg', 'away_conceded_avg',
            'home_momentum_score', 'away_momentum_score',
            'home_squad_quality', 'away_squad_quality',
        ]
        print("── [DEBUG] Feature values for model input ──")
        for _dk in _debug_keys:
            print(f"  {_dk}: {features.get(_dk, 'MISSING')}")
        print(f"  Total features: {len(features)}")
        print("── [DEBUG END] ──")
        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 — now with V27 BTTS model
                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']

                # V27 BTTS model prediction (if available)
                v27_btts = v27_preds.get('btts')
                v27_btts_yes = (v27_btts or {}).get('yes', 0) if v27_btts else 0

                if v27_btts_yes > 0:
                    btts_combined = (v27_btts_yes + btts_band_rate) / 2.0
                else:
                    btts_combined = btts_band_rate
                btts_edge = btts_combined - btts_implied
                btts_band_confirms = btts_band_rate > btts_implied
                btts_v27_confirms = v27_btts_yes > btts_implied if v27_btts_yes > 0 else False
                btts_conf_count = sum([btts_v27_confirms, btts_band_confirms])

                # BTTS divergence (V25 vs V27)
                v25_btts_probs = {
                    'no': 1.0 - prediction.btts_yes_prob,
                    'yes': prediction.btts_yes_prob,
                }
                btts_divergence = compute_divergence(v25_btts_probs, v27_btts) if v27_btts else {}
                btts_odds = {
                    'yes': float((data.odds_data or {}).get('btts_y', 0)),
                    'no': float((data.odds_data or {}).get('btts_n', 0)),
                }
                btts_value_edge = compute_value_edge(
                    v25_btts_probs, v27_btts, btts_odds,
                ) if v27_btts else {}

                # DC divergence (derived from V27 MS probs)
                v27_dc = v27_preds.get('dc')
                dc_divergence = {}
                dc_value_edge = {}
                if v27_dc:
                    v25_dc_probs = {
                        '1x': prediction.ms_home_prob + prediction.ms_draw_prob,
                        'x2': prediction.ms_draw_prob + prediction.ms_away_prob,
                        '12': prediction.ms_home_prob + prediction.ms_away_prob,
                    }
                    dc_divergence = compute_divergence(v25_dc_probs, v27_dc)
                    dc_odds = {
                        '1x': float((data.odds_data or {}).get('dc_1x', 0)),
                        'x2': float((data.odds_data or {}).get('dc_x2', 0)),
                        '12': float((data.odds_data or {}).get('dc_12', 0)),
                    }
                    dc_value_edge = compute_value_edge(v25_dc_probs, v27_dc, dc_odds)

                triple_value['btts_yes'] = {
                    'v27_prob': round(v27_btts_yes, 4),
                    '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': btts_conf_count,
                    'is_value': (
                        btts_conf_count >= 2
                        and btts_edge > 0.05
                        and odds_band_btts['sample'] >= 8
                    ) if v27_btts_yes > 0 else (
                        btts_band_confirms
                        and btts_edge > 0.05
                        and odds_band_btts['sample'] >= 8
                    ),
                }

                _odds_data = data.odds_data or {}
                def _band_value(label, band_rate, odds_key, sample):
                    o = float(_odds_data.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 {},
                        "btts": v27_btts or {},
                        "dc": v27_dc or {},
                    },
                    "divergence": {
                        "ms": ms_divergence,
                        "ou25": ou25_divergence,
                        "btts": btts_divergence,
                        "dc": dc_divergence,
                    },
                    "value_edge": {
                        "ms": ms_value,
                        "ou25": ou25_value,
                        "btts": btts_value_edge,
                        "dc": dc_value_edge,
                    },
                    "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.__getitem__)
                    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, data)

        # V35c: the brain rebuilt main/value/supporting/bet_summary AFTER the
        # market anchor ran inside _build_prediction_package — re-stamp the
        # calibrated display fields (Güven/CI/Model%/edge) so they stay
        # consistent, BEFORE the commentary reads the package.
        self._apply_anchor_to_picks(
            base_package.get("market_board") or {},
            base_package.get("main_pick"),
            base_package.get("value_pick"),
            base_package.get("aggressive_pick"),
            base_package.get("supporting_picks"),
            base_package.get("bet_summary"),
        )
        _mp = base_package.get("main_pick")
        _advice = base_package.get("bet_advice")
        if isinstance(_mp, dict) and isinstance(_advice, dict) and _mp.get("confidence_band"):
            _advice["confidence_band"] = _mp["confidence_band"]
        # no fabricated value bets: a value pick must carry measured positive edge
        _vp = base_package.get("value_pick")
        if isinstance(_vp, dict) and float(_vp.get("ev_edge", 0.0) or 0.0) <= 0.0:
            base_package["value_pick"] = None

        # ── Match Commentary: human-readable summary ──────────────
        try:
            base_package["match_commentary"] = generate_match_commentary(base_package)
        except Exception as e:
            print(f"[Commentary] ⚠ Generation failed (non-fatal): {e}")
            base_package["match_commentary"] = None

        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":
            shadow_package["match_commentary"] = base_package.get("match_commentary")
            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


_orchestrator: Optional[SingleMatchOrchestrator] = None


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