#!/usr/bin/env python3
from __future__ import annotations

import os
import sys
from datetime import datetime, timedelta, timezone
import json
from pathlib import Path
from typing import Dict, List, Optional, Tuple

import joblib
import numpy as np
import pandas as pd
import requests

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

from train_models import FEATURE_COLUMNS, create_features  # noqa: E402
from optimize_priority_models import create_enhanced_features  # noqa: E402
from team_name_zh import ensure_team_name_map  # noqa: E402

BEIJING_TZ = timezone(timedelta(hours=8))
DEFAULT_HOURS = 24
PREDICTIONS_DIR = REPO_ROOT / 'predictions'
ARCHIVE_DIR = PREDICTIONS_DIR / 'archive'

LEAGUE_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',
    ('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': '日职联',
    'k2': '韩国K2',
    'm1': '美职联',
    'p1': '葡超',
    'r1': '俄超',
    'sc1': '苏超',
    'sp1': '西甲',
    'tr1': '土超',
    'uz1': '乌兹别克超',
}

# 亚盘预测模式：
# - model: 使用大小球模型预测（仅在模型有价值时）
# - bookmaker: 直接用庄家亚盘赔率方向（用于数据不完整或模型弱于庄家的联赛）
USE_BOOKMAKER_AH: set = {
    # 数据不完整（历史数据缺O/U赔率列）的联赛
    'a1', 'br1', 'd2', 'e0', 'f2', 'g1', 'j1', 'm1', 'r1', 'uz1', 'k2',
    # 数据完整但模型弱于庄家的联赛
    'd1', 'i1', 'e1', 'e3',
}

TEAM_ZH: Dict[str, str] = {
    'Western Sydney Wanderers': '西悉尼流浪者', 'Adelaide United': '阿德莱德联',
    'Neftchi': '纳萨夫油工', 'Lokomotiv': '火车头', 'Nasaf': '纳萨夫', 'Surkhon': '苏尔汉',
    'Sogdiana': '索格迪亚纳', 'Pakhtakor': '棉农', 'Hannover 96': '汉诺威96',
    'Eintracht Braunschweig': '布伦瑞克', 'Karlsruher SC': '卡尔斯鲁厄',
    'SpVgg Greuther Fürth': '菲尔特', 'Rodez': '罗德兹', 'Bastia': '巴斯蒂亚',
    'Laval': '拉瓦勒', 'Grenoble': '格勒诺布尔', 'Boulogne': '布洛涅', 'Nancy': '南锡',
    'PAU': '波城', 'Montpellier': '蒙彼利埃', 'Amiens': '亚眠', 'Le Mans': '勒芒',
    'Clermont Foot': '克莱蒙', 'RED Star FC 93': '红星', 'Lens': '朗斯', 'Angers': '昂热',
    'Bournemouth': '伯恩茅斯', 'Manchester United': '曼联', 'Auckland': '奥克兰FC',
    'Macarthur': '麦克阿瑟FC', 'Brisbane Roar': '布里斯班狮吼',
    'Wellington Phoenix': '惠灵顿凤凰', 'Fagiano Okayama': '冈山绿雉',
    'V-varen Nagasaki': '长崎成功丸', 'Avispa Fukuoka': '福冈黄蜂',
    'Gamba Osaka': '大阪钢巴', 'Melbourne Victory': '墨尔本胜利',
    'Central Coast Mariners': '中央海岸水手', 'Akhmat': '格罗兹尼艾哈迈特',
    'FC Rostov': '罗斯托夫', 'SV Elversberg': '埃尔弗斯堡', 'Arminia Bielefeld': '比勒费尔德',
    '1. FC Nürnberg': '纽伦堡', '1. FC Kaiserslautern': '凯泽斯劳滕',
    'SC Paderborn 07': '帕德博恩', 'Dynamo Dresden': '德累斯顿迪纳摩', 'Brighton': '布莱顿',
    'Liverpool': '利物浦', 'Guingamp': '甘冈', 'Reims': '兰斯', 'Estac Troyes': '特鲁瓦',
    'Dunkerque': '敦刻尔克', 'CSKA Moscow': '莫斯科中央陆军',
    'Dinamo Makhachkala': '马哈奇卡拉迪纳摩', 'Fulham': '富勒姆', 'Burnley': '伯恩利',
    'Manchester City': '曼城', 'Crystal Palace': '水晶宫', 'FC Krasnodar': '克拉斯诺达尔',
    'Nizhny Novgorod': '下诺夫哥罗德', 'Toulouse': '图卢兹', 'Lorient': '洛里昂',
    'Toronto FC': '多伦多FC', 'Columbus Crew': '哥伦布机员', 'Everton': '埃弗顿',
    'Chelsea': '切尔西', 'Baltika': '巴蒂卡', 'FC Sochi': '索契', 'Auxerre': '欧塞尔',
    'Stade Brestois 29': '布雷斯特', 'Saint Etienne': '圣埃蒂安', 'Annecy': '阿讷西',
    'RB Bragantino': '布拉干蒂诺红牛', 'Botafogo': '博塔弗戈', 'SV Darmstadt 98': '达姆施塔特',
    'FC Schalke 04': '沙尔克04', 'Leeds': '利兹联', 'Brentford': '布伦特福德', 'Nice': '尼斯',
    'Paris Saint Germain': '巴黎圣日耳曼', 'Philadelphia Union': '费城联合',
    'Chicago Fire': '芝加哥火焰', 'Fluminense': '弗鲁米嫩塞', 'Atletico-MG': '米内罗竞技',
    'Nashville SC': '纳什维尔SC', 'Orlando City SC': '奥兰多城', 'Atlanta United FC': '亚特兰大联',
    'DC United': '华盛顿联', 'Charlotte': '夏洛特FC', 'New York Red Bulls': '纽约红牛',
    'Sao Paulo': '圣保罗', 'Palmeiras': '帕尔梅拉斯', 'FC Dallas': '达拉斯FC',
    'Houston Dynamo': '休斯敦迪纳摩', 'Sporting Kansas City': '堪萨斯城竞技',
    'Colorado Rapids': '科罗拉多急流', 'St. Louis City': '圣路易斯城',
    'New England Revolution': '新英格兰革命', 'Austin': '奥斯汀FC', 'Los Angeles FC': '洛杉矶FC',
    'Vancouver Whitecaps': '温哥华白帽', 'San Jose Earthquakes': '圣何塞地震',
}


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:
    if conf >= 70:
        return '高'
    if conf >= 60:
        return '中'
    return '低'


def parse_match_winner(bookmakers: List[dict]) -> tuple[Optional[float], Optional[float], Optional[float]]:
    for bookmaker in bookmakers:
        for bet in bookmaker.get('bets', []):
            if bet.get('name') != 'Match Winner':
                continue
            vals = {v.get('value'): v.get('odd') for v in bet.get('values', [])}
            if vals.get('Home') and vals.get('Away'):
                return (
                    float(vals['Home']),
                    float(vals['Draw']) if vals.get('Draw') else np.nan,
                    float(vals['Away']),
                )
    return None, None, None


PREFERRED_AH_BOOKMAKERS = {
    'Pinnacle': 0,
    'SBO': 1,
    '188Bet': 2,
    'Bet365': 3,
    'Marathonbet': 4,
    '1xBet': 5,
}


def _asian_price_band(home_odd: float, away_odd: float) -> int:
    if 1.80 <= home_odd <= 2.10 and 1.80 <= away_odd <= 2.10:
        return 0
    if 1.70 <= home_odd <= 2.25 and 1.70 <= away_odd <= 2.25:
        return 1
    return 2


def _extract_asian_candidates(bookmaker: dict) -> List[dict]:
    candidates: List[dict] = []
    for bet in bookmaker.get('bets', []):
        if bet.get('name') != 'Asian Handicap':
            continue
        grouped: Dict[str, dict] = {}
        for value in bet.get('values', []):
            raw = value.get('value', '')
            odd = value.get('odd')
            parts = raw.split()
            if len(parts) < 2 or odd in (None, ''):
                continue
            side = parts[0]
            try:
                num = float(parts[1].replace('+', ''))
                price = float(odd)
            except Exception:
                continue
            grouped.setdefault(parts[1], {'handicap': num})[side] = price
        for row in grouped.values():
            if 'Home' not in row or 'Away' not in row:
                continue
            home_odd = float(row['Home'])
            away_odd = float(row['Away'])
            handicap = float(row['handicap'])
            candidates.append({
                'bookmaker': bookmaker.get('name', ''),
                'handicap': 0.0 if abs(handicap) < 1e-9 else handicap,
                'home_odd': home_odd,
                'away_odd': away_odd,
                'price_band': _asian_price_band(home_odd, away_odd),
                'balance_gap': abs(home_odd - away_odd),
                'evenness_gap': abs(home_odd - 1.95) + abs(away_odd - 1.95),
            })
    return candidates


def _extract_pinnacle_ah(bookmakers: List[dict]) -> dict:
    """
    专门提取 Pinnacle 的亚洲让球赔率（用于训练/预测数据源统一）
    API-Football 格式: "Home -1" 和 "Away -1" 是同一条盘口（主队让1球，handicap=-1）
    返回: {handicap, home_odd, away_odd, closing_home, closing_away, opening_home, opening_away, steam}
    """
    for bookmaker in bookmakers:
        if bookmaker.get('name') != 'Pinnacle':
            continue
        for bet in bookmaker.get('bets', []):
            if bet.get('name') != 'Asian Handicap':
                continue
            # API格式：同一条盘口两边 handicap 符号相同（如 Home -1 和 Away -1）
            # 用 handicap 原始值作为分组 key
            grouped: Dict[str, dict] = {}
            for value in bet.get('values', []):
                raw = value.get('value', '')
                odd = value.get('odd')
                last_odd = value.get('last')
                opening_odd = value.get('opening')
                parts = raw.split()
                if len(parts) < 2 or odd in (None, ''):
                    continue
                side = parts[0]  # 'Home' or 'Away'
                try:
                    handicap_val = float(parts[1])  # 如 '-1', '+0.5', '-0.75'
                    odd_val = float(odd)
                    last_val = float(last_odd) if last_odd not in (None, '') else None
                    open_val = float(opening_odd) if opening_odd not in (None, '') else None
                except:
                    continue
                entry = grouped.setdefault(parts[1], {'handicap': handicap_val})
                entry[side] = odd_val
                if last_val is not None:
                    entry['closing_' + side] = last_val
                if open_val is not None:
                    entry['opening_' + side] = open_val

            # 找同时有 Home 和 Away 的那条盘口
            for row in grouped.values():
                if 'Home' not in row or 'Away' not in row:
                    continue
                h = float(row['handicap'])
                # steam: 收盘相对开盘的变化（正=主队更热门）
                steam = None
                if row.get('closing_Home') and row.get('opening_Home'):
                    steam = round(float(row['closing_Home']) - float(row['opening_Home']), 3)
                return {
                    'handicap': h,
                    'home_odd': float(row['Home']),
                    'away_odd': float(row['Away']),
                    'closing_home': row.get('closing_Home'),
                    'closing_away': row.get('closing_Away'),
                    'opening_home': row.get('opening_Home'),
                    'opening_away': row.get('opening_Away'),
                    'steam': steam,
                }
    return {}


def parse_asian_handicap(bookmakers: List[dict]) -> tuple[Optional[float], Optional[float], Optional[float]]:
    candidates: List[dict] = []
    for bookmaker in bookmakers:
        candidates.extend(_extract_asian_candidates(bookmaker))

    if not candidates:
        return None, None, None

    candidates.sort(
        key=lambda row: (
            row['price_band'],
            PREFERRED_AH_BOOKMAKERS.get(row['bookmaker'], 999),
            row['balance_gap'],
            row['evenness_gap'],
            abs(row['handicap']),
        )
    )
    best = candidates[0]
    return best['handicap'], best['home_odd'], best['away_odd']


def fetch_json(session: requests.Session, base_url: str, endpoint: str, params: dict) -> dict:
    r = session.get(base_url + endpoint, params=params, timeout=60)
    r.raise_for_status()
    return r.json()


def get_output_paths(hours: int = DEFAULT_HOURS, output_dir: Path | None = None) -> tuple[Path, Path]:
    output_dir = output_dir or PREDICTIONS_DIR
    output_dir.mkdir(parents=True, exist_ok=True)
    stem = f'predictions_{hours}h_v4'
    return output_dir / f'{stem}.csv', output_dir / f'{stem}.xlsx'


def generate_predictions(hours: int = DEFAULT_HOURS, models_dir: Path | None = None) -> pd.DataFrame:
    base_url = os.environ['API_FOOTBALL_BASE_URL'].rstrip('/')
    api_key = os.environ['API_FOOTBALL_KEY']
    models_dir = models_dir or (REPO_ROOT / 'models' / 'v4_models_serving_current')

    session = requests.Session()
    session.headers.update({'x-apisports-key': api_key})

    now = datetime.now(timezone.utc)
    generated_at_bj = now.astimezone(BEIJING_TZ)
    end = now + timedelta(hours=hours)

    fixtures: List[dict] = []
    current_date = now.date()
    while current_date <= end.date():
        payload = fetch_json(session, base_url, '/fixtures', {'date': current_date.isoformat()})
        for item in payload.get('response', []):
            league_key = (item['league']['country'], item['league']['name'])
            if league_key not in LEAGUE_MAP:
                continue
            kickoff = datetime.fromtimestamp(item['fixture']['timestamp'], tz=timezone.utc)
            if now <= kickoff <= end:
                item['code'] = LEAGUE_MAP[league_key]
                fixtures.append(item)
        current_date += timedelta(days=1)

    prepared: List[dict] = []
    for item in fixtures:
        odds_payload = fetch_json(session, base_url, '/odds', {'fixture': item['fixture']['id']})
        response = odds_payload.get('response', [])
        if not response:
            continue
        bookmakers = response[0].get('bookmakers', [])
        home_odds, draw_odds, away_odds = parse_match_winner(bookmakers)
        handicap, home_handicap_odd, away_handicap_odd = parse_asian_handicap(bookmakers)
        # 提取 Pinnacle 亚指（用于训练/预测数据源统一）
        pinnacle_data = _extract_pinnacle_ah(bookmakers)
        if home_odds is None or away_odds is None or handicap is None:
            continue
        item['odds'] = (home_odds, draw_odds, away_odds, handicap, home_handicap_odd, away_handicap_odd)
        item['pinnacle'] = pinnacle_data
        prepared.append(item)

    team_name_map = ensure_team_name_map(
        [
            item['teams']['home']['name']
            for item in prepared
        ] + [
            item['teams']['away']['name']
            for item in prepared
        ]
    )

    rows: List[dict] = []
    for code in sorted({item['code'] for item in prepared}):
        hist_path = REPO_ROOT / 'historical_data' / f'{code}_history.csv'
        if not hist_path.exists():
            print(f"[{code}] No history file, skipping", file=sys.stderr)
            continue
        subset = [item for item in prepared if item['code'] == code]
        hist = pd.read_csv(hist_path)
        model_path = models_dir / f'{code}_pro_v4_model.pkl'
        scaler_path = models_dir / f'{code}_pro_v4_scaler.pkl'
        config_path = models_dir / f'{code}_pro_v4_config.pkl'
        if not model_path.exists() or not scaler_path.exists() or not config_path.exists():
            print(f"[{code}] Missing model files, skipping", file=sys.stderr)
            continue
        model = joblib.load(model_path)
        scaler = joblib.load(scaler_path)
        config = joblib.load(config_path)
        feature_mode = config.get('feature_mode', 'baseline_v4')

        future_rows = []
        key_to_meta = {}
        for item in subset:
            home_odds, draw_odds, away_odds, handicap, home_handicap_odd, away_handicap_odd = item['odds']
            future = {c: np.nan for c in hist.columns}
            date_str = datetime.fromtimestamp(item['fixture']['timestamp'], tz=timezone.utc).strftime('%Y-%m-%d')
            home = item['teams']['home']['name']
            away = item['teams']['away']['name']
            future['Date'] = date_str
            future['HomeTeam'] = home
            future['AwayTeam'] = away
            # 关键: AHh和赔率列需要取反, 与历史数据(训练时)一致
            for col, val in [
                ('AvgH', home_odds), ('AvgA', away_odds), ('AvgD', draw_odds),
                ('B365H', home_odds), ('B365D', draw_odds), ('B365A', away_odds),
                ('AHh', handicap), ('B365AHH', home_handicap_odd), ('B365AHA', away_handicap_odd),
            ]:
                if col in hist.columns:
                    # AHh和亚盘赔率列取反(与历史数据negate一致)
                    if col in ('AHh', 'B365AHH', 'B365AHA'):
                        future[col] = -val if pd.notna(val) else val
                    else:
                        future[col] = val
            # 写入 Pinnacle 亚指（统一训练/预测数据源）
            pinnacle = item.get('pinnacle')
            if pinnacle and pinnacle.get('handicap') is not None:
                h = pinnacle['handicap']
                if 'PinnAHh' in hist.columns:
                    future['PinnAHh'] = -h if pd.notna(h) else h
                if 'PinnAHH' in hist.columns:
                    future['PinnAHH'] = pinnacle.get('home_odd')
                if 'PinnAHA' in hist.columns:
                    future['PinnAHA'] = pinnacle.get('away_odd')
                # 市场特征: Pinnacle closing odds（收盘线）
                if pinnacle.get('closing_home') and pinnacle.get('closing_away'):
                    if 'PinnAHh_closing' in hist.columns:
                        future['PinnAHh_closing'] = pinnacle.get('closing_home')
                    if 'PinnAHH_closing' in hist.columns:
                        future['PinnAHH_closing'] = pinnacle.get('closing_home')
                    if 'PinnAHA_closing' in hist.columns:
                        future['PinnAHA_closing'] = pinnacle.get('closing_away')
                # 市场特征: 盘口变动（closing vs opening）
                if pinnacle.get('opening_home') and pinnacle.get('closing_home'):
                    if 'PinnSteam' in hist.columns:
                        steam = pinnacle.get('closing_home') - pinnacle.get('opening_home')
                        future['PinnSteam'] = steam
            future_rows.append(future)
            key_to_meta[(home, away, date_str)] = item

        combo = pd.concat([hist, pd.DataFrame(future_rows)], ignore_index=True)
        combo['date_obj'] = pd.to_datetime(combo['Date'], errors='coerce', format='%Y-%m-%d')
        mask = combo['date_obj'].isna()
        combo.loc[mask, 'date_obj'] = pd.to_datetime(combo.loc[mask, 'Date'], errors='coerce', dayfirst=True)
        combo = combo.sort_values('date_obj').reset_index(drop=True)
        if feature_mode == 'enhanced_v1':
            features = create_enhanced_features(combo)
        else:
            features = create_features(combo)

        use_bookmaker = code in USE_BOOKMAKER_AH
        future_mask = combo['Date'].isin([k[2] for k in key_to_meta.keys()]) & combo['HomeTeam'].isin([k[0] for k in key_to_meta.keys()])
        for idx, row in combo[future_mask].iterrows():
            meta = key_to_meta.get((row['HomeTeam'], row['AwayTeam'], row['Date']))
            if not meta:
                continue
            home_odds, draw_odds, away_odds, handicap, home_handicap_odd, away_handicap_odd = meta['odds']
            kickoff_bj = datetime.fromtimestamp(meta['fixture']['timestamp'], tz=timezone.utc).astimezone(BEIJING_TZ)

            if use_bookmaker:
                # 庄家亚盘方向：赔率低=庄家更看好=预测方向
                # AH赔率：home_handicap_odd是主队让球赔率，away是客队受让赔率
                # 如果home_handicap_odd < away_handicap_odd，庄家更看好主队上盘
                bm_prob_up = 1.0 / float(home_handicap_odd) if float(home_handicap_odd) > 1 else 0.5
                bm_prob_down = 1.0 / float(away_handicap_odd) if float(away_handicap_odd) > 1 else 0.5
                bm_total = bm_prob_up + bm_prob_down
                if bm_total > 0:
                    bm_prob_up = bm_prob_up / bm_total
                    bm_prob_down = bm_prob_down / bm_total
                prob_up = bm_prob_up * 100
                prob_down = bm_prob_down * 100
                conf = max(prob_up, prob_down)
                pred_raw = '上盘' if home_handicap_odd < away_handicap_odd else '下盘'
                # 让球约定: AHh<0=主队让球=上盘, AHh>0=客队让球=上盘, AHh=0=平手盘=主队上盘
                # AHh<0: 上盘=主队, 下盘=客队 → 上盘押主队, 下盘押客队
                # AHh>0: 上盘=客队, 下盘=主队 → 上盘押客队, 下盘押主队
                # AHh=0: 上盘=主队, 下盘=客队 → 上盘押主队, 下盘押客队
                h = float(handicap)
                if h <= 0:
                    pred = '主队' if pred_raw == '上盘' else '客队'
                else:
                    pred = '客队' if pred_raw == '上盘' else '主队'
                rec_label, rec_level = recommend_level(conf)
                model_type = '庄家亚盘'
                model_acc = np.nan  # 庄家准确率无法从模型训练集获得
            else:
                feature_names = config.get('features', FEATURE_COLUMNS)
                x = pd.DataFrame([features.iloc[idx].reindex(feature_names)]).fillna(0)
                probs = model.predict_proba(x)[0]
                prob_up = float(probs[1] * 100)
                prob_down = float(probs[0] * 100)
                conf = max(prob_up, prob_down)
                pred_raw = '上盘' if prob_up >= prob_down else '下盘'
                h = float(handicap)
                if h <= 0:
                    pred = '主队' if pred_raw == '上盘' else '客队'
                else:
                    pred = '客队' if pred_raw == '上盘' else '主队'
                rec_label, rec_level = recommend_level(conf)
                model_type = config['model_name']
                model_acc = round(float(config['bet_acc']) * 100, 1)

            rows.append({
                'fixture_id': int(meta['fixture']['id']),
                '联赛代码': code,
                '预测生成时间': generated_at_bj.strftime('%Y-%m-%d %H:%M:%S'),
                '预测日期': generated_at_bj.strftime('%Y-%m-%d'),
                '预测窗口小时': int(hours),
                '联赛': LEAGUE_ZH.get(code, code),
                '开赛时间': kickoff_bj.strftime('%m-%d %H:%M'),
                '主队': team_name_map.get(str(row['HomeTeam']), TEAM_ZH.get(row['HomeTeam'], row['HomeTeam'])),
                '客队': team_name_map.get(str(row['AwayTeam']), TEAM_ZH.get(row['AwayTeam'], row['AwayTeam'])),
                '主队原名': row['HomeTeam'],
                '客队原名': row['AwayTeam'],
                '让球数': handicap,
                '上盘赔率': round(float(home_handicap_odd), 2),
                '下盘赔率': round(float(away_handicap_odd), 2),
                '主胜欧赔': round(float(home_odds), 2),
                '平局欧赔': round(float(draw_odds), 2) if pd.notna(draw_odds) else np.nan,
                '客胜欧赔': round(float(away_odds), 2),
                '上盘概率': round(prob_up, 1),
                '下盘概率': round(prob_down, 1),
                '预测方向': pred,
                '置信区间': confidence_band(conf),
                '置信度': round(conf, 1),
                '推荐等级': rec_label,
                '推荐级别': rec_level,
                '模型类型': model_type,
                '模型准确率': model_acc,
            })

    if not rows:
        return pd.DataFrame(columns=[
            'fixture_id', '联赛代码', '预测生成时间', '预测日期', '预测窗口小时', '联赛', '开赛时间', '主队', '客队',
            '主队原名', '客队原名', '让球数', '上盘赔率', '下盘赔率', '主胜欧赔', '平局欧赔', '客胜欧赔',
            '上盘概率', '下盘概率', '预测方向', '置信区间', '置信度', '推荐等级', '推荐级别', '模型类型', '模型准确率'
        ])
    
    out = pd.DataFrame(rows).sort_values(['开赛时间', '置信度'], ascending=[True, False]).reset_index(drop=True)
    return out


def save_outputs(df: pd.DataFrame, output_dir: Path | None = None, hours: int = DEFAULT_HOURS) -> tuple[Path, Path]:
    csv_path, xlsx_path = get_output_paths(hours, output_dir)
    csv_path.parent.mkdir(parents=True, exist_ok=True)
    df.to_csv(csv_path, index=False, encoding='utf-8-sig')
    with pd.ExcelWriter(xlsx_path, engine='openpyxl') as writer:
        df.to_excel(writer, index=False, sheet_name=f'{hours}h_predictions')
        df[df['推荐级别'] >= 2].to_excel(writer, index=False, sheet_name='high_confidence')

    if not df.empty:
        generated_at = pd.to_datetime(df['预测生成时间'].iloc[0])
    else:
        generated_at = datetime.now(BEIJING_TZ)
    archive_day = ARCHIVE_DIR / generated_at.strftime('%Y-%m-%d')
    archive_day.mkdir(parents=True, exist_ok=True)
    stamp = generated_at.strftime('%Y%m%d_%H%M%S')
    archive_csv = archive_day / f'{stamp}_{hours}h_predictions.csv'
    df.to_csv(archive_csv, index=False, encoding='utf-8-sig')
    meta = {
        'generated_at': generated_at.strftime('%Y-%m-%d %H:%M:%S'),
        'hours': hours,
        'rows': int(len(df)),
        'csv': archive_csv.name,
    }
    (archive_day / f'{stamp}_{hours}h_predictions.json').write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
    return csv_path, xlsx_path


def main() -> None:
    df = generate_predictions(hours=DEFAULT_HOURS)
    csv_path, xlsx_path = save_outputs(df, hours=DEFAULT_HOURS)
    print(f'rows={len(df)}')
    print(csv_path)
    print(xlsx_path)


if __name__ == '__main__':
    main()
