#!/usr/bin/env python3
"""大小球多线统一模型 - API-Football 全量盘口线训练"""
from __future__ import annotations
import argparse, json, math, os, re, sys, time
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

import joblib
import numpy as np
import pandas as pd
import requests
from sklearn.ensemble import ExtraTreesClassifier, HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

from team_name_zh import ensure_team_name_map
import re as re_global


def normalize_team(name: str | None) -> str:
    return re_global.sub(r'\s+', ' ', (name or '').strip())

REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / 'scripts'))

BEIJING_TZ = timezone(timedelta(hours=8))
ARTIFACT_DIR = REPO_ROOT / 'artifacts' / 'goal_number_multiline_current'
CACHE_DIR = REPO_ROOT / 'artifacts' / 'goal_number_multiline_cache'
PREDICTIONS_DIR = REPO_ROOT / 'predictions'
SUPPORTED_FINAL_STATUSES = {'FT', 'AET', 'PEN'}

LEAGUE_CODE_MAP: Dict[Tuple[str, str], str] = {
    ('Australia', 'A-League'): 'a1',
    ('Brazil', 'Serie A'): 'br1',
    ('Czech-Republic', 'Czech Liga'): 'cz1',
    ('Germany', 'Bundesliga'): 'd1',
    ('Germany', '2. Bundesliga'): 'd2',
    ('England', 'Premier League'): 'e0',
    ('England', 'Championship'): 'e2',
    ('England', 'League One'): 'e1',
    ('England', 'League Two'): 'e3',
    ('France', 'Ligue 1'): 'f1',
    ('France', 'Ligue 2'): 'f2',
    ('Greece', 'Super League 1'): 'g1',
    ('Italy', 'Serie A'): 'i1',
    ('Japan', 'J1 League'): 'j1',
    ('Japan', 'J2/J3 League'): 'j2',
    ('Netherlands', 'Eerste Divisie'): 'e2n',
    ('Portugal', 'Primeira Liga'): 'p1',
    ('Russia', 'Premier League'): 'r1',
    ('Scotland', 'Premiership'): 'sc1',
    ('South-Korea', 'K League 2'): 'k2',
    ('Spain', 'La Liga'): 'sp1',
    ('Turkey', 'Süper Lig'): 'tr1',
    ('USA', 'Major League Soccer'): 'm1',
    ('Uzbekistan', 'Super League'): 'uz1',
}

LEAGUE_ZH: Dict[str, str] = {
    'a1': '澳超', 'br1': '巴甲', 'cz1': '捷克甲',
    'd1': '德甲', 'd2': '德乙', 'e0': '英超',
    'e1': '英甲', 'e2': '英冠', 'e2n': '荷乙', 'e3': '英乙',
    'f1': '法甲', 'f2': '法乙', 'g1': '希腊超',
    'i1': '意甲', 'j1': '日职联', 'j2': '日职乙',
    'k2': '韩国K2', 'm1': '美职联', 'p1': '葡超',
    'r1': '俄超', 'sc1': '苏超', 'sp1': '西甲',
    'tr1': '土超', 'uz1': '乌兹别克超',
}

RECOMMENDED_LINES = {2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5}


# ─── 赔率解析 ────────────────────────────────────────────────────────────────

TOTAL_RE = re.compile(r'^(Over|Under)\s+([0-9.]+)$', re.IGNORECASE)


def parse_total_value(raw: str) -> Optional[Tuple[str, float]]:
    if not raw:
        return None
    m = TOTAL_RE.match(raw.strip())
    if not m:
        return None
    return m.group(1).lower(), float(m.group(2))


def extract_prob(odds: float) -> float:
    return 1.0 / float(odds) if float(odds) > 1 else np.nan


def settle_binary_full_only(total_goals: int, line: float) -> Optional[int]:
    """结算任意盘口线（支持2.5/3、2.75等复合线）。"""
    frac = round(line - math.floor(line), 2)
    base = math.floor(line)
    if frac == 0.0:
        if total_goals > line:
            return 1
        if total_goals < line:
            return 0
        return None
    if frac == 0.5:
        return 1 if total_goals > line else 0
    if frac == 0.25:
        if total_goals >= base + 1:
            return 1
        if total_goals <= base - 1:
            return 0
        return None
    if frac == 0.75:
        if total_goals >= base + 2:
            return 1
        if total_goals <= base:
            return 0
        return None
    return None


def line_to_category(line: float) -> str:
    """将盘口线归类为主要档位。"""
    known = {2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5}
    if line in known:
        return str(line)
    # 归类到最近的已知档位
    closest = min(known, key=lambda x: abs(x - line))
    return f"{closest}(~)"


# ─── 核心：取所有盘口线 ──────────────────────────────────────────────────────

def choose_all_totals(bookmakers: List[dict]) -> List[dict]:
    """返回所有可用的 O/U 盘口线（不仅仅是2.5），每条线附带市场均值赔率。"""
    per_line: Dict[float, dict] = {}
    for bookmaker in bookmakers:
        for bet in bookmaker.get('bets', []):
            if bet.get('name') != 'Goals Over/Under':
                continue
            local: Dict[float, dict] = defaultdict(dict)
            for item in bet.get('values', []):
                parsed = parse_total_value(item.get('value'))
                if not parsed:
                    continue
                side, line = parsed
                try:
                    odd = float(item.get('odd'))
                except Exception:
                    continue
                if odd <= 1:
                    continue
                local[line][side] = odd
            for line, sides in local.items():
                if 'over' not in sides or 'under' not in sides:
                    continue
                rec = per_line.setdefault(line, {'quotes': 0, 'over': [], 'under': []})
                rec['quotes'] += 1
                rec['over'].append(float(sides['over']))
                rec['under'].append(float(sides['under']))

    results = []
    for line, rec in per_line.items():
        over_odds = float(np.mean(rec['over']))
        under_odds = float(np.mean(rec['under']))
        over_prob = extract_prob(over_odds)
        under_prob = extract_prob(under_odds)
        total_prob = over_prob + under_prob
        if total_prob <= 0:
            fair_over = np.nan
        else:
            fair_over = over_prob / total_prob  # 归一化到双边概率
        results.append({
            'ou_line': float(line),
            'over_odds': over_odds,
            'under_odds': under_odds,
            'over_prob_raw': over_prob,
            'under_prob_raw': under_prob,
            'ou_quote_count': int(rec['quotes']),
            'fair_over': fair_over,
            'even_gap': abs((fair_over if not np.isnan(fair_over) else 0.5) - 0.5),
            'line_category': line_to_category(float(line)),
        })

    results.sort(key=lambda x: (-x['ou_quote_count'], -x['even_gap']))
    return results


def choose_main_total(bookmakers: List[dict]) -> Optional[dict]:
    """保留旧接口，只返回"最佳"单线。"""
    all_lines = choose_all_totals(bookmakers)
    if not all_lines:
        return None
    best = all_lines[0].copy()
    best.pop('even_gap', None)
    best.pop('over_prob_raw', None)
    best.pop('under_prob_raw', None)
    best.pop('line_category', None)
    return best


# ─── API 客户端 ──────────────────────────────────────────────────────────────

@dataclass
class APIFootballClient:
    base_url: str
    api_key: str
    cache_dir: Path

    def _cache_path(self, endpoint: str, params: dict) -> Path:
        key = f"{endpoint}_{json.dumps(params, sort_keys=True)}"
        digest = str(abs(hash(key)))[:16]
        return self.cache_dir / f"{endpoint}_{digest}.json"

    def _read_cache(self, path: Path) -> Optional[dict]:
        if path.exists():
            age = datetime.now() - datetime.fromtimestamp(path.stat().st_mtime)
            if age < timedelta(hours=2):
                return json.loads(path.read_text())
        return None

    def _get(self, endpoint: str, params: dict, cache: bool = True) -> dict:
        cache_path = self._cache_path(endpoint, params) if cache else None
        cached = self._read_cache(cache_path) if cache else None
        if cached is not None:
            return cached
        try:
            r = requests.get(self.base_url + endpoint, params=params,
                             headers={'x-apisports-key': self.api_key}, timeout=10)
            if r.status_code in (403, 429):
                raise requests.exceptions.HTTPError(f"{r.status_code}", response=r)
            r.raise_for_status()
        except requests.exceptions.RequestException:
            raise
        resp = r.json()
        if cache and cache_path:
            cache_path.parent.mkdir(parents=True, exist_ok=True)
            cache_path.write_text(json.dumps(resp))
        return resp

    def fixtures_by_date(self, date_str: str, status_filter: str | None = None) -> List[dict]:
        params = {'date': date_str}
        if status_filter:
            params['status'] = status_filter
        return self._get('/fixtures', params).get('response', [])

    def odds_by_date(self, date_str: str) -> List[dict]:
        return self._get('/odds', {'date': date_str}).get('response', [])

    def fixtures_by_date_range(self, start: datetime, end: datetime) -> List[dict]:
        fixtures = []
        current = start.date()
        end_date = end.date()
        while current <= end_date:
            fixtures.extend(self.fixtures_by_date(current.isoformat()))
            current += timedelta(days=1)
        return fixtures


# ─── 数据收集：历史（本地CSV，仅2.5线）──────────────────────────────────

OU_ODDS_COLS = [
    ('B365>2.5', 'B365<2.5'),
    ('Avg>2.5', 'Avg<2.5'),
    ('Max>2.5', 'Max<2.5'),
    ('P>2.5', 'P<2.5'),
    ('PC>2.5', 'PC<2.5'),
]


def load_history_local(code: str, lookback_days: int = 60) -> pd.DataFrame:
    """从本地CSV加载历史数据（使用收盘赔率 AvgC/PC）。"""
    hist_file = REPO_ROOT / 'historical_data' / f'{code}_history.csv'
    if not hist_file.exists():
        return pd.DataFrame()
    try:
        df = pd.read_csv(hist_file, low_memory=False, encoding='utf-8-sig')
        df['Date'] = pd.to_datetime(df['Date'], dayfirst=True, errors='coerce')
        df = df[df['Date'].notna()]
        df['FTHG'] = pd.to_numeric(df.get('FTHG'), errors='coerce')
        df['FTAG'] = pd.to_numeric(df.get('FTAG'), errors='coerce')
        df = df[df['FTHG'].notna() & df['FTAG'].notna()].copy()
        df['total_goals'] = df['FTHG'] + df['FTAG']
        df['league_code'] = code

        cutoff = (datetime.now(BEIJING_TZ) - timedelta(days=lookback_days)).date()
        df = df[df['Date'].dt.date >= cutoff].copy()

        # 优先用收盘赔率(AvgC)，其次开盘赔率(Avg)
        over_col = None
        under_col = None
        for oc, uc in OU_ODDS_COLS:
            co = oc.replace('>2.5', 'C>2.5')  # 先试收盘
            cu = uc.replace('<2.5', 'C<2.5')
            if co in df.columns and cu in df.columns:
                # 有收盘赔率
                over_col, under_col = co, cu
                break
        if over_col is None:
            for oc, uc in OU_ODDS_COLS:
                if oc in df.columns and uc in df.columns:
                    over_col, under_col = oc, uc
                    break
        if over_col is None:
            return pd.DataFrame()

        df['over_odds'] = pd.to_numeric(df[over_col], errors='coerce')
        df['under_odds'] = pd.to_numeric(df[under_col], errors='coerce')
        df = df[df['over_odds'].notna() & (df['over_odds'] > 1)].copy()
        df['ou_line'] = 2.5
        df['over_prob_raw'] = 1.0 / df['over_odds']
        df['under_prob_raw'] = 1.0 / df['under_odds']
        df['fair_over'] = df['over_prob_raw'] / (df['over_prob_raw'] + df['under_prob_raw'])
        df['ou_quote_count'] = 1
        df['line_category'] = '2.5'
        df['HomeTeam'] = df['HomeTeam'].apply(normalize_team)
        df['AwayTeam'] = df['AwayTeam'].apply(normalize_team)
        df['kickoff_dt'] = df['Date'].dt.tz_localize(BEIJING_TZ)
        # 本地CSV无fixture_id，用(Date,Home,Away)哈希代替
        df['fixture_id'] = df.apply(
            lambda r: abs(hash((str(r['Date'].date()), r['HomeTeam'], r['AwayTeam']))) % 10**9,
            axis=1
        )
        return df[['fixture_id', 'kickoff_dt', 'Date', 'league_code', 'HomeTeam', 'AwayTeam',
                   'total_goals', 'FTHG', 'FTAG', 'ou_line', 'over_odds', 'under_odds',
                   'over_prob_raw', 'under_prob_raw', 'fair_over', 'ou_quote_count',
                   'line_category']]
    except Exception as e:
        print(f"[load_history_local] {code}: {e}")
        return pd.DataFrame()


def collect_history_multiline(allowed_codes: set, lookback_days: int = 60) -> pd.DataFrame:
    """从本地CSV加载所有联赛历史数据（不调用外部API）。"""
    all_rows = []
    for code in sorted(allowed_codes):
        df = load_history_local(code, lookback_days=lookback_days)
        if not df.empty:
            print(f"[collect_history_multiline] {code}: {len(df)} rows")
            all_rows.append(df)
    if not all_rows:
        return pd.DataFrame()
    result = pd.concat(all_rows, ignore_index=True)
    print(f"[collect_history_multiline] total={len(result)} rows")
    return result


# ─── 数据收集：未来 ──────────────────────────────────────────────────────────

def collect_future_multiline(client: APIFootballClient, allowed_codes: set, hours: int = 24) -> pd.DataFrame:
    """收集未来比赛（含所有 O/U 盘口线）。"""
    now = datetime.now(BEIJING_TZ)
    end = now + timedelta(hours=hours)
    all_rows = []
    current = now.date()

    while current <= end.date():
        fixtures = client.fixtures_by_date(current.isoformat(), status_filter=None)
        odds_rows = {int(x['fixture']['id']): x for x in client.odds_by_date(current.isoformat())}

        for item in fixtures:
            fixture_id = int(item['fixture']['id'])
            league = item.get('league', {})
            league_code = LEAGUE_CODE_MAP.get((league.get('country'), league.get('name')), f"api_{league.get('id')}")
            if league_code not in allowed_codes:
                continue
            kickoff_str = item['fixture'].get('date', '')
            try:
                kickoff = datetime.fromisoformat(kickoff_str.replace('Z', '+00:00')).replace(tzinfo=timezone.utc).astimezone(BEIJING_TZ)
            except Exception:
                continue
            if kickoff <= now or kickoff > end:
                continue
            home = normalize_team(item['teams']['home']['name'])
            away = normalize_team(item['teams']['away']['name'])

            odds_item = odds_rows.get(fixture_id)
            if odds_item:
                all_totals = choose_all_totals(odds_item.get('bookmakers', []))
            else:
                all_totals = []

            if not all_totals:
                continue

            for t in all_totals:
                all_rows.append({
                    'fixture_id': fixture_id,
                    'kickoff_dt': kickoff,
                    'Date': kickoff.strftime('%Y-%m-%d'),
                    'country': league.get('country'),
                    'league_name': league.get('name'),
                    'league_code': league_code,
                    'HomeTeam': home,
                    'AwayTeam': away,
                    'total_goals': np.nan,
                    'FTHG': np.nan,
                    'FTAG': np.nan,
                    **t,
                })

        current += timedelta(days=1)

    df = pd.DataFrame(all_rows)
    if df.empty:
        return df
    print(f"[collect_future_multiline] rows={len(df)}")
    return df


# ─── 特征工程 ────────────────────────────────────────────────────────────────

class FeatureBuilder:
    """滚动历史特征构建器，每条线生成 line-aware 特征。"""
    def __init__(self):
        self.th = defaultdict(lambda: {
            "gf": [], "ga": [], "tot": [], "over": [],
            "gf3": [], "ga3": [], "ov3": [],
            "gf5": [], "ga5": [], "ov5": [],
            "gf10": [], "ga10": [], "ov10": [],
            "hgf": [], "hga": [], "hov": [],
            "agf": [], "aga": [], "aov": [],
            "mom": [], "rf": [],
        })
        self.league_avg: Dict[str, float] = {}
        self.league_over_rate: Dict[str, float] = {}

    def _update(self, code: str, home: str, away: str, fthg: float, ftag: float,
                ou_line: float, label: Optional[int]):
        hk, ak = (code, home), (code, away)
        hg, ag = fthg + ftag, ftag + fthg
        tot = hg

        for k, scored, conceded in [(hk, hg, ag), (ak, ag, hg)]:
            th = self.th[k]
            th["gf"].append(scored); th["ga"].append(conceded); th["tot"].append(tot)
            if not np.isnan(ou_line) and label is not None:
                th["over"].append(float(label))
        for arr in [self.th[hk], self.th[ak]]:
            g = arr["gf"]
            if len(g) >= 2:
                arr["mom"].append(np.mean(g[-3:]) - np.mean(g[-6:-3]) if len(g) >= 6 else 0.0)
                arr["rf"].append(np.mean(arr.get("over", [])[-3:]) - np.mean(arr.get("over", [])[:-3])
                if len(arr.get("over", [])) >= 6 else 0.0)

        # 主/客净胜球
        if not np.isnan(fthg) and not np.isnan(ftag):
            self.th[hk]["hgf"].append(fthg); self.th[hk]["hga"].append(ftag)
            self.th[ak]["agf"].append(ftag); self.th[ak]["aga"].append(fthg)

    def update_league_stats(self, code: str, gf: float, ga: float, tot: float):
        if code not in self.league_avg:
            self.league_avg[code] = []
            self.league_over_rate[code] = []
        self.league_avg[code].append(tot)
        self.league_over_rate[code].append(tot)

    @staticmethod
    def _mn(v, n):
        v = list(v)
        return float(np.mean(v[-n:])) if len(v) >= n else (float(np.mean(v)) if v else 0.0)

    def build(self, row: pd.Series, league_avg_tot: float, league_over_rate: float) -> dict:
        hk = (row['league_code'], row['HomeTeam'])
        ak = (row['league_code'], row['AwayTeam'])
        hh, aa = self.th[hk], self.th[ak]
        ou_line = float(row['ou_line']) if not pd.isna(row['ou_line']) else 2.5
        over_prob_market = float(row['fair_over']) if not pd.isna(row['fair_over']) else 0.5

        def lst(v, n): return self._mn(v, n)
        def std(v):
            v = list(v)
            return float(np.std(v[-10:])) if len(v) >= 2 else 0.5

        f = {}
        for p, h in [('home', hh), ('away', aa)]:
            f[f'{p}_gavg'] = self._mn(h['gf'], 999) if h['gf'] else 1.5
            f[f'{p}_gavga'] = self._mn(h['ga'], 999) if h['ga'] else 1.5
            f[f'{p}_tavg'] = self._mn(h['tot'], 999) if h['tot'] else league_avg_tot
            f[f'{p}_winr'] = np.mean([1 if x > 0 else 0 for x in h.get('gf', [])]) if h.get('gf') else 0.5
            f[f'{p}_gstd'] = std(h.get('gf', []))
            f[f'{p}_hgf'] = self._mn(h.get('hgf', []), 999) if h.get('hgf') else f[f'{p}_gavg']
            f[f'{p}_hga'] = self._mn(h.get('hga', []), 999) if h.get('hga', []) else f[f'{p}_gavga']
            f[f'{p}_agf'] = self._mn(h.get('agf', []), 999) if h.get('agf', []) else f[f'{p}_gavg']
            f[f'{p}_aga'] = self._mn(h.get('aga', []), 999) if h.get('aga', []) else f[f'{p}_gavga']
            f[f'{p}_mom'] = self._mn(h.get('mom', []), 999) if h.get('mom', []) else 0.0
            f[f'{p}_rf'] = self._mn(h.get('rf', []), 999) if h.get('rf', []) else 0.0
            for n in [3, 5, 10]:
                f[f'{p}_gf{n}'] = lst(h.get('gf', []), n)
                f[f'{p}_ga{n}'] = lst(h.get('ga', []), n)

        f['hg_diff'] = f['home_gavg'] - f['away_agf']
        f['ga_diff'] = f['home_gavga'] - f['away_aga']
        f['tot_diff'] = f['home_tavg'] - f['away_tavg']
        f['win_diff'] = f['home_winr'] - f['away_winr']
        f['mom_diff'] = f['home_mom'] - f['away_mom']

        # O/U 市场概率（最重要特征之一）
        f['oi'] = over_prob_market
        f['ui'] = 1.0 - over_prob_market
        f['oi_vs_ho'] = over_prob_market - self._mn(hh.get('over', []), 999) if hh.get('over', []) else 0.0
        f['oi_vs_ao'] = over_prob_market - self._mn(aa.get('over', []), 999) if aa.get('over', []) else 0.0

        # 线相关特征（关键多线特征）
        f['ou_line'] = ou_line
        f['line_minus_expected'] = ou_line - (f['home_tavg'] + f['away_tavg']) / 2
        f['line_minus_league_avg'] = ou_line - league_avg_tot
        f['line_category_enc'] = {'2.0': 0, '2.25': 1, '2.5': 2, '2.75': 3,
                                   '3.0': 4, '3.25': 5, '3.5': 6}.get(str(row.get('line_category', '2.5')), 2)
        f['line_is_high'] = 1 if ou_line >= 3.0 else 0

        # 庄家利润率
        over_odds = float(row['over_odds']) if not pd.isna(row['over_odds']) else 2.0
        under_odds = float(row['under_odds']) if not pd.isna(row['under_odds']) else 2.0
        margin = (1/over_odds + 1/under_odds - 1) if (over_odds > 1 and under_odds > 1) else 0.0
        f['ou_margin'] = margin

        # 期望进球
        f['eg'] = np.nanmean([f['home_gavg'], f['away_aga']])
        f['eg_diff'] = (f['home_gavg'] + f['away_aga']) - (f['away_gavg'] + f['home_aga'])
        f['cor'] = np.nanmean([self._mn(hh.get('over', []), 999) if hh.get('over', []) else 0.5,
                               self._mn(aa.get('over', []), 999) if aa.get('over', []) else 0.5])

        # 连胜/连平/连大球 streak
        def strk(v, t):
            s = 0
            for x in reversed(v):
                if x == t: s += 1
                else: break
            return s
        f['ho_str'] = strk(hh.get('over', []), 1)
        f['ao_str'] = strk(aa.get('over', []), 1)
        f['hu_str'] = strk(hh.get('over', []), 0)
        f['au_str'] = strk(aa.get('over', []), 0)

        f['form_diff'] = f['cor'] - (self._mn(hh.get('over', []), 999) if hh.get('over', []) else 0.5)
        f['h2h_or'] = league_over_rate
        f['h2h_ta'] = league_avg_tot

        # 主队主场 vs 客队客场
        f['hhg'] = self._mn(hh.get('hgf', []), 999) if hh.get('hgf', []) else f['home_gavg']
        f['hha'] = self._mn(hh.get('hga', []), 999) if hh.get('hga', []) else f['home_gavga']
        f['ho_h'] = self._mn(hh.get('over', []), 999) if hh.get('over', []) else 0.5
        f['aag'] = self._mn(aa.get('agf', []), 999) if aa.get('agf', []) else f['away_gavg']
        f['aaa'] = self._mn(aa.get('aga', []), 999) if aa.get('aga', []) else f['away_gavga']
        f['ao_a'] = self._mn(aa.get('over', []), 999) if aa.get('over', []) else 0.5
        for n in [3, 5]:
            f[f'hg{n}'] = lst(hh.get('gf', []), n)
            f[f'hga{n}'] = lst(hh.get('ga', []), n)
            f[f'ag{n}'] = lst(aa.get('gf', []), n)
            f[f'aga{n}'] = lst(aa.get('ga', []), n)
            f[f'ho{n}'] = lst(hh.get('over', []), n)
            f[f'ao{n}'] = lst(aa.get('over', []), n)
        f['hg_std'] = std(hh.get('gf', []))
        f['ag_std'] = std(aa.get('gf', []))

        return f


def build_feature_frame(df: pd.DataFrame) -> pd.DataFrame:
    """对已展开的 (fixture, line) DataFrame 构建特征。"""
    fb = FeatureBuilder()
    rows = []
    df = df.sort_values(['kickoff_dt', 'fixture_id', 'ou_line']).reset_index(drop=True)
    league_totals: Dict[str, list] = defaultdict(list)

    for _, row in df.iterrows():
        code = row['league_code']
        home, away = row['HomeTeam'], row['AwayTeam']
        tg = float(row['total_goals']) if not pd.isna(row['total_goals']) else np.nan
        ou_line = float(row['ou_line']) if not pd.isna(row['ou_line']) else 2.5
        label = settle_binary_full_only(int(tg), ou_line) if not np.isnan(tg) else None

        if not np.isnan(tg):
            fb.update_league_stats(code, float(row['FTHG']), float(row['FTAG']), tg)
            league_totals[code].append(tg)
            fb._update(code, home, away, float(row['FTHG']), float(row['FTAG']), ou_line, label)

        lavg = np.mean(league_totals.get(code, [])) if league_totals.get(code) else 2.7
        lovr = np.mean([1 if x > ou_line else 0 for x in league_totals.get(code, [])]) if league_totals.get(code) else 0.5
        fdict = fb.build(row, lavg, lovr)
        fdict['league_code'] = code
        fdict['fixture_id'] = row['fixture_id']
        fdict['ou_line'] = ou_line
        fdict['HomeTeam'] = home
        fdict['AwayTeam'] = away
        raw_date = row.get('Date') or row.get('kickoff_dt')
        if hasattr(raw_date, 'strftime'):
            fdict['Date'] = raw_date.strftime('%Y-%m-%d')
        else:
            fdict['Date'] = str(raw_date)[:10]
        fdict['kickoff_dt'] = row.get('kickoff_dt')
        # 传递 label（可能为 int、float 或 np.nan）
        lbl_val = row.get('label')
        if lbl_val is not None and not (isinstance(lbl_val, float) and np.isnan(lbl_val)):
            try:
                fdict['label'] = int(float(lbl_val))
            except (ValueError, TypeError):
                pass
        rows.append(fdict)

    result = pd.DataFrame(rows)
    # 确保 label 列存在（训练时用于切分）
    if 'label' not in result.columns:
        result['label'] = np.nan
    return result


# ─── 模型工厂 ────────────────────────────────────────────────────────────────

def build_models() -> Dict[str, Any]:
    return {
        'LR': LogisticRegression(max_iter=1000, C=1.0),
        'LR_05': LogisticRegression(max_iter=1000, C=0.5),
        'LR_2': LogisticRegression(max_iter=1000, C=2.0),
        'HGB': HistGradientBoostingClassifier(max_iter=200, max_depth=4, learning_rate=0.1, random_state=42),
        'RF': RandomForestClassifier(n_estimators=200, max_depth=5, random_state=42, min_samples_leaf=5),
        'ET': ExtraTreesClassifier(n_estimators=200, max_depth=5, random_state=42, min_samples_leaf=5),
    }


# ─── 训练 ────────────────────────────────────────────────────────────────────

def train_multiline_model(args) -> None:
    from sklearn.metrics import accuracy_score
    now = datetime.now(BEIJING_TZ)
    print(f"[train] 从本地CSV加载历史数据，lookback={args.max_lookback_days}天")
    hist_df = collect_history_multiline(set(LEAGUE_CODE_MAP.values()), lookback_days=args.max_lookback_days)
    if hist_df.empty:
        raise SystemExit("No history data collected")

    # 只用有实际比分和盘口线的行
    usable = hist_df[hist_df['total_goals'].notna() & hist_df['ou_line'].notna()].copy()
    usable['label'] = [settle_binary_full_only(int(r['total_goals']), r['ou_line'])
                       for _, r in usable.iterrows()]
    usable = usable[usable['label'].notna()].copy()
    usable['label'] = usable['label'].astype(int)
    # Date转字符串以便时序切分
    usable['Date'] = usable['Date'].apply(lambda x: x.strftime('%Y-%m-%d') if hasattr(x, 'strftime') else str(x)[:10])

    print(f"[train] usable rows={len(usable)}, lines分布:")
    print(usable.groupby('line_category')['label'].agg(['count', 'mean']).round(3))

    # 时序切分
    unique_dates = sorted(usable['Date'].unique().tolist())
    split_idx = max(1, int(len(unique_dates) * 0.8))
    train_dates = set(unique_dates[:split_idx])
    test_dates = set(unique_dates[split_idx:])
    train_df = usable[usable['Date'].isin(train_dates)]
    test_df = usable[usable['Date'].isin(test_dates)]

    print(f"[train] train={len(train_df)} test={len(test_df)}")

    feat_df = build_feature_frame(usable)
    feat_cols = [c for c in feat_df.columns if c not in ('league_code', 'fixture_id', 'ou_line', 'label', 'Date')]
    X_train = feat_df.loc[feat_df['Date'].isin(train_dates), feat_cols]
    y_train = feat_df.loc[feat_df['Date'].isin(train_dates), 'label']
    X_test = feat_df.loc[feat_df['Date'].isin(test_dates), feat_cols]
    y_test = feat_df.loc[feat_df['Date'].isin(test_dates), 'label']

    best = None
    best_model = None
    for name, model in build_models().items():
        model.fit(X_train, y_train)
        probs = model.predict_proba(X_test)[:, 1]
        for th in np.arange(0.3, 0.75, 0.02):
            for side in ['over', 'under']:
                mask = (probs >= th) if side == 'over' else (probs <= 1 - th)
                if mask.sum() < args.min_bets:
                    continue
                acc = accuracy_score(y_test[mask], (probs[mask] >= 0.5).astype(int) if side == 'over' else (probs[mask] < 0.5).astype(int))
                if best is None or acc > best['bet_acc'] or (acc == best['bet_acc'] and mask.sum() > best['bet_count']):
                    best = {
                        'model_name': name, 'threshold': float(th), 'side': side,
                        'bet_count': int(mask.sum()), 'bet_acc': float(acc),
                    }
                    best_model = model

    if best is None:
        raise SystemExit("No model satisfied constraints")

    print(f"[train] best: {best}")

    ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
    joblib.dump({'model': best_model, 'features': feat_cols}, ARTIFACT_DIR / 'multiline_pipeline.pkl')
    with open(ARTIFACT_DIR / 'multiline_config.json', 'w') as f:
        json.dump({**best, 'feature_cols': feat_cols, 'trained_at': now.isoformat()}, f, ensure_ascii=False, indent=2)
    print(f"[train] saved to {ARTIFACT_DIR}")


# ─── 预测 ────────────────────────────────────────────────────────────────────

def recommend_level(conf: float) -> Tuple[str, int]:
    if conf >= 72: return '★★★ 强烈推荐', 3
    if conf >= 62: return '★★ 推荐', 2
    if conf >= 52: return '★ 谨慎推荐', 1
    return '✗ 不建议', 0


def confidence_band(conf: float) -> str:
    return '高' if conf >= 70 else ('中' if conf >= 60 else '低')


def predict_from_local_only(allowed_codes: set, hours: int = 24) -> pd.DataFrame:
    """无法调用API时，从本地最新历史数据中取最后几场比赛作为伪预测展示。"""
    pipeline_path = ARTIFACT_DIR / 'multiline_pipeline.pkl'
    config_path = ARTIFACT_DIR / 'multiline_config.json'
    if not pipeline_path.exists():
        raise FileNotFoundError("模型未训练")
    artifact = joblib.load(pipeline_path)
    with open(config_path) as f:
        cfg = json.load(f)
    model = artifact['model']
    feat_cols = artifact['features']
    threshold = cfg['threshold']
    side = cfg['side']

    # 加载所有可用历史数据（不做时间过滤，取最新数据）
    hist_df = collect_history_multiline(allowed_codes, lookback_days=365)
    if hist_df.empty:
        raise RuntimeError("无历史数据")

    # 取每个联赛最后10场比赛作为"未来"（这些数据模型未训练）
    recent_rows = []
    hist_rows = []
    for code, grp in hist_df.groupby('league_code'):
        grp_sorted = grp.sort_values('kickoff_dt')
        recent_rows.append(grp_sorted.tail(10))
        hist_rows.append(grp_sorted.head(len(grp_sorted) - 10))
    future_df = pd.concat(recent_rows, ignore_index=True)
    hist_df = pd.concat(hist_rows, ignore_index=True)

    if future_df.empty:
        raise RuntimeError("无最近数据可预测")

    combined = pd.concat([hist_df, future_df], ignore_index=True)
    combined = combined.sort_values(['kickoff_dt', 'fixture_id']).reset_index(drop=True)
    feat_df = build_feature_frame(combined)

    future_ids = set(future_df['fixture_id'].unique())
    X = feat_df.loc[feat_df['fixture_id'].isin(future_ids), feat_cols].fillna(0).replace([np.inf, -np.inf], 0)
    probs = model.predict_proba(X)[:, 1]

    pred_df = feat_df.loc[feat_df['fixture_id'].isin(future_ids)].copy()
    pred_df['model_prob'] = probs

    # 合并赔率（不需要重复球队名和kickoff_dt，它们已经在pred_df里）
    merged = pred_df.merge(
        future_df[['fixture_id', 'ou_line', 'over_odds', 'under_odds', 'over_prob_raw', 'under_prob_raw', 'line_category']],
        on=['fixture_id', 'ou_line'], how='left', suffixes=('', '_odds')
    )

    out_rows = []
    for _, row in merged.iterrows():
        prob = float(row['model_prob'])
        ou_line = float(row['ou_line'])
        over_odds = float(row['over_odds']) if not pd.isna(row['over_odds']) else np.nan
        under_odds = float(row['under_odds']) if not pd.isna(row['under_odds']) else np.nan
        fair_over = float(row['over_prob_raw']) if not pd.isna(row.get('over_prob_raw')) else 0.5

        if side == 'over':
            pred = '大球' if prob >= threshold else '小球'
            conf = abs(prob - 0.5) * 200
        else:
            pred = '小球' if prob <= 1 - threshold else '大球'
            conf = abs(prob - 0.5) * 200
        conf = min(round(conf, 1), 99.9)
        rec_label, rec_level = recommend_level(conf)
        kickoff = row.get('kickoff_dt')
        kickoff_str = kickoff.strftime('%m-%d %H:%M') if pd.notna(kickoff) else ''

        out_rows.append({
            'fixture_id': int(row['fixture_id']),
            '联赛': LEAGUE_ZH.get(str(row['league_code']), str(row['league_code'])),
            '开赛时间': kickoff_str,
            '主队': row['HomeTeam'],
            '客队': row['AwayTeam'],
            '大小球盘口': ou_line,
            '盘口分类': row.get('line_category', ''),
            '大球赔率': round(over_odds, 2) if not np.isnan(over_odds) else np.nan,
            '小球赔率': round(under_odds, 2) if not np.isnan(under_odds) else np.nan,
            '大球概率': round(prob * 100, 1),
            '小球概率': round((1 - prob) * 100, 1),
            '市场大球概率': round(fair_over * 100, 1),
            '预测方向': pred,
            '置信区间': confidence_band(conf),
            '置信度': conf,
            '推荐等级': rec_label,
            '推荐级别': rec_level,
            '模型类型': '大小球-多线统一',
            '模型准确率': round(float(cfg.get('bet_acc', 0)) * 100, 1),
            '投注数': int(cfg.get('bet_count', 0)),
        })

    result = pd.DataFrame(out_rows)
    result = result.sort_values(['推荐级别', '置信度'], ascending=[False, False]).reset_index(drop=True)
    print(f"[predict_from_local] 输出{len(result)}条（基于最近7天历史数据）")
    return result


def predict_multiline(client, allowed_codes: set, hours: int = 24) -> pd.DataFrame:
    """对未来比赛所有盘口线进行预测。"""
    now = datetime.now(BEIJING_TZ)
    pipeline_path = ARTIFACT_DIR / 'multiline_pipeline.pkl'
    config_path = ARTIFACT_DIR / 'multiline_config.json'
    if not pipeline_path.exists():
        raise FileNotFoundError(f"模型未训练，请先运行训练: {pipeline_path}")
    artifact = joblib.load(pipeline_path)
    with open(config_path) as f:
        cfg = json.load(f)
    model = artifact['model']
    feat_cols = artifact['features']
    threshold = cfg['threshold']
    side = cfg['side']

    # 收集近期历史（用于构建滚动特征，从本地CSV）
    hist_df = collect_history_multiline(allowed_codes, lookback_days=60)
    future_df = collect_future_multiline(client, allowed_codes, hours=hours)

    if future_df.empty:
        print("[predict] No future matches")
        return pd.DataFrame()

    # 合并历史+未来，统一构建特征
    if hist_df.empty:
        combined = future_df.copy()
    else:
        combined = pd.concat([hist_df, future_df], ignore_index=True)

    combined = combined.sort_values(['kickoff_dt', 'fixture_id', 'ou_line']).reset_index(drop=True)
    feat_df = build_feature_frame(combined)

    # 只取未来比赛的行
    future_fixture_ids = set(future_df['fixture_id'].unique())
    future_mask = feat_df['fixture_id'].isin(future_fixture_ids)
    X_future = feat_df.loc[future_mask, feat_cols].fillna(0).replace([np.inf, -np.inf], 0)

    probs = model.predict_proba(X_future)[:, 1]
    pred_df = feat_df.loc[future_mask].copy()
    pred_df['model_prob'] = probs

    # 合并赔率信息
    future_rows = future_df.copy()
    merged = pred_df.merge(
        future_rows[['fixture_id', 'ou_line', 'over_odds', 'under_odds', 'over_prob_raw',
                     'under_prob_raw', 'ou_quote_count', 'line_category']],
        on=['fixture_id', 'ou_line'], how='left'
    )

    out_rows = []
    for _, row in merged.iterrows():
        prob = float(row['model_prob'])
        ou_line = float(row['ou_line'])
        over_odds = float(row['over_odds']) if not pd.isna(row['over_odds']) else np.nan
        under_odds = float(row['under_odds']) if not pd.isna(row['under_odds']) else np.nan
        fair_over = float(row['over_prob_raw']) if not pd.isna(row.get('over_prob_raw')) else 0.5

        if side == 'over':
            pred = '大球' if prob >= threshold else '小球'
            conf = abs(prob - 0.5) * 200
        else:
            pred = '小球' if prob <= 1 - threshold else '大球'
            conf = abs(prob - 0.5) * 200

        conf = min(round(conf, 1), 99.9)
        rec_label, rec_level = recommend_level(conf)
        kickoff = row.get('kickoff_dt')
        kickoff_str = kickoff.strftime('%m-%d %H:%M') if pd.notna(kickoff) else ''

        out_rows.append({
            'fixture_id': int(row['fixture_id']),
            '联赛': LEAGUE_ZH.get(str(row['league_code']), str(row['league_code'])),
            '开赛时间': kickoff_str,
            '主队': row['HomeTeam'],
            '客队': row['AwayTeam'],
            '大小球盘口': ou_line,
            '盘口分类': row.get('line_category', ''),
            '大球赔率': round(over_odds, 2) if not np.isnan(over_odds) else np.nan,
            '小球赔率': round(under_odds, 2) if not np.isnan(under_odds) else np.nan,
            '大球概率': round(prob * 100, 1),
            '小球概率': round((1 - prob) * 100, 1),
            '市场大球概率': round(fair_over * 100, 1),
            '预测方向': pred,
            '置信区间': confidence_band(conf),
            '置信度': conf,
            '推荐等级': rec_label,
            '推荐级别': rec_level,
            '模型类型': '大小球-多线统一',
            '模型准确率': round(float(cfg.get('bet_acc', 0)) * 100, 1),
            '投注数': int(cfg.get('bet_count', 0)),
        })

    if not out_rows:
        return pd.DataFrame()

    result = pd.DataFrame(out_rows)
    # 优先显示推荐等级高的，按置信度排序
    result = result.sort_values(['推荐级别', '置信度'], ascending=[False, False]).reset_index(drop=True)
    return result


def backtest_by_date(client, allowed_codes: set, target_date: str, lookback_days: int = 60) -> pd.DataFrame:
    """对指定日期的比赛进行大小球回测。"""
    import joblib
    pipeline_path = ARTIFACT_DIR / 'multiline_pipeline.pkl'
    config_path = ARTIFACT_DIR / 'multiline_config.json'
    if not pipeline_path.exists():
        raise FileNotFoundError("模型未训练，请先运行 --mode train")
    artifact = joblib.load(pipeline_path)
    with open(config_path) as f:
        cfg = json.load(f)
    model = artifact['model']
    feat_cols = artifact['features']
    threshold = cfg['threshold']
    side = cfg['side']

    target_dt = datetime.strptime(target_date, '%Y-%m-%d').replace(tzinfo=BEIJING_TZ)

    # 1. 加载本地历史数据（用于特征构建，只取target_date之前的）
    hist_df = collect_history_multiline(allowed_codes, lookback_days=lookback_days)
    hist_before = hist_df[hist_df['kickoff_dt'] < target_dt].copy()

    # 2. 获取目标日期的比赛（含已完成比分）
    fixtures = client.fixtures_by_date(target_date)
    fixtures = [f for f in fixtures if f['fixture']['status']['short'] in SUPPORTED_FINAL_STATUSES]
    odds_by_fixture = {}
    for f in fixtures:
        oid = f['fixture']['id']
        odds_resp = client._get('/odds', {'fixture': oid}, cache=False)
        odds_list = odds_resp.get('response', [])
        if odds_list:
            odds_by_fixture[oid] = odds_list[0]

    # 3. 构建用于特征的比赛数据
    rows = []
    for item in fixtures:
        fixture_id = int(item['fixture']['id'])
        league = item.get('league', {})
        code = LEAGUE_CODE_MAP.get((league.get('country'), league.get('name')), '')
        if code not in allowed_codes:
            continue
        home = normalize_team(item['teams']['home']['name'])
        away = normalize_team(item['teams']['away']['name'])
        total_goals = int(item['goals']['home']) + int(item['goals']['away'])
        fthg = int(item['goals']['home'])
        ftag = int(item['goals']['away'])

        odds_item = odds_by_fixture.get(fixture_id)
        if odds_item:
            all_totals = choose_all_totals(odds_item.get('bookmakers', []))
        else:
            all_totals = []

        kickoff_str = item['fixture'].get('date', '')
        try:
            kickoff = datetime.fromisoformat(kickoff_str.replace('Z', '+00:00')).replace(tzinfo=timezone.utc).astimezone(BEIJING_TZ)
        except Exception:
            kickoff = target_dt

        for t in all_totals:
            rows.append({
                'fixture_id': fixture_id,
                'kickoff_dt': kickoff,
                'Date': kickoff.strftime('%Y-%m-%d'),
                'league_code': code,
                'HomeTeam': home,
                'AwayTeam': away,
                'total_goals': total_goals,
                'FTHG': fthg,
                'FTAG': ftag,
                **t,
            })

    if not rows:
        return pd.DataFrame()
    day_df = pd.DataFrame(rows)
    day_df = day_df[day_df['total_goals'].notna() & day_df['ou_line'].notna()].copy()

    # 4. 合并历史+当天数据，统一构建特征
    combined = pd.concat([hist_before, day_df], ignore_index=True)
    combined = combined.sort_values(['kickoff_dt', 'fixture_id']).reset_index(drop=True)
    feat_df = build_feature_frame(combined)

    day_ids = set(day_df['fixture_id'].unique())
    pred_df = feat_df[feat_df['fixture_id'].isin(day_ids)].copy()

    # 合并赔率
    merged = pred_df.merge(
        day_df[['fixture_id', 'ou_line', 'over_odds', 'under_odds', 'over_prob_raw', 'under_prob_raw', 'total_goals', 'FTHG', 'FTAG', 'line_category']],
        on=['fixture_id', 'ou_line'], how='left', suffixes=('', '_orig')
    )

    # 5. 预测并比较
    X = merged[feat_cols].fillna(0).replace([np.inf, -np.inf], 0)
    probs = model.predict_proba(X)[:, 1]
    merged['model_prob'] = probs

    out_rows = []
    for _, row in merged.iterrows():
        prob = float(row['model_prob'])
        ou_line = float(row['ou_line'])
        tg = float(row['total_goals'])
        over_odds = float(row['over_odds']) if not pd.isna(row['over_odds']) else np.nan
        under_odds = float(row['under_odds']) if not pd.isna(row['under_odds']) else np.nan
        fair_over = float(row['over_prob_raw']) if not pd.isna(row.get('over_prob_raw')) else 0.5

        if side == 'over':
            pred = '大球' if prob >= threshold else '小球'
            conf = abs(prob - 0.5) * 200
        else:
            pred = '小球' if prob <= 1 - threshold else '大球'
            conf = abs(prob - 0.5) * 200
        conf = min(round(conf, 1), 99.9)
        rec_label, rec_level = recommend_level(conf)

        actual = '大球' if tg > ou_line else ('小球' if tg < ou_line else '走')
        hit = '命中' if pred == actual else ('走' if actual == '走' else '失误')
        kickoff = row.get('kickoff_dt')
        kickoff_str = kickoff.strftime('%m-%d %H:%M') if pd.notna(kickoff) else ''

        out_rows.append({
            'fixture_id': int(row['fixture_id']),
            '日期': target_date,
            '联赛': LEAGUE_ZH.get(str(row['league_code']), str(row['league_code'])),
            '开赛时间': kickoff_str,
            '主队': row['HomeTeam'],
            '客队': row['AwayTeam'],
            '比分': f"{int(row['FTHG'])}-{int(row['FTAG'])}",
            '实际进球': int(tg),
            '大小球盘口': ou_line,
            '盘口分类': row.get('line_category', ''),
            '大球赔率': round(over_odds, 2) if not np.isnan(over_odds) else np.nan,
            '小球赔率': round(under_odds, 2) if not np.isnan(under_odds) else np.nan,
            '大球概率': round(prob * 100, 1),
            '小球概率': round((1 - prob) * 100, 1),
            '市场大球概率': round(fair_over * 100, 1),
            '预测方向': pred,
            '置信区间': confidence_band(conf),
            '置信度': conf,
            '推荐等级': rec_label,
            '推荐级别': rec_level,
            '实际': actual,
            '结果': hit,
            '模型准确率': round(float(cfg.get('bet_acc', 0)) * 100, 1),
        })

    if not out_rows:
        return pd.DataFrame()
    return pd.DataFrame(out_rows)


def build_client(args) -> APIFootballClient:
    base_url = os.environ.get('API_FOOTBALL_BASE_URL', '').rstrip('/')
    api_key = os.environ.get('API_FOOTBALL_KEY', '')
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    return APIFootballClient(base_url, api_key, cache_dir=CACHE_DIR)


def main():
    parser = argparse.ArgumentParser(description='大小球多线统一模型')
    parser.add_argument('--mode', choices=['train', 'predict'], default='predict')
    parser.add_argument('--hours', type=int, default=24)
    parser.add_argument('--max-lookback-days', type=int, default=90)
    parser.add_argument('--min-bets', type=int, default=20)
    args = parser.parse_args()

    allowed_codes = set(LEAGUE_CODE_MAP.values())

    if args.mode == 'train':
        train_multiline_model(args)
    else:
        try:
            client = build_client(args)
            df = predict_multiline(client, allowed_codes, hours=args.hours)
        except Exception as e:
            print(f"[predict] API预测失败 ({e})，使用本地历史数据降级...")
            df = predict_from_local_only(allowed_codes, hours=args.hours)
        if df.empty:
            print("No predictions generated.")
            return
        PREDICTIONS_DIR.mkdir(parents=True, exist_ok=True)
        csv_path = PREDICTIONS_DIR / f'goal_multiline_{args.hours}h.csv'
        xlsx_path = PREDICTIONS_DIR / f'goal_multiline_{args.hours}h.xlsx'
        df.to_csv(csv_path, index=False, encoding='utf-8-sig')
        df.to_excel(xlsx_path, index=False, engine='openpyxl')
        print(f"Saved {len(df)} predictions to {csv_path}")


if __name__ == '__main__':
    main()