import json
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch

AI_ENGINE_ROOT = Path(__file__).resolve().parents[1]
if str(AI_ENGINE_ROOT) not in sys.path:
    sys.path.insert(0, str(AI_ENGINE_ROOT))

from schemas.prediction import FullMatchPrediction
from schemas.match_data import MatchData
from models.basketball_v25 import BasketballMatchPrediction
from services.single_match_orchestrator import SingleMatchOrchestrator


class _CursorContext:
    def __init__(self, cursor):
        self._cursor = cursor

    def __enter__(self):
        return self._cursor

    def __exit__(self, exc_type, exc, tb):
        return False


class _ConnContext:
    def __init__(self, cursor):
        self._cursor = cursor

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        return False

    def cursor(self, cursor_factory=None):
        return _CursorContext(self._cursor)


class _StaticFetchAllCursor:
    def __init__(self, rows):
        self.rows = rows
        self.executed = []

    def execute(self, query, params=None):
        self.executed.append((query, params))

    def fetchall(self):
        return list(self.rows)


class _RouterCursor:
    def __init__(
        self,
        *,
        live_row=None,
        hist_row=None,
        relational_rows=None,
        participation_rows=None,
        probable_rows=None,
    ):
        self.live_row = live_row
        self.hist_row = hist_row
        self.relational_rows = relational_rows or []
        self.participation_rows = participation_rows or []
        self.probable_rows = probable_rows or []
        self.last_query = ""

    def execute(self, query, params=None):
        self.last_query = query

    def fetchone(self):
        if "FROM live_matches" in self.last_query:
            return self.live_row
        if "FROM matches m" in self.last_query:
            return self.hist_row
        return None

    def fetchall(self):
        if "FROM odd_categories" in self.last_query:
            return list(self.relational_rows)
        if "FROM match_player_participation" in self.last_query and "GROUP BY" not in self.last_query:
            return list(self.participation_rows)
        if "GROUP BY mpp.player_id" in self.last_query:
            return list(self.probable_rows)
        return []


def _build_orchestrator() -> SingleMatchOrchestrator:
    orchestrator = SingleMatchOrchestrator.__new__(SingleMatchOrchestrator)
    orchestrator.v25_predictor = MagicMock()
    orchestrator.basketball_predictor = MagicMock()
    orchestrator.dsn = "postgresql://unit-test"
    orchestrator.league_reliability = {}
    orchestrator.market_calibration = {
        "MS": 0.82,
        "DC": 0.93,
        "OU15": 0.90,
        "OU25": 0.85,
        "OU35": 0.88,
        "BTTS": 0.83,
        "HT": 0.80,
        "HT_OU05": 0.88,
    }
    orchestrator.market_min_conf = {
        "MS": 52.0,
        "DC": 56.0,
        "OU15": 60.0,
        "OU25": 58.0,
        "OU35": 54.0,
        "BTTS": 57.0,
        "HT": 53.0,
        "HT_OU05": 55.0,
    }
    orchestrator.market_min_play_score = {
        "MS": 72.0,
        "DC": 62.0,
        "OU15": 64.0,
        "OU25": 70.0,
        "OU35": 76.0,
        "BTTS": 70.0,
        "HT": 74.0,
        "HT_OU05": 64.0,
    }
    orchestrator.market_min_edge = {
        "MS": 0.03,
        "DC": 0.01,
        "OU15": 0.01,
        "OU25": 0.02,
        "OU35": 0.04,
        "BTTS": 0.03,
        "HT": 0.04,
        "HT_OU05": 0.01,
    }
    return orchestrator


class SingleMatchOrchestratorTests(unittest.TestCase):
    def setUp(self):
        self.orchestrator = _build_orchestrator()

    def test_parse_odds_json_uses_exact_market_match_and_ignores_collisions(self):
        odds_json = {
            "Maç Sonucu": {"1": "2.15", "X": "3.20", "2": "3.30"},
            "İlk Yarı/Maç Sonucu": {"1/1": "4.30"},
            "2,5 Alt/Üst": {"Üst": "1.85", "Alt": "1.95"},
            "İY 0,5 Alt/Üst": {"Üst": "1.49", "Alt": "2.20"},
            "1. Yarı Ev Sahibi 0,5 Alt/Üst": {"Üst": "1.99", "Alt": "1.45"},
            "2,5 Kart Puanı Alt/Üst": {"Üst": "1.33", "Alt": "2.95"},
            "Karşılıklı Gol": {"Var": "1.75", "Yok": "2.05"},
            "1. Yarı Karşılıklı Gol": {"Var": "2.10", "Yok": "1.60"},
            "Çifte Şans": {"1-X": "1.33", "X-2": "1.62", "1-2": "1.30"},
            "1. Yarı Sonucu": {"1": "2.45", "X": "2.00", "2": "3.80"},
        }

        parsed = self.orchestrator._parse_odds_json(odds_json)

        self.assertEqual(parsed["ms_h"], 2.15)
        self.assertEqual(parsed["ms_d"], 3.20)
        self.assertEqual(parsed["ms_a"], 3.30)
        self.assertEqual(parsed["ou25_o"], 1.85)
        self.assertEqual(parsed["ou25_u"], 1.95)
        self.assertEqual(parsed["btts_y"], 1.75)
        self.assertEqual(parsed["btts_n"], 2.05)
        self.assertEqual(parsed["dc_1x"], 1.33)
        self.assertEqual(parsed["dc_x2"], 1.62)
        self.assertEqual(parsed["dc_12"], 1.30)
        self.assertEqual(parsed["ht_h"], 2.45)
        self.assertEqual(parsed["ht_d"], 2.00)
        self.assertEqual(parsed["ht_a"], 3.80)
        self.assertEqual(parsed["ht_ou05_o"], 1.49)
        self.assertEqual(parsed["ht_ou05_u"], 2.20)
        self.assertEqual(parsed["htft_11"], 4.30)

    def test_parse_odds_json_accepts_selection_variants(self):
        odds_json = {
            "2,5 Alt/Üst": {"2,5 Üst": "1.91", "2,5 Alt": "1.86"},
            "Karşılıklı Gol": {"YES": "1.82", "NO": "1.96"},
            "Çifte Şans": {"1X": "1.28", "X2": "1.44", "12": "1.32"},
        }

        parsed = self.orchestrator._parse_odds_json(odds_json)

        self.assertEqual(parsed["ou25_o"], 1.91)
        self.assertEqual(parsed["ou25_u"], 1.86)
        self.assertEqual(parsed["btts_y"], 1.82)
        self.assertEqual(parsed["btts_n"], 1.96)
        self.assertEqual(parsed["dc_1x"], 1.28)
        self.assertEqual(parsed["dc_x2"], 1.44)
        self.assertEqual(parsed["dc_12"], 1.32)

    def test_parse_odds_json_maps_all_football_markets_with_noise(self):
        odds_json = {
            "Maç Sonucu": {"1": "2.31", "X": "3.22", "2": "3.05"},
            "Çifte Şans": {"1-X": "1.34", "X-2": "1.52", "1-2": "1.28"},
            "1,5 Alt/Üst": {"Üst": "1.29", "Alt": "3.45"},
            "2,5 Alt/Üst": {"Üst": "1.71", "Alt": "2.05"},
            "3,5 Alt/Üst": {"Üst": "2.62", "Alt": "1.41"},
            "Karşılıklı Gol": {"Var": "1.66", "Yok": "2.11"},
            "1. Yarı Sonucu": {"1": "3.10", "X": "1.95", "2": "4.60"},
            "1. Yarı 0,5 Alt/Üst": {"Üst": "1.21", "Alt": "2.72"},
            # noise categories that must not overwrite football main markets
            "1. Yarı Ev Sahibi 0,5 Alt/Üst": {"Üst": "1.99", "Alt": "1.45"},
            "1. Yarı Deplasman 0,5 Alt/Üst": {"Üst": "1.73", "Alt": "1.63"},
            "1.Yarı 3,5 Korner Alt/Üst": {"Üst": "1.26", "Alt": "2.30"},
            "2,5 Kart Puanı Alt/Üst": {"Üst": "1.40", "Alt": "2.60"},
        }

        parsed = self.orchestrator._parse_odds_json(odds_json)

        self.assertEqual(parsed["ms_h"], 2.31)
        self.assertEqual(parsed["ms_d"], 3.22)
        self.assertEqual(parsed["ms_a"], 3.05)
        self.assertEqual(parsed["dc_1x"], 1.34)
        self.assertEqual(parsed["dc_x2"], 1.52)
        self.assertEqual(parsed["dc_12"], 1.28)
        self.assertEqual(parsed["ou15_o"], 1.29)
        self.assertEqual(parsed["ou15_u"], 3.45)
        self.assertEqual(parsed["ou25_o"], 1.71)
        self.assertEqual(parsed["ou25_u"], 2.05)
        self.assertEqual(parsed["ou35_o"], 2.62)
        self.assertEqual(parsed["ou35_u"], 1.41)
        self.assertEqual(parsed["btts_y"], 1.66)
        self.assertEqual(parsed["btts_n"], 2.11)
        self.assertEqual(parsed["ht_h"], 3.10)
        self.assertEqual(parsed["ht_d"], 1.95)
        self.assertEqual(parsed["ht_a"], 4.60)
        self.assertEqual(parsed["ht_ou05_o"], 1.21)
        self.assertEqual(parsed["ht_ou05_u"], 2.72)

    def test_v25_market_odds_ignores_synthetic_default_when_selection_missing(self):
        odds_json = {
            "1,5 Alt/Üst": {"Alt": 5.70},
            "Çifte Şans": {"1-X": 1.30, "X-2": 1.38, "1-2": 1.09},
        }

        parsed = self.orchestrator._parse_odds_json(odds_json)

        self.assertEqual(parsed["ou15_o"], 0.0)
        self.assertEqual(
            self.orchestrator._v25_market_odds(parsed, "OU15", "Over"),
            1.0,
        )
        self.assertEqual(
            self.orchestrator._v25_market_odds(parsed, "OU15", "Under"),
            5.7,
        )
        self.assertEqual(
            self.orchestrator._v25_market_odds(parsed, "DC", "X2"),
            1.38,
        )

    def test_parse_odds_json_extracts_basketball_ml_total_spread(self):
        odds_json = {
            "Maç Sonucu (Uzt. Dahil)": {"1": "1.74", "2": "2.08"},
            "Alt/Üst (163,5)": {"Üst": "1.86", "Alt": "1.94"},
            "1. Yarı Alt/Üst (81,5)": {"Üst": "1.89", "Alt": "1.91"},
            "1. Yarı Alt/Üst (100,5)": {"Üst": "1.83", "Alt": "1.97"},
            "Hnd. MS (0:5,5)": {"1": "1.91", "+5.5h": "1.87"},
        }

        parsed = self.orchestrator._parse_odds_json(odds_json)

        self.assertEqual(parsed["ml_h"], 1.74)
        self.assertEqual(parsed["ml_a"], 2.08)
        self.assertEqual(parsed["tot_line"], 163.5)
        self.assertEqual(parsed["tot_o"], 1.86)
        self.assertEqual(parsed["tot_u"], 1.94)
        self.assertEqual(parsed["spread_home_line"], -5.5)
        self.assertEqual(parsed["spread_h"], 1.91)
        self.assertEqual(parsed["spread_a"], 1.87)
        self.assertNotIn("ht_ou05_o", parsed)
        self.assertNotIn("ht_ou05_u", parsed)

    def test_extract_odds_merges_relational_when_live_json_is_incomplete(self):
        row = {
            "match_id": "m-1",
            "odds": {"Maç Sonucu": {"1": 2.10, "X": 3.20, "2": 3.35}},
        }
        relational_rows = [
            {"category_name": "Çifte Şans", "selection_name": "1-X", "odd_value": 1.28},
            {"category_name": "Çifte Şans", "selection_name": "X-2", "odd_value": 1.44},
            {"category_name": "Çifte Şans", "selection_name": "1-2", "odd_value": 1.31},
            {"category_name": "2,5 Alt/Üst", "selection_name": "Üst", "odd_value": 1.89},
            {"category_name": "2,5 Alt/Üst", "selection_name": "Alt", "odd_value": 1.94},
            {"category_name": "Karşılıklı Gol", "selection_name": "Var", "odd_value": 1.77},
            {"category_name": "Karşılıklı Gol", "selection_name": "Yok", "odd_value": 2.02},
            {"category_name": "1. Yarı Sonucu", "selection_name": "1", "odd_value": 2.55},
            {"category_name": "1. Yarı Sonucu", "selection_name": "X", "odd_value": 1.98},
            {"category_name": "1. Yarı Sonucu", "selection_name": "2", "odd_value": 3.40},
        ]
        cur = _StaticFetchAllCursor(relational_rows)

        odds = self.orchestrator._extract_odds(cur, row)

        self.assertEqual(odds["ms_h"], 2.10)
        self.assertEqual(odds["ms_d"], 3.20)
        self.assertEqual(odds["ms_a"], 3.35)
        self.assertEqual(odds["dc_x2"], 1.44)
        self.assertEqual(odds["ou25_o"], 1.89)
        self.assertEqual(odds["btts_y"], 1.77)
        self.assertEqual(odds["ht_d"], 1.98)
        self.assertEqual(len(cur.executed), 1)

    def test_extract_odds_fills_default_ms_when_no_source_available(self):
        row = {"match_id": "m-2", "odds": None}
        cur = _StaticFetchAllCursor([])

        odds = self.orchestrator._extract_odds(cur, row)

        self.assertEqual(odds["ms_h"], SingleMatchOrchestrator.DEFAULT_MS_H)
        self.assertEqual(odds["ms_d"], SingleMatchOrchestrator.DEFAULT_MS_D)
        self.assertEqual(odds["ms_a"], SingleMatchOrchestrator.DEFAULT_MS_A)

    def test_parse_lineups_json_supports_id_playerid_personid(self):
        lineups = {
            "home": {
                "xi": [
                    {"id": "11"},
                    {"playerId": "12"},
                ],
            },
            "away": {
                "starting": [
                    {"personId": "21"},
                    "22",
                ],
            },
        }

        home, away = self.orchestrator._parse_lineups_json(lineups)

        self.assertEqual(home, ["11", "12"])
        self.assertEqual(away, ["21", "22"])

    def test_extract_lineups_uses_participation_and_probable_xi_fallbacks(self):
        row = {
            "match_id": "m-3",
            "home_team_id": "h1",
            "away_team_id": "a1",
            "match_date_ms": 1700000000000,
            "lineups": {
                "home": {"xi": [{"personId": "h-live-1"}]},
                "away": {},
            },
        }
        participation = [
            {"team_id": "a1", "player_id": "a-db-1"},
            {"team_id": "a1", "player_id": "a-db-2"},
        ]
        cur = _StaticFetchAllCursor(participation)

        with patch.object(
            self.orchestrator,
            "_build_probable_xi",
            side_effect=[["h-prob-1"], ["a-prob-1"]],
        ) as probable_xi:
            home, away, source = self.orchestrator._extract_lineups(cur, row)

        self.assertEqual(home, ["h-live-1"])
        self.assertEqual(away, ["a-db-1", "a-db-2"])
        self.assertEqual(source, "none")
        probable_xi.assert_not_called()

    def test_extract_lineups_falls_back_to_probable_xi_when_live_and_participation_missing(self):
        row = {
            "match_id": "m-4",
            "home_team_id": "h2",
            "away_team_id": "a2",
            "match_date_ms": 1700000000000,
            "lineups": None,
        }
        cur = _StaticFetchAllCursor([])

        with patch.object(
            self.orchestrator,
            "_build_probable_xi",
            side_effect=[["h-prob-1", "h-prob-2"], ["a-prob-1"]],
        ) as probable_xi:
            home, away, source = self.orchestrator._extract_lineups(cur, row)

        self.assertEqual(home, ["h-prob-1", "h-prob-2"])
        self.assertEqual(away, ["a-prob-1"])
        self.assertEqual(source, "probable_xi")
        self.assertEqual(probable_xi.call_count, 2)

    def test_load_match_data_parses_live_row_json_and_sidelined(self):
        odds_payload = {
            "Maç Sonucu": {"1": 2.10, "X": 3.30, "2": 3.50},
            "Çifte Şans": {"1-X": 1.30, "X-2": 1.52, "1-2": 1.34},
            "1,5 Alt/Üst": {"Üst": 1.33, "Alt": 2.90},
            "2,5 Alt/Üst": {"Üst": 1.91, "Alt": 1.85},
            "3,5 Alt/Üst": {"Üst": 2.95, "Alt": 1.38},
            "Karşılıklı Gol": {"Var": 1.84, "Yok": 1.92},
            "1. Yarı Sonucu": {"1": 2.55, "X": 1.97, "2": 3.45},
        }
        lineups_payload = {
            "home": {"xi": [{"personId": "101"}, {"personId": "102"}]},
            "away": {"xi": [{"personId": "201"}, {"personId": "202"}]},
        }
        live_row = {
            "match_id": "live-101",
            "home_team_id": "h-101",
            "away_team_id": "a-101",
            "league_id": "l-101",
            "sport": "FOOTBALL",
            "match_date_ms": 1760000000000,
            "odds": json.dumps(odds_payload),
            "lineups": json.dumps(lineups_payload),
            "sidelined": json.dumps(
                {
                    "homeTeam": {"totalSidelined": 1, "players": []},
                    "awayTeam": {"totalSidelined": 0, "players": []},
                }
            ),
            "referee_name": "John Ref",
            "home_team_name": "Home FC",
            "away_team_name": "Away FC",
            "league_name": "League Name",
        }
        cursor = _RouterCursor(live_row=live_row)

        with patch("services.single_match_orchestrator.psycopg2.connect", return_value=_ConnContext(cursor)):
            data = self.orchestrator._load_match_data("live-101")

        self.assertIsNotNone(data)
        self.assertEqual(data.match_id, "live-101")
        self.assertEqual(data.home_team_id, "h-101")
        self.assertEqual(data.away_team_id, "a-101")
        self.assertEqual(data.sport, "football")
        self.assertEqual(data.referee_name, "John Ref")
        self.assertEqual(data.home_lineup, ["101", "102"])
        self.assertEqual(data.away_lineup, ["201", "202"])
        self.assertEqual(data.lineup_source, "none")
        self.assertEqual(data.sidelined_data["homeTeam"]["totalSidelined"], 1)
        self.assertEqual(data.odds_data["dc_x2"], 1.52)
        self.assertEqual(data.odds_data["ht_h"], 2.55)

    def test_analyze_match_forwards_all_core_fields_to_predictor(self):
        match_data = MatchData(
            match_id="live-55",
            home_team_id="home-55",
            away_team_id="away-55",
            home_team_name="Home 55",
            away_team_name="Away 55",
            match_date_ms=1760000000000,
            sport="football",
            league_id="league-55",
            league_name="League 55",
            referee_name="Ref 55",
            odds_data={"ms_h": 2.4, "ms_d": 3.1, "ms_a": 2.9},
            home_lineup=["h1", "h2"],
            away_lineup=["a1", "a2"],
            sidelined_data={
                "homeTeam": {"totalSidelined": 2, "players": []},
                "awayTeam": {"totalSidelined": 1, "players": []},
            },
            home_goals_avg=1.6,
            home_conceded_avg=1.1,
            away_goals_avg=1.2,
            away_conceded_avg=1.4,
            home_position=5,
            away_position=8,
            lineup_source="confirmed_live",
        )
        prediction = FullMatchPrediction(match_id="live-55", home_team="Home 55", away_team="Away 55")

        self.orchestrator._load_match_data = MagicMock(return_value=match_data)
        self.orchestrator.v25_predictor.predict_market_bundle = MagicMock(return_value={"MS": {"pick": "1"}})
        self.orchestrator._build_v25_features = MagicMock(return_value={})
        self.orchestrator._get_v25_signal = MagicMock(return_value={"MS": {"pick": "1"}})
        self.orchestrator._build_v25_prediction = MagicMock(return_value=prediction)
        self.orchestrator._build_prediction_package = MagicMock(return_value={"ok": True})

        result = self.orchestrator.analyze_match("live-55")

        self.assertEqual(result, {"ok": True})
        self.orchestrator._build_v25_features.assert_called_once_with(match_data)
        self.orchestrator._get_v25_signal.assert_called_once_with(match_data, {})
        self.orchestrator._build_v25_prediction.assert_called_once_with(
            match_data,
            {},
            {"MS": {"pick": "1"}},
        )

    def test_analyze_match_routes_basketball_to_basketball_predictor(self):
        match_data = MatchData(
            match_id="b-live-1",
            home_team_id="bh",
            away_team_id="ba",
            home_team_name="Home B",
            away_team_name="Away B",
            match_date_ms=1760000000000,
            sport="basketball",
            league_id="bleague",
            league_name="B League",
            referee_name=None,
            odds_data={"ml_h": 1.75, "ml_a": 2.05, "tot_line": 161.5, "tot_o": 1.88, "tot_u": 1.92},
            home_lineup=None,
            away_lineup=None,
            sidelined_data={"homeTeam": {"totalSidelined": 1}, "awayTeam": {"totalSidelined": 0}},
            home_goals_avg=85.0,
            home_conceded_avg=79.0,
            away_goals_avg=82.0,
            away_conceded_avg=81.0,
            home_position=4,
            away_position=7,
            lineup_source="none",
        )
        prediction = BasketballMatchPrediction(
            match_id="b-live-1",
            home_team_name="Home B",
            away_team_name="Away B",
            league_name="B League",
        )

        self.orchestrator._load_match_data = MagicMock(return_value=match_data)
        self.orchestrator.basketball_predictor.predict = MagicMock(return_value=prediction)
        self.orchestrator._build_basketball_prediction_package = MagicMock(
            return_value={"sport": "basketball", "ok": True}
        )

        result = self.orchestrator.analyze_match("b-live-1")

        self.assertEqual(result, {"sport": "basketball", "ok": True})
        self.orchestrator.basketball_predictor.predict.assert_called_once()
        kwargs = self.orchestrator.basketball_predictor.predict.call_args.kwargs
        self.assertEqual(kwargs["match_id"], "b-live-1")
        self.assertEqual(kwargs["home_team_id"], "bh")
        self.assertEqual(kwargs["away_team_id"], "ba")
        self.assertEqual(kwargs["league_id"], "bleague")
        self.assertEqual(kwargs["odds_data"]["ml_h"], 1.75)
        self.orchestrator.v25_predictor.predict_market_bundle.assert_not_called()

    def test_build_market_rows_maps_odds_keys_correctly(self):
        data = MatchData(
            match_id="m-rows",
            home_team_id="h",
            away_team_id="a",
            home_team_name="Home",
            away_team_name="Away",
            match_date_ms=1760000000000,
            sport="football",
            league_id=None,
            league_name="",
            referee_name=None,
            odds_data={
                "ms_h": 2.3,
                "ms_d": 3.2,
                "ms_a": 3.1,
                "dc_x2": 1.45,
                "ou15_o": 1.36,
                "ou25_u": 1.92,
                "ou35_o": 2.85,
                "btts_y": 1.88,
                "ht_h": 2.55,
                "ht_ou05_o": 1.47,
            },
            home_lineup=None,
            away_lineup=None,
            sidelined_data=None,
            home_goals_avg=1.5,
            home_conceded_avg=1.2,
            away_goals_avg=1.2,
            away_conceded_avg=1.4,
            home_position=10,
            away_position=10,
            lineup_source="none",
        )
        pred = FullMatchPrediction(
            match_id="m-rows",
            home_team="Home",
            away_team="Away",
            ms_home_prob=0.25,
            ms_draw_prob=0.30,
            ms_away_prob=0.45,
            ms_pick="2",
            ms_confidence=69.0,
            dc_1x_prob=0.60,
            dc_x2_prob=0.72,
            dc_12_prob=0.68,
            dc_pick="X2",
            dc_confidence=67.0,
            over_15_prob=0.74,
            under_15_prob=0.26,
            ou15_pick="1.5 Üst",
            ou15_confidence=72.0,
            over_25_prob=0.44,
            under_25_prob=0.56,
            ou25_pick="2.5 Alt",
            ou25_confidence=61.0,
            over_35_prob=0.39,
            under_35_prob=0.61,
            ou35_pick="3.5 Over",
            ou35_confidence=58.0,
            btts_yes_prob=0.57,
            btts_no_prob=0.43,
            btts_pick="Yes",
            btts_confidence=63.0,
            ht_home_prob=0.41,
            ht_draw_prob=0.39,
            ht_away_prob=0.20,
            ht_pick="1",
            ht_confidence=60.0,
            ht_over_05_prob=0.64,
            ht_under_05_prob=0.36,
            ht_ou_pick="Over 0.5",
        )

        rows = self.orchestrator._build_market_rows(data, pred)
        by_market = {row["market"]: row for row in rows}

        self.assertEqual(by_market["MS"]["odds"], 3.1)
        self.assertEqual(by_market["DC"]["odds"], 1.45)
        self.assertEqual(by_market["OU15"]["odds"], 1.36)
        self.assertEqual(by_market["OU25"]["odds"], 1.92)
        self.assertEqual(by_market["OU35"]["odds"], 2.85)
        self.assertEqual(by_market["BTTS"]["odds"], 1.88)
        self.assertEqual(by_market["HT"]["odds"], 2.55)
        self.assertEqual(by_market["HT_OU05"]["odds"], 1.47)

    def test_build_basketball_market_rows_maps_odds_keys_correctly(self):
        data = MatchData(
            match_id="b-rows",
            home_team_id="bh",
            away_team_id="ba",
            home_team_name="Home B",
            away_team_name="Away B",
            match_date_ms=1760000000000,
            sport="basketball",
            league_id="bl",
            league_name="Basketball League",
            referee_name=None,
            odds_data={
                "ml_h": 1.73,
                "ml_a": 2.10,
                "tot_line": 162.5,
                "tot_o": 1.89,
                "tot_u": 1.93,
                "spread_home_line": -4.5,
                "spread_h": 1.91,
                "spread_a": 1.88,
            },
            home_lineup=None,
            away_lineup=None,
            sidelined_data=None,
            home_goals_avg=84.0,
            home_conceded_avg=80.0,
            away_goals_avg=82.0,
            away_conceded_avg=81.0,
            home_position=5,
            away_position=8,
            lineup_source="none",
        )
        pred = {
            "match_id": "b-rows",
            "market_board": {
                "ML": {"1": "62%", "2": "38%"},
                "Totals": {"Under 162.5": "43%", "Over 162.5": "57%"},
                "Spread": {"Away +4.5": "46%", "Home -4.5": "54%"}
            }
        }

        rows = self.orchestrator._build_basketball_market_rows(data, pred)
        by_market = {row["market"]: row for row in rows}

        self.assertEqual(by_market["ML"]["odds"], 1.73)
        self.assertEqual(by_market["TOTAL"]["odds"], 1.89)
        self.assertEqual(by_market["SPREAD"]["odds"], 1.91)

    def test_compute_data_quality_flags_missing_referee_and_lineup(self):
        data = MatchData(
            match_id="dq-1",
            home_team_id="h",
            away_team_id="a",
            home_team_name="Home",
            away_team_name="Away",
            match_date_ms=1760000000000,
            sport="football",
            league_id=None,
            league_name="",
            referee_name=None,
            odds_data={"ms_h": 2.5, "ms_d": 3.2, "ms_a": 2.9},
            home_lineup=["h1", "h2"],
            away_lineup=["a1"],
            sidelined_data=None,
            home_goals_avg=1.5,
            home_conceded_avg=1.2,
            away_goals_avg=1.2,
            away_conceded_avg=1.4,
            home_position=10,
            away_position=10,
            lineup_source="none",
        )

        quality = self.orchestrator._compute_data_quality(data)

        self.assertIn("lineup_incomplete", quality["flags"])
        self.assertIn("missing_referee", quality["flags"])
        self.assertEqual(quality["label"], "MEDIUM")

    def test_load_match_data_returns_none_when_team_ids_missing(self):
        live_row = {
            "match_id": "live-missing-ids",
            "home_team_id": None,
            "away_team_id": None,
            "league_id": "l-1",
            "sport": "football",
            "match_date_ms": 1760000000000,
            "odds": None,
            "lineups": None,
            "sidelined": None,
            "referee_name": None,
            "home_team_name": "Home",
            "away_team_name": "Away",
            "league_name": "League",
        }
        cursor = _RouterCursor(live_row=live_row)

        with patch("services.single_match_orchestrator.psycopg2.connect", return_value=_ConnContext(cursor)):
            data = self.orchestrator._load_match_data("live-missing-ids")

        self.assertIsNone(data)

    def test_decorate_market_row_blocks_required_market_when_odds_missing(self):
        data = MatchData(
            match_id="dq-odds",
            home_team_id="h",
            away_team_id="a",
            home_team_name="Home",
            away_team_name="Away",
            match_date_ms=1760000000000,
            sport="football",
            league_id="l1",
            league_name="League",
            referee_name="Ref",
            odds_data={"ms_h": 2.2, "ms_d": 3.2, "ms_a": 3.0},
            home_lineup=["h"] * 11,
            away_lineup=["a"] * 11,
            sidelined_data=None,
            home_goals_avg=1.5,
            home_conceded_avg=1.2,
            away_goals_avg=1.2,
            away_conceded_avg=1.4,
            home_position=7,
            away_position=9,
            lineup_source="confirmed_live",
        )
        prediction = FullMatchPrediction(match_id="dq-odds", home_team="Home", away_team="Away")
        quality = self.orchestrator._compute_data_quality(data)
        row = {
            "market": "HT_OU05",
            "pick": "İY 0.5 Üst",
            "probability": 0.65,
            "confidence": 66.0,
            "odds": 0.0,
        }

        out = self.orchestrator._decorate_market_row(data, prediction, quality, row)
        self.assertFalse(out["playable"])
        self.assertIn("market_odds_missing", out["decision_reasons"])


if __name__ == "__main__":
    unittest.main()
