"""
Odds-Band Historical Performance Analyzer (V28)
================================================
Uses historical betting odds as a lookup key to analyze how a team
actually performed when priced at similar odds ranges.

Instead of using odds as raw model features (V25 approach), this
module treats odds as a *historical reference* — answering the
question: "When this team was priced at ~1.55, what actually happened?"

This creates powerful calibration features that reveal systematic
market inefficiencies specific to each team.

All methods are fail-safe and return sensible defaults.
"""

from __future__ import annotations

from typing import Any, Dict, Optional, Tuple

from psycopg2.extras import RealDictCursor


# ─── Band Range Configuration ──────────────────────────────────────
def get_band_range(odds: float) -> Tuple[float, float]:
    """
    Calculate the odds band range for historical lookup.
    
    Narrower bands for low odds (strong favorites — more data),
    wider bands for high odds (underdogs — less data).
    """
    if odds <= 0.0:
        return (1.01, 1.50)  # Invalid odds → default band
    if odds <= 1.30:
        margin = 0.05   # Very strong favorite → tight band
    elif odds <= 2.00:
        margin = 0.10   # Normal favorite
    elif odds <= 3.50:
        margin = 0.15   # Draw zone / slight underdog
    else:
        margin = 0.25   # Big underdog → wide band (less data)
    return (max(1.01, odds - margin), odds + margin)


# ─── Minimum sample sizes ──────────────────────────────────────────
MIN_TEAM_SAMPLE = 8    # Min matches for team-level band stats
MIN_LEAGUE_SAMPLE = 15  # Min matches for league-level fallback
MAX_LOOKBACK = 50       # Max historical matches to consider


class OddsBandAnalyzer:
    """
    Stateless service — receives a psycopg2 cursor and computes
    odds-band historical features for any team + market combination.
    """

    # Default features when no data is available
    _DEFAULTS: Dict[str, float] = {
        "band_ms_win_rate": 0.33,
        "band_ms_draw_rate": 0.33,
        "band_ms_loss_rate": 0.34,
        "band_ms_sample": 0.0,
        "band_ou25_over_rate": 0.50,
        "band_ou25_sample": 0.0,
        "band_ou15_over_rate": 0.65,
        "band_ou15_sample": 0.0,
        "band_ou35_over_rate": 0.35,
        "band_ou35_sample": 0.0,
        "band_btts_rate": 0.50,
        "band_btts_sample": 0.0,
        "band_ms_value_signal": 0.0,
        "band_ou25_value_signal": 0.0,
        "band_btts_value_signal": 0.0,
    }

    # ─── Public Interface ──────────────────────────────────────────

    def compute_all(
        self,
        cur: RealDictCursor,
        home_team_id: str,
        away_team_id: str,
        league_id: Optional[str],
        odds: Dict[str, float],
        before_ts: int,
        referee_name: Optional[str] = None,
    ) -> Dict[str, float]:
        """
        Compute odds-band features for both home and away teams.
        
        Args:
            cur: psycopg2 RealDictCursor
            home_team_id: Home team ID
            away_team_id: Away team ID
            league_id: League ID for fallback queries
            odds: Current match odds dict (ms_h, ms_d, ms_a, ou25_o, etc.)
            before_ts: Match timestamp (ms) — only look at matches before this
            referee_name: Optional referee name for card profiling
            
        Returns:
            Dict with odds-band features (home_ and away_ prefixed)
        """
        result = {}

        ms_home_odds = float(odds.get("ms_h", 0))
        ms_away_odds = float(odds.get("ms_a", 0))
        ou25_over_odds = float(odds.get("ou25_o", 0))
        ou25_under_odds = float(odds.get("ou25_u", 0))
        ou15_over_odds = float(odds.get("ou15_o", 0))
        ou35_over_odds = float(odds.get("ou35_o", 0))
        btts_yes_odds = float(odds.get("btts_y", 0))

        # ── Home team band analysis ───────────────────────────────
        home_ms = self._compute_ms_band(
            cur, home_team_id, league_id, ms_home_odds,
            is_home=True, before_ts=before_ts,
        )
        for key, val in home_ms.items():
            result[f"home_{key}"] = val

        # ── Away team band analysis ───────────────────────────────
        away_ms = self._compute_ms_band(
            cur, away_team_id, league_id, ms_away_odds,
            is_home=False, before_ts=before_ts,
        )
        for key, val in away_ms.items():
            result[f"away_{key}"] = val

        # ── Match-level OU/BTTS band analysis ─────────────────────
        ou25_band = self._compute_ou_band(
            cur, home_team_id, away_team_id, league_id,
            ou25_over_odds, line=2.5, before_ts=before_ts,
        )
        for key, val in ou25_band.items():
            result[f"band_ou25_{key}"] = val

        ou15_band = self._compute_ou_band(
            cur, home_team_id, away_team_id, league_id,
            ou15_over_odds, line=1.5, before_ts=before_ts,
        )
        for key, val in ou15_band.items():
            result[f"band_ou15_{key}"] = val

        ou35_band = self._compute_ou_band(
            cur, home_team_id, away_team_id, league_id,
            ou35_over_odds, line=3.5, before_ts=before_ts,
        )
        for key, val in ou35_band.items():
            result[f"band_ou35_{key}"] = val

        btts_band = self._compute_btts_band(
            cur, home_team_id, away_team_id, league_id,
            btts_yes_odds, before_ts=before_ts,
        )
        for key, val in btts_band.items():
            result[f"band_btts_{key}"] = val

        # ── DC (Çifte Şans) band ──────────────────────────────────
        dc_1x_odds = float(odds.get("dc_1x", 0))
        dc_x2_odds = float(odds.get("dc_x2", 0))
        dc_12_odds = float(odds.get("dc_12", 0))
        dc_band = self._compute_dc_band(
            cur, home_team_id, away_team_id, league_id,
            dc_1x_odds, dc_x2_odds, dc_12_odds, before_ts=before_ts,
        )
        for key, val in dc_band.items():
            result[f"band_dc_{key}"] = val

        # ── HT (İlk Yarı Sonucu) band ────────────────────────────
        ht_h_odds = float(odds.get("ht_h", 0))
        ht_a_odds = float(odds.get("ht_a", 0))
        home_ht = self._compute_ht_band(
            cur, home_team_id, league_id, ht_h_odds,
            is_home=True, before_ts=before_ts,
        )
        for key, val in home_ht.items():
            result[f"home_{key}"] = val
        away_ht = self._compute_ht_band(
            cur, away_team_id, league_id, ht_a_odds,
            is_home=False, before_ts=before_ts,
        )
        for key, val in away_ht.items():
            result[f"away_{key}"] = val

        # ── HT OU 0.5 / 1.5 bands ────────────────────────────────
        ht_ou05_odds = float(odds.get("ht_ou05_o", 0))
        ht_ou05_band = self._compute_ou_band(
            cur, home_team_id, away_team_id, league_id,
            ht_ou05_odds, line=0.5, before_ts=before_ts,
            half_time=True,
        )
        for key, val in ht_ou05_band.items():
            result[f"band_ht_ou05_{key}"] = val

        ht_ou15_odds = float(odds.get("ht_ou15_o", 0))
        ht_ou15_band = self._compute_ou_band(
            cur, home_team_id, away_team_id, league_id,
            ht_ou15_odds, line=1.5, before_ts=before_ts,
            half_time=True,
        )
        for key, val in ht_ou15_band.items():
            result[f"band_ht_ou15_{key}"] = val

        # ── OE (Tek/Çift) band ────────────────────────────────────
        oe_odd_odds = float(odds.get("oe_odd", 0))
        oe_band = self._compute_oe_band(
            cur, home_team_id, away_team_id, league_id,
            oe_odd_odds, before_ts=before_ts,
        )
        for key, val in oe_band.items():
            result[f"band_oe_{key}"] = val

        # ── Cards (Kart Alt/Üst) — Hakem + Takım profili ──────────
        cards_o_odds = float(odds.get("cards_o", 0))
        cards_band = self._compute_cards_band(
            cur, home_team_id, away_team_id, league_id,
            cards_o_odds, before_ts=before_ts,
            referee_name=referee_name,
        )
        for key, val in cards_band.items():
            result[f"band_cards_{key}"] = val

        # ── HTFT (İY/MS) — 9 Kombinasyon ──────────────────────────
        htft_band = self._compute_htft_band(
            cur, home_team_id, away_team_id, league_id,
            odds, before_ts=before_ts,
        )
        for key, val in htft_band.items():
            result[key] = val  # already prefixed with band_htft_

        # ── Value signals ─────────────────────────────────────────
        self._add_value_signal(result, "home_band_ms_value_signal",
            result.get("home_band_ms_win_rate", 0.33), ms_home_odds)
        self._add_value_signal(result, "away_band_ms_value_signal",
            result.get("away_band_ms_win_rate", 0.33), ms_away_odds)
        self._add_value_signal(result, "band_ou25_value_signal",
            result.get("band_ou25_over_rate", 0.50), ou25_over_odds)
        self._add_value_signal(result, "band_ou15_value_signal",
            result.get("band_ou15_over_rate", 0.65), ou15_over_odds)
        self._add_value_signal(result, "band_ou35_value_signal",
            result.get("band_ou35_over_rate", 0.35), ou35_over_odds)
        self._add_value_signal(result, "band_btts_value_signal",
            result.get("band_btts_yes_rate", 0.50), btts_yes_odds)
        self._add_value_signal(result, "band_dc_1x_value_signal",
            result.get("band_dc_1x_rate", 0.60), dc_1x_odds)
        self._add_value_signal(result, "band_dc_x2_value_signal",
            result.get("band_dc_x2_rate", 0.60), dc_x2_odds)
        self._add_value_signal(result, "band_dc_12_value_signal",
            result.get("band_dc_12_rate", 0.67), dc_12_odds)
        self._add_value_signal(result, "home_band_ht_value_signal",
            result.get("home_band_ht_win_rate", 0.33), ht_h_odds)
        self._add_value_signal(result, "away_band_ht_value_signal",
            result.get("away_band_ht_win_rate", 0.33), ht_a_odds)
        self._add_value_signal(result, "band_ht_ou05_value_signal",
            result.get("band_ht_ou05_over_rate", 0.50), ht_ou05_odds)
        self._add_value_signal(result, "band_ht_ou15_value_signal",
            result.get("band_ht_ou15_over_rate", 0.35), ht_ou15_odds)
        self._add_value_signal(result, "band_oe_value_signal",
            result.get("band_oe_odd_rate", 0.50), oe_odd_odds)
        # Cards value signal
        self._add_value_signal(result, "band_cards_value_signal",
            result.get("band_cards_combined_over_rate", 0.50), cards_o_odds)
        # HTFT value signals (9 combinations)
        for combo in ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22"):
            htft_key = f"htft_{combo}"
            htft_odds_key = f"htft_{combo}"
            htft_odds_val = float(odds.get(htft_odds_key, 0))
            self._add_value_signal(result, f"band_htft_{combo}_value_signal",
                result.get(f"band_htft_{combo}_rate", 0.11), htft_odds_val)

        return result

    # ─── Shared value signal helper ───────────────────────────────
    @staticmethod
    def _add_value_signal(
        result: Dict[str, float], key: str,
        actual_rate: float, odds_val: float,
    ) -> None:
        if odds_val > 1.0:
            result[key] = round(actual_rate - (1.0 / odds_val), 4)
        else:
            result[key] = 0.0

    # ─── MS Band ──────────────────────────────────────────────────

    def _compute_ms_band(
        self,
        cur: RealDictCursor,
        team_id: str,
        league_id: Optional[str],
        team_odds: float,
        is_home: bool,
        before_ts: int,
    ) -> Dict[str, float]:
        """
        Compute MS win/draw/loss rate for a team in a given odds band.
        
        Looks up: "When this team had odds ~X, how often did they win?"
        """
        defaults = {
            "band_ms_win_rate": 0.33,
            "band_ms_draw_rate": 0.33,
            "band_ms_loss_rate": 0.34,
            "band_ms_sample": 0.0,
            "band_ms_avg_goals_scored": 1.3,
            "band_ms_avg_goals_conceded": 1.1,
        }

        if team_odds <= 1.0:
            return defaults

        band_low, band_high = get_band_range(team_odds)

        try:
            # Query: find finished matches where this team's odds
            # fell within the band, then calculate actual outcomes
            cur.execute("""
                WITH team_matches_in_band AS (
                    SELECT
                        m.id,
                        m.score_home,
                        m.score_away,
                        m.home_team_id,
                        m.away_team_id,
                        CASE
                            WHEN m.home_team_id = %(team_id)s THEN os_sel.odd_value::numeric
                            ELSE os_sel2.odd_value::numeric
                        END AS team_odds
                    FROM matches m
                    JOIN odd_categories oc 
                        ON oc.match_id = m.id 
                        AND oc.name IN ('Maç Sonucu', 'Mac Sonucu', 'Match Result', '1x2')
                    LEFT JOIN odd_selections os_sel
                        ON os_sel.odd_category_db_id = oc.db_id 
                        AND os_sel.name = '1'
                        AND m.home_team_id = %(team_id)s
                    LEFT JOIN odd_selections os_sel2
                        ON os_sel2.odd_category_db_id = oc.db_id 
                        AND os_sel2.name = '2'
                        AND m.away_team_id = %(team_id)s
                    WHERE (m.home_team_id = %(team_id)s OR m.away_team_id = %(team_id)s)
                      AND m.sport = 'football'
                      AND m.status = 'FT'
                      AND m.score_home IS NOT NULL
                      AND m.score_away IS NOT NULL
                      AND m.mst_utc < %(before_ts)s
                      AND COALESCE(os_sel.odd_value::numeric, os_sel2.odd_value::numeric) 
                          BETWEEN %(band_low)s AND %(band_high)s
                    ORDER BY m.mst_utc DESC
                    LIMIT %(max_lookback)s
                )
                SELECT
                    COUNT(*) AS sample_size,
                    COALESCE(AVG(CASE
                        WHEN (home_team_id = %(team_id)s AND score_home > score_away)
                          OR (away_team_id = %(team_id)s AND score_away > score_home)
                        THEN 1.0 ELSE 0.0 END), 0.33) AS win_rate,
                    COALESCE(AVG(CASE 
                        WHEN score_home = score_away THEN 1.0 
                        ELSE 0.0 END), 0.33) AS draw_rate,
                    COALESCE(AVG(CASE
                        WHEN (home_team_id = %(team_id)s AND score_home < score_away)
                          OR (away_team_id = %(team_id)s AND score_away < score_home)
                        THEN 1.0 ELSE 0.0 END), 0.34) AS loss_rate,
                    COALESCE(AVG(CASE
                        WHEN home_team_id = %(team_id)s THEN score_home
                        ELSE score_away END), 1.3) AS avg_goals_scored,
                    COALESCE(AVG(CASE
                        WHEN home_team_id = %(team_id)s THEN score_away
                        ELSE score_home END), 1.1) AS avg_goals_conceded
                FROM team_matches_in_band
            """, {
                "team_id": team_id,
                "before_ts": before_ts,
                "band_low": band_low,
                "band_high": band_high,
                "max_lookback": MAX_LOOKBACK,
            })

            row = cur.fetchone()
            if not row or int(row["sample_size"]) < MIN_TEAM_SAMPLE:
                # Fallback to league-level if team sample is too small
                return self._compute_ms_band_league_fallback(
                    cur, league_id, team_odds, before_ts, defaults
                )

            return {
                "band_ms_win_rate": round(float(row["win_rate"]), 4),
                "band_ms_draw_rate": round(float(row["draw_rate"]), 4),
                "band_ms_loss_rate": round(float(row["loss_rate"]), 4),
                "band_ms_sample": float(row["sample_size"]),
                "band_ms_avg_goals_scored": round(float(row["avg_goals_scored"]), 2),
                "band_ms_avg_goals_conceded": round(float(row["avg_goals_conceded"]), 2),
            }

        except Exception as e:
            print(f"[OddsBand] ⚠ MS band query failed: {e}")
            return defaults

    def _compute_ms_band_league_fallback(
        self,
        cur: RealDictCursor,
        league_id: Optional[str],
        team_odds: float,
        before_ts: int,
        defaults: Dict[str, float],
    ) -> Dict[str, float]:
        """League-level fallback when team-specific sample is too small."""
        if not league_id or team_odds <= 1.0:
            return defaults

        band_low, band_high = get_band_range(team_odds)

        try:
            cur.execute("""
                WITH league_matches_in_band AS (
                    SELECT
                        m.id,
                        m.score_home,
                        m.score_away,
                        os_h.odd_value AS home_odds
                    FROM matches m
                    JOIN odd_categories oc 
                        ON oc.match_id = m.id 
                        AND oc.name IN ('Maç Sonucu', 'Mac Sonucu', 'Match Result', '1x2')
                    JOIN odd_selections os_h
                        ON os_h.odd_category_db_id = oc.db_id 
                        AND os_h.name = '1'
                    WHERE m.league_id = %(league_id)s
                      AND m.sport = 'football'
                      AND m.status = 'FT'
                      AND m.score_home IS NOT NULL
                      AND m.score_away IS NOT NULL
                      AND m.mst_utc < %(before_ts)s
                      AND os_h.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
                    ORDER BY m.mst_utc DESC
                    LIMIT %(max_lookback)s
                )
                SELECT
                    COUNT(*) AS sample_size,
                    COALESCE(AVG(CASE WHEN score_home > score_away 
                        THEN 1.0 ELSE 0.0 END), 0.33) AS home_win_rate,
                    COALESCE(AVG(CASE WHEN score_home = score_away 
                        THEN 1.0 ELSE 0.0 END), 0.33) AS draw_rate,
                    COALESCE(AVG(score_home + score_away), 2.5) AS avg_total_goals
                FROM league_matches_in_band
            """, {
                "league_id": league_id,
                "before_ts": before_ts,
                "band_low": band_low,
                "band_high": band_high,
                "max_lookback": MAX_LOOKBACK * 2,  # Wider for league
            })

            row = cur.fetchone()
            if not row or int(row["sample_size"]) < MIN_LEAGUE_SAMPLE:
                return defaults

            win_rate = float(row["home_win_rate"])
            draw_rate = float(row["draw_rate"])
            return {
                "band_ms_win_rate": round(win_rate, 4),
                "band_ms_draw_rate": round(draw_rate, 4),
                "band_ms_loss_rate": round(1.0 - win_rate - draw_rate, 4),
                "band_ms_sample": float(row["sample_size"]),
                "band_ms_avg_goals_scored": round(float(row["avg_total_goals"]) / 2, 2),
                "band_ms_avg_goals_conceded": round(float(row["avg_total_goals"]) / 2, 2),
            }

        except Exception as e:
            print(f"[OddsBand] ⚠ League MS fallback failed: {e}")
            return defaults

    # ─── OU Band ──────────────────────────────────────────────────

    def _compute_ou_band(
        self,
        cur: RealDictCursor,
        home_team_id: str,
        away_team_id: str,
        league_id: Optional[str],
        over_odds: float,
        line: float,
        before_ts: int,
        half_time: bool = False,
    ) -> Dict[str, float]:
        """
        Compute Over/Under rate for matches where teams had similar OU odds.
        When half_time=True, uses IY category names and score_ht fields.
        """
        defaults = {
            "over_rate": 0.50,
            "under_rate": 0.50,
            "avg_total_goals": 2.5 if not half_time else 1.0,
            "sample": 0.0,
        }

        if over_odds <= 1.0:
            return defaults

        band_low, band_high = get_band_range(over_odds)

        line_str = str(line).replace(".", ",")
        if half_time:
            cat_names = [
                f"1. Yarı {line_str} Alt/Üst",
                f"1. Yari {line_str} Alt/Ust",
                f"İlk Yarı {line_str} Alt/Üst",
                f"Ilk Yari {line_str} Alt/Ust",
            ]
            score_expr = "COALESCE(m.ht_score_home, 0) + COALESCE(m.ht_score_away, 0)"
        else:
            cat_names = [
                f"{line_str} Alt/Üst",
                f"{line_str} Alt/Ust",
                f"Over/Under {line}",
            ]
            score_expr = "m.score_home + m.score_away"

        try:
            query = f"""
                WITH ou_matches AS (
                    SELECT
                        {score_expr} AS total_goals
                    FROM matches m
                    JOIN odd_categories oc 
                        ON oc.match_id = m.id 
                        AND oc.name = ANY(%(cat_names)s)
                    JOIN odd_selections os_over
                        ON os_over.odd_category_db_id = oc.db_id
                        AND os_over.name IN ('Üst', 'Ust', 'Over')
                    WHERE (m.home_team_id = %(home_id)s OR m.away_team_id = %(home_id)s
                        OR m.home_team_id = %(away_id)s OR m.away_team_id = %(away_id)s)
                      AND m.sport = 'football'
                      AND m.status = 'FT'
                      AND m.score_home IS NOT NULL
                      AND m.mst_utc < %(before_ts)s
                      AND os_over.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
                    ORDER BY m.mst_utc DESC
                    LIMIT %(max_lookback)s
                )
                SELECT
                    COUNT(*) AS sample_size,
                    COALESCE(AVG(CASE WHEN total_goals > %(line)s 
                        THEN 1.0 ELSE 0.0 END), 0.5) AS over_rate,
                    COALESCE(AVG(total_goals), %(default_avg)s) AS avg_total
                FROM ou_matches
            """
            cur.execute(query, {
                "home_id": home_team_id,
                "away_id": away_team_id,
                "cat_names": cat_names,
                "before_ts": before_ts,
                "band_low": band_low,
                "band_high": band_high,
                "line": line,
                "default_avg": 1.0 if half_time else 2.5,
                "max_lookback": MAX_LOOKBACK,
            })

            row = cur.fetchone()
            if not row or int(row["sample_size"]) < MIN_TEAM_SAMPLE:
                return defaults

            over_rate = float(row["over_rate"])
            return {
                "over_rate": round(over_rate, 4),
                "under_rate": round(1.0 - over_rate, 4),
                "avg_total_goals": round(float(row["avg_total"]), 2),
                "sample": float(row["sample_size"]),
            }

        except Exception as e:
            ht_label = "HT_" if half_time else ""
            print(f"[OddsBand] ⚠ {ht_label}OU{line} band query failed: {e}")
            return defaults

    # ─── BTTS Band ────────────────────────────────────────────────

    def _compute_btts_band(
        self,
        cur: RealDictCursor,
        home_team_id: str,
        away_team_id: str,
        league_id: Optional[str],
        btts_yes_odds: float,
        before_ts: int,
    ) -> Dict[str, float]:
        """
        Compute BTTS rate for matches where teams had similar BTTS odds.
        """
        defaults = {
            "yes_rate": 0.50,
            "no_rate": 0.50,
            "sample": 0.0,
        }

        if btts_yes_odds <= 1.0:
            return defaults

        band_low, band_high = get_band_range(btts_yes_odds)

        try:
            cur.execute("""
                WITH btts_matches AS (
                    SELECT
                        m.score_home,
                        m.score_away
                    FROM matches m
                    JOIN odd_categories oc 
                        ON oc.match_id = m.id 
                        AND oc.name IN ('Karşılıklı Gol', 'Karsilikli Gol', 
                                        'Both Teams to Score', 'BTTS')
                    JOIN odd_selections os_yes
                        ON os_yes.odd_category_db_id = oc.db_id
                        AND os_yes.name IN ('Var', 'Yes')
                    WHERE (m.home_team_id = %(home_id)s OR m.away_team_id = %(home_id)s
                        OR m.home_team_id = %(away_id)s OR m.away_team_id = %(away_id)s)
                      AND m.sport = 'football'
                      AND m.status = 'FT'
                      AND m.score_home IS NOT NULL
                      AND m.mst_utc < %(before_ts)s
                      AND os_yes.odd_value::numeric BETWEEN %(band_low)s AND %(band_high)s
                    ORDER BY m.mst_utc DESC
                    LIMIT %(max_lookback)s
                )
                SELECT
                    COUNT(*) AS sample_size,
                    COALESCE(AVG(CASE WHEN score_home > 0 AND score_away > 0 
                        THEN 1.0 ELSE 0.0 END), 0.5) AS btts_rate
                FROM btts_matches
            """, {
                "home_id": home_team_id,
                "away_id": away_team_id,
                "before_ts": before_ts,
                "band_low": band_low,
                "band_high": band_high,
                "max_lookback": MAX_LOOKBACK,
            })

            row = cur.fetchone()
            if not row or int(row["sample_size"]) < MIN_TEAM_SAMPLE:
                return defaults

            yes_rate = float(row["btts_rate"])
            return {
                "yes_rate": round(yes_rate, 4),
                "no_rate": round(1.0 - yes_rate, 4),
                "sample": float(row["sample_size"]),
            }

        except Exception as e:
            print(f"[OddsBand] ⚠ BTTS band query failed: {e}")
            return defaults

    # ─── DC (Çifte Şans) Band ─────────────────────────────────────

    def _compute_dc_band(
        self,
        cur: RealDictCursor,
        home_team_id: str,
        away_team_id: str,
        league_id: Optional[str],
        dc_1x_odds: float,
        dc_x2_odds: float,
        dc_12_odds: float,
        before_ts: int,
    ) -> Dict[str, float]:
        """Compute DC hit rates from matches with similar DC odds."""
        defaults = {
            "1x_rate": 0.60, "x2_rate": 0.60, "12_rate": 0.67,
            "1x_sample": 0.0, "x2_sample": 0.0, "12_sample": 0.0,
        }
        result = {}
        for sel_key, odds_val, label in [
            ("1x", dc_1x_odds, "1-X"),
            ("x2", dc_x2_odds, "X-2"),
            ("12", dc_12_odds, "1-2"),
        ]:
            if odds_val <= 1.0:
                result[f"{sel_key}_rate"] = defaults[f"{sel_key}_rate"]
                result[f"{sel_key}_sample"] = 0.0
                continue
            band_low, band_high = get_band_range(odds_val)
            try:
                cur.execute("""
                    WITH dc_matches AS (
                        SELECT m.score_home, m.score_away
                        FROM matches m
                        JOIN odd_categories oc ON oc.match_id = m.id
                            AND oc.name IN ('Çifte Şans', 'Cifte Sans', 'Double Chance')
                        JOIN odd_selections os_sel ON os_sel.odd_category_db_id = oc.db_id
                            AND os_sel.name IN (%(label)s, %(label_compact)s)
                        WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s
                            OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s)
                          AND m.sport = 'football' AND m.status = 'FT'
                          AND m.score_home IS NOT NULL
                          AND m.mst_utc < %(before_ts)s
                          AND os_sel.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
                        ORDER BY m.mst_utc DESC LIMIT %(ml)s
                    )
                    SELECT COUNT(*) AS ss,
                        COALESCE(AVG(CASE
                            WHEN %(sel_key)s = '1x' AND (score_home >= score_away) THEN 1.0
                            WHEN %(sel_key)s = 'x2' AND (score_away >= score_home) THEN 1.0
                            WHEN %(sel_key)s = '12' AND (score_home != score_away) THEN 1.0
                            ELSE 0.0 END), %(def_rate)s) AS hit_rate
                    FROM dc_matches
                """, {
                    "hid": home_team_id, "aid": away_team_id,
                    "label": label, "label_compact": label.replace("-", ""),
                    "before_ts": before_ts, "bl": band_low, "bh": band_high,
                    "ml": MAX_LOOKBACK, "sel_key": sel_key,
                    "def_rate": defaults[f"{sel_key}_rate"],
                })
                row = cur.fetchone()
                if row and int(row["ss"]) >= MIN_TEAM_SAMPLE:
                    result[f"{sel_key}_rate"] = round(float(row["hit_rate"]), 4)
                    result[f"{sel_key}_sample"] = float(row["ss"])
                else:
                    result[f"{sel_key}_rate"] = defaults[f"{sel_key}_rate"]
                    result[f"{sel_key}_sample"] = 0.0
            except Exception as e:
                print(f"[OddsBand] ⚠ DC-{sel_key} band failed: {e}")
                result[f"{sel_key}_rate"] = defaults[f"{sel_key}_rate"]
                result[f"{sel_key}_sample"] = 0.0
        return result

    # ─── HT (İlk Yarı Sonucu) Band ───────────────────────────────

    def _compute_ht_band(
        self,
        cur: RealDictCursor,
        team_id: str,
        league_id: Optional[str],
        team_odds: float,
        is_home: bool,
        before_ts: int,
    ) -> Dict[str, float]:
        """Compute HT win/draw/loss rate for a team in a given HT odds band."""
        defaults = {
            "band_ht_win_rate": 0.33, "band_ht_draw_rate": 0.40,
            "band_ht_loss_rate": 0.27, "band_ht_sample": 0.0,
        }
        if team_odds <= 1.0:
            return defaults
        band_low, band_high = get_band_range(team_odds)
        try:
            cur.execute("""
                WITH ht_matches AS (
                    SELECT m.ht_score_home, m.ht_score_away,
                           m.home_team_id, m.away_team_id
                    FROM matches m
                    JOIN odd_categories oc ON oc.match_id = m.id
                        AND oc.name IN ('1. Yarı Sonucu', '1. Yari Sonucu',
                                        'İlk Yarı Sonucu', 'Ilk Yari Sonucu',
                                        'Half Time Result')
                    LEFT JOIN odd_selections os1 ON os1.odd_category_db_id = oc.db_id
                        AND os1.name = '1' AND m.home_team_id = %(tid)s
                    LEFT JOIN odd_selections os2 ON os2.odd_category_db_id = oc.db_id
                        AND os2.name = '2' AND m.away_team_id = %(tid)s
                    WHERE (m.home_team_id = %(tid)s OR m.away_team_id = %(tid)s)
                      AND m.sport = 'football' AND m.status = 'FT'
                      AND m.ht_score_home IS NOT NULL
                      AND m.mst_utc < %(before_ts)s
                      AND COALESCE(os1.odd_value::numeric, os2.odd_value::numeric)
                          BETWEEN %(bl)s AND %(bh)s
                    ORDER BY m.mst_utc DESC LIMIT %(ml)s
                )
                SELECT COUNT(*) AS ss,
                    COALESCE(AVG(CASE
                        WHEN (home_team_id = %(tid)s AND ht_score_home > ht_score_away)
                          OR (away_team_id = %(tid)s AND ht_score_away > ht_score_home)
                        THEN 1.0 ELSE 0.0 END), 0.33) AS win_rate,
                    COALESCE(AVG(CASE WHEN ht_score_home = ht_score_away
                        THEN 1.0 ELSE 0.0 END), 0.40) AS draw_rate
                FROM ht_matches
            """, {
                "tid": team_id, "before_ts": before_ts,
                "bl": band_low, "bh": band_high, "ml": MAX_LOOKBACK,
            })
            row = cur.fetchone()
            if not row or int(row["ss"]) < MIN_TEAM_SAMPLE:
                return defaults
            w = float(row["win_rate"])
            d = float(row["draw_rate"])
            return {
                "band_ht_win_rate": round(w, 4),
                "band_ht_draw_rate": round(d, 4),
                "band_ht_loss_rate": round(1.0 - w - d, 4),
                "band_ht_sample": float(row["ss"]),
            }
        except Exception as e:
            print(f"[OddsBand] ⚠ HT band query failed: {e}")
            return defaults

    # ─── OE (Tek/Çift) Band ──────────────────────────────────────

    def _compute_oe_band(
        self,
        cur: RealDictCursor,
        home_team_id: str,
        away_team_id: str,
        league_id: Optional[str],
        oe_odd_odds: float,
        before_ts: int,
    ) -> Dict[str, float]:
        """Compute Odd/Even rate from matches with similar OE odds."""
        defaults = {"odd_rate": 0.50, "even_rate": 0.50, "sample": 0.0}
        if oe_odd_odds <= 1.0:
            return defaults
        band_low, band_high = get_band_range(oe_odd_odds)
        try:
            cur.execute("""
                WITH oe_matches AS (
                    SELECT m.score_home + m.score_away AS total
                    FROM matches m
                    JOIN odd_categories oc ON oc.match_id = m.id
                        AND oc.name IN ('Tek/Çift', 'Tek/Cift', 'Odd/Even')
                    JOIN odd_selections os_odd ON os_odd.odd_category_db_id = oc.db_id
                        AND os_odd.name IN ('Tek', 'Odd')
                    WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s
                        OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s)
                      AND m.sport = 'football' AND m.status = 'FT'
                      AND m.score_home IS NOT NULL
                      AND m.mst_utc < %(before_ts)s
                      AND os_odd.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
                    ORDER BY m.mst_utc DESC LIMIT %(ml)s
                )
                SELECT COUNT(*) AS ss,
                    COALESCE(AVG(CASE WHEN total %% 2 = 1
                        THEN 1.0 ELSE 0.0 END), 0.5) AS odd_rate
                FROM oe_matches
            """, {
                "hid": home_team_id, "aid": away_team_id,
                "before_ts": before_ts, "bl": band_low, "bh": band_high,
                "ml": MAX_LOOKBACK,
            })
            row = cur.fetchone()
            if not row or int(row["ss"]) < MIN_TEAM_SAMPLE:
                return defaults
            odd_rate = float(row["odd_rate"])
            return {
                "odd_rate": round(odd_rate, 4),
                "even_rate": round(1.0 - odd_rate, 4),
                "sample": float(row["ss"]),
            }
        except Exception as e:
            print(f"[OddsBand] ⚠ OE band query failed: {e}")
            return defaults

    # ─── Cards (Kart Alt/Üst) Band — 3-Layer Profiling ───────────

    def _compute_cards_band(
        self,
        cur: RealDictCursor,
        home_team_id: str,
        away_team_id: str,
        league_id: Optional[str],
        cards_o_odds: float,
        before_ts: int,
        referee_name: Optional[str] = None,
    ) -> Dict[str, float]:
        """
        3-layer card analysis:
          1) Referee card profile (avg cards/match, over-line rate)
          2) Team card profile (both teams' card averages)
          3) Composite weighted average

        Card data comes from match_player_events (event_type='card'),
        NOT from score columns.
        """
        # Detect the card line from odds (3.5, 4.5, 5.5 etc.)
        card_line = self._detect_card_line(cards_o_odds)

        defaults: Dict[str, float] = {
            "referee_avg": 0.0,
            "referee_over_rate": 0.50,
            "referee_sample": 0.0,
            "team_avg": 0.0,
            "team_over_rate": 0.50,
            "team_sample": 0.0,
            "combined_over_rate": 0.50,
            "sample": 0.0,
        }

        referee_avg = 0.0
        referee_over_rate = 0.50
        referee_sample = 0.0
        team_avg = 0.0
        team_over_rate = 0.50
        team_sample = 0.0

        # ── Layer 1: Referee card profile ──────────────────────────
        if referee_name:
            try:
                cur.execute("""
                    WITH ref_matches AS (
                        SELECT m.id AS match_id
                        FROM matches m
                        JOIN match_officials mo ON mo.match_id = m.id
                        WHERE mo.name = %(ref)s
                          AND mo.role_id = 1
                          AND m.status = 'FT'
                          AND m.mst_utc < %(before_ts)s
                        ORDER BY m.mst_utc DESC
                        LIMIT 50
                    ),
                    card_counts AS (
                        SELECT rm.match_id,
                            COUNT(*) AS total_cards
                        FROM ref_matches rm
                        JOIN match_player_events mpe ON mpe.match_id = rm.match_id
                        WHERE mpe.event_type = 'card'
                        GROUP BY rm.match_id
                    )
                    SELECT
                        COUNT(DISTINCT rm.match_id) AS ss,
                        COALESCE(AVG(COALESCE(cc.total_cards, 0)), 0) AS avg_cards,
                        COALESCE(AVG(CASE WHEN COALESCE(cc.total_cards, 0) > %(line)s
                            THEN 1.0 ELSE 0.0 END), 0.5) AS over_rate
                    FROM ref_matches rm
                    LEFT JOIN card_counts cc ON cc.match_id = rm.match_id
                """, {
                    "ref": referee_name,
                    "before_ts": before_ts,
                    "line": card_line,
                })
                row = cur.fetchone()
                if row and int(row["ss"]) >= 5:
                    referee_avg = float(row["avg_cards"])
                    referee_over_rate = float(row["over_rate"])
                    referee_sample = float(row["ss"])
            except Exception as e:
                print(f"[OddsBand] ⚠ Cards referee query failed: {e}")

        # ── Layer 2: Team card profile ─────────────────────────────
        try:
            cur.execute("""
                WITH team_matches AS (
                    SELECT m.id AS match_id
                    FROM matches m
                    WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s
                        OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s)
                      AND m.sport = 'football'
                      AND m.status = 'FT'
                      AND m.mst_utc < %(before_ts)s
                    ORDER BY m.mst_utc DESC
                    LIMIT %(ml)s
                ),
                card_counts AS (
                    SELECT tm.match_id,
                        COUNT(*) AS total_cards
                    FROM team_matches tm
                    JOIN match_player_events mpe ON mpe.match_id = tm.match_id
                    WHERE mpe.event_type = 'card'
                    GROUP BY tm.match_id
                )
                SELECT
                    COUNT(DISTINCT tm.match_id) AS ss,
                    COALESCE(AVG(COALESCE(cc.total_cards, 0)), 0) AS avg_cards,
                    COALESCE(AVG(CASE WHEN COALESCE(cc.total_cards, 0) > %(line)s
                        THEN 1.0 ELSE 0.0 END), 0.5) AS over_rate
                FROM team_matches tm
                LEFT JOIN card_counts cc ON cc.match_id = tm.match_id
            """, {
                "hid": home_team_id, "aid": away_team_id,
                "before_ts": before_ts, "line": card_line,
                "ml": MAX_LOOKBACK,
            })
            row = cur.fetchone()
            if row and int(row["ss"]) >= MIN_TEAM_SAMPLE:
                team_avg = float(row["avg_cards"])
                team_over_rate = float(row["over_rate"])
                team_sample = float(row["ss"])
        except Exception as e:
            print(f"[OddsBand] ⚠ Cards team query failed: {e}")

        # ── Layer 3: Composite ─────────────────────────────────────
        total_sample = referee_sample + team_sample
        if total_sample > 0:
            # Referee weight = 60% when available (referee insight is more predictive)
            ref_weight = 0.6 if referee_sample >= 5 else 0.0
            team_weight = 1.0 - ref_weight
            combined = (referee_over_rate * ref_weight) + (team_over_rate * team_weight)
        else:
            combined = 0.50

        return {
            "referee_avg": round(referee_avg, 2),
            "referee_over_rate": round(referee_over_rate, 4),
            "referee_sample": referee_sample,
            "team_avg": round(team_avg, 2),
            "team_over_rate": round(team_over_rate, 4),
            "team_sample": team_sample,
            "combined_over_rate": round(combined, 4),
            "sample": max(referee_sample, team_sample),
        }

    @staticmethod
    def _detect_card_line(cards_o_odds: float) -> float:
        """
        Detect the card line from over odds.
        Low odds (< 1.60) → probably 3.5 line
        Medium odds (1.60-2.20) → probably 4.5 line
        High odds (> 2.20) → probably 5.5 line
        """
        if cards_o_odds <= 0:
            return 4.5  # Default assumption
        if cards_o_odds < 1.60:
            return 3.5
        if cards_o_odds < 2.20:
            return 4.5
        return 5.5

    # ─── HTFT (İY/MS) Band — 9 Combination Analysis ──────────────

    _HTFT_COMBOS = ("11", "1x", "12", "x1", "xx", "x2", "21", "2x", "22")

    def _compute_htft_band(
        self,
        cur: RealDictCursor,
        home_team_id: str,
        away_team_id: str,
        league_id: Optional[str],
        odds: Dict[str, float],
        before_ts: int,
    ) -> Dict[str, float]:
        """
        Compute HTFT (İlk Yarı / Maç Sonucu) hit rates for all 9 combinations.

        For each combination, looks at historical matches where the HTFT odds
        were in a similar band, and computes how often that specific HTFT
        outcome actually occurred.

        Uses ht_score_home, ht_score_away (half-time) and
        score_home, score_away (full-time) from matches table.
        """
        defaults: Dict[str, float] = {}
        for combo in self._HTFT_COMBOS:
            defaults[f"band_htft_{combo}_rate"] = 0.11  # ~1/9
            defaults[f"band_htft_{combo}_sample"] = 0.0

        # Check if any HTFT odds exist
        has_htft_odds = any(
            float(odds.get(f"htft_{combo}", 0)) > 1.0
            for combo in self._HTFT_COMBOS
        )
        if not has_htft_odds:
            return defaults

        # Pick the most-traded HTFT combo (lowest odds = most likely) for band
        # This gives us the best sample for the band lookup
        try:
            # Strategy: query all matches for both teams where HTFT odds exist,
            # then compute hit rates for each combination
            cur.execute("""
                WITH htft_matches AS (
                    SELECT
                        m.id,
                        m.ht_score_home,
                        m.ht_score_away,
                        m.score_home,
                        m.score_away,
                        CASE
                            WHEN m.ht_score_home > m.ht_score_away THEN '1'
                            WHEN m.ht_score_home = m.ht_score_away THEN 'x'
                            ELSE '2'
                        END AS ht_result,
                        CASE
                            WHEN m.score_home > m.score_away THEN '1'
                            WHEN m.score_home = m.score_away THEN 'x'
                            ELSE '2'
                        END AS ft_result
                    FROM matches m
                    WHERE (m.home_team_id = %(hid)s OR m.away_team_id = %(hid)s
                        OR m.home_team_id = %(aid)s OR m.away_team_id = %(aid)s)
                      AND m.sport = 'football'
                      AND m.status = 'FT'
                      AND m.score_home IS NOT NULL
                      AND m.ht_score_home IS NOT NULL
                      AND m.mst_utc < %(before_ts)s
                    ORDER BY m.mst_utc DESC
                    LIMIT %(ml)s
                )
                SELECT
                    COUNT(*) AS total_matches,
                    COUNT(*) FILTER (WHERE ht_result || ft_result = '11') AS c_11,
                    COUNT(*) FILTER (WHERE ht_result || ft_result = '1x') AS c_1x,
                    COUNT(*) FILTER (WHERE ht_result || ft_result = '12') AS c_12,
                    COUNT(*) FILTER (WHERE ht_result || ft_result = 'x1') AS c_x1,
                    COUNT(*) FILTER (WHERE ht_result || ft_result = 'xx') AS c_xx,
                    COUNT(*) FILTER (WHERE ht_result || ft_result = 'x2') AS c_x2,
                    COUNT(*) FILTER (WHERE ht_result || ft_result = '21') AS c_21,
                    COUNT(*) FILTER (WHERE ht_result || ft_result = '2x') AS c_2x,
                    COUNT(*) FILTER (WHERE ht_result || ft_result = '22') AS c_22
                FROM htft_matches
            """, {
                "hid": home_team_id, "aid": away_team_id,
                "before_ts": before_ts,
                "ml": MAX_LOOKBACK,
            })
            base_row = cur.fetchone()
            base_total = int(base_row["total_matches"]) if base_row else 0

            if base_total < MIN_TEAM_SAMPLE:
                # Fallback to league level
                if league_id:
                    cur.execute("""
                        WITH htft_lg AS (
                            SELECT
                                CASE
                                    WHEN m.ht_score_home > m.ht_score_away THEN '1'
                                    WHEN m.ht_score_home = m.ht_score_away THEN 'x'
                                    ELSE '2'
                                END AS ht_result,
                                CASE
                                    WHEN m.score_home > m.score_away THEN '1'
                                    WHEN m.score_home = m.score_away THEN 'x'
                                    ELSE '2'
                                END AS ft_result
                            FROM matches m
                            WHERE m.league_id = %(lid)s
                              AND m.sport = 'football'
                              AND m.status = 'FT'
                              AND m.score_home IS NOT NULL
                              AND m.ht_score_home IS NOT NULL
                              AND m.mst_utc < %(before_ts)s
                            ORDER BY m.mst_utc DESC
                            LIMIT 200
                        )
                        SELECT
                            COUNT(*) AS total_matches,
                            COUNT(*) FILTER (WHERE ht_result || ft_result = '11') AS c_11,
                            COUNT(*) FILTER (WHERE ht_result || ft_result = '1x') AS c_1x,
                            COUNT(*) FILTER (WHERE ht_result || ft_result = '12') AS c_12,
                            COUNT(*) FILTER (WHERE ht_result || ft_result = 'x1') AS c_x1,
                            COUNT(*) FILTER (WHERE ht_result || ft_result = 'xx') AS c_xx,
                            COUNT(*) FILTER (WHERE ht_result || ft_result = 'x2') AS c_x2,
                            COUNT(*) FILTER (WHERE ht_result || ft_result = '21') AS c_21,
                            COUNT(*) FILTER (WHERE ht_result || ft_result = '2x') AS c_2x,
                            COUNT(*) FILTER (WHERE ht_result || ft_result = '22') AS c_22
                        FROM htft_lg
                    """, {"lid": league_id, "before_ts": before_ts})
                    base_row = cur.fetchone()
                    base_total = int(base_row["total_matches"]) if base_row else 0

            if base_total < MIN_TEAM_SAMPLE:
                return defaults

            result: Dict[str, float] = {}
            for combo in self._HTFT_COMBOS:
                count = int(base_row[f"c_{combo}"])
                rate = count / base_total if base_total > 0 else 0.11
                result[f"band_htft_{combo}_rate"] = round(rate, 4)
                result[f"band_htft_{combo}_sample"] = float(base_total)

            # ── Odds-band refinement: for combos with odds, check
            # if similar-odds matches had different hit rates
            for combo in self._HTFT_COMBOS:
                combo_odds = float(odds.get(f"htft_{combo}", 0))
                if combo_odds <= 1.0:
                    continue
                band_low, band_high = get_band_range(combo_odds)
                try:
                    cur.execute("""
                        WITH band_htft AS (
                            SELECT
                                CASE
                                    WHEN m.ht_score_home > m.ht_score_away THEN '1'
                                    WHEN m.ht_score_home = m.ht_score_away THEN 'x'
                                    ELSE '2'
                                END || CASE
                                    WHEN m.score_home > m.score_away THEN '1'
                                    WHEN m.score_home = m.score_away THEN 'x'
                                    ELSE '2'
                                END AS htft_outcome
                            FROM matches m
                            JOIN odd_categories oc ON oc.match_id = m.id
                                AND oc.name IN (
                                    'İlk Yarı/Maç Sonucu',
                                    'Ilk Yarı/Maç Sonucu',
                                    'İlk Yarı/Mac Sonucu',
                                    'Ilk Yari/Mac Sonucu',
                                    'IY/MS'
                                )
                            JOIN odd_selections os ON os.odd_category_db_id = oc.db_id
                                AND os.odd_value::numeric BETWEEN %(bl)s AND %(bh)s
                            WHERE m.sport = 'football'
                              AND m.status = 'FT'
                              AND m.score_home IS NOT NULL
                              AND m.ht_score_home IS NOT NULL
                              AND m.mst_utc < %(before_ts)s
                            ORDER BY m.mst_utc DESC
                            LIMIT %(ml)s
                        )
                        SELECT
                            COUNT(*) AS ss,
                            COALESCE(AVG(CASE WHEN htft_outcome = %(target)s
                                THEN 1.0 ELSE 0.0 END), 0.0) AS hit_rate
                        FROM band_htft
                    """, {
                        "bl": band_low, "bh": band_high,
                        "before_ts": before_ts,
                        "ml": MAX_LOOKBACK,
                        "target": combo,
                    })
                    brow = cur.fetchone()
                    if brow and int(brow["ss"]) >= MIN_TEAM_SAMPLE:
                        # Blend base rate with band-specific rate (60/40 band preference)
                        base_rate = result[f"band_htft_{combo}_rate"]
                        band_rate = float(brow["hit_rate"])
                        blended = (band_rate * 0.6) + (base_rate * 0.4)
                        result[f"band_htft_{combo}_rate"] = round(blended, 4)
                        result[f"band_htft_{combo}_sample"] = float(brow["ss"])
                except Exception:
                    pass  # Keep base rate

            return result

        except Exception as e:
            print(f"[OddsBand] ⚠ HTFT band query failed: {e}")
            return defaults

