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

import argparse
import json
import math
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional

import joblib
import numpy as np
import pandas as pd
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

REQUIRED_COLUMNS = ['Date', 'HomeTeam', 'AwayTeam', 'FTHG', 'FTAG', 'ou_line', 'over_odds', 'under_odds']
FEATURE_COLUMNS = [
    'ou_line', 'over_odds', 'under_odds', 'fair_over_open', 'ou_margin_open',
    'home_odds', 'draw_odds', 'away_odds', 'home_prob', 'draw_prob', 'away_prob', 'home_away_prob_gap',
    'home_gf_avg', 'home_ga_avg', 'away_gf_avg', 'away_ga_avg', 'home_tot_avg', 'away_tot_avg',
    'home_over_rate', 'away_over_rate', 'home_gf_last5', 'home_ga_last5', 'away_gf_last5', 'away_ga_last5',
    'home_over_last5', 'away_over_last5', 'expected_goals_proxy', 'combined_over_rate',
]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description='Train goal-number models against dynamic OU line when data includes ou_line.')
    parser.add_argument('--data', type=Path, required=True, help='CSV with ou_line/over_odds/under_odds columns')
    parser.add_argument('--output-dir', type=Path, default=Path('experiments/goal_number_dynamic_train'))
    parser.add_argument('--min-bets', type=int, default=25)
    return parser.parse_args()


def safe_to_datetime(series: pd.Series) -> pd.Series:
    iso = pd.to_datetime(series, format='%Y-%m-%d', errors='coerce')
    dmy = pd.to_datetime(series, format='%d/%m/%Y', errors='coerce')
    return iso.fillna(dmy)


def extract_prob(odds: float | None) -> float:
    if odds is None or pd.isna(odds) or odds <= 0:
        return np.nan
    return 1.0 / float(odds)


def settle_binary_full_only(total_goals: int, line: float) -> Optional[int]:
    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  # push
    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  # half-win/half-loss region at total_goals == base
    if frac == 0.75:
        if total_goals >= base + 2:
            return 1
        if total_goals <= base:
            return 0
        return None  # half-win/half-loss region at total_goals == base+1
    return None


def mean_or_default(values: List[float], default: float) -> float:
    return float(np.mean(values)) if values else float(default)


def build_features(df: pd.DataFrame) -> pd.DataFrame:
    team_hist = defaultdict(lambda: {'gf': [], 'ga': [], 'tot': [], 'over': []})
    rows = []
    for _, match in df.iterrows():
        home = str(match['HomeTeam'])
        away = str(match['AwayTeam'])
        hh = team_hist[home]
        aa = team_hist[away]
        over_prob = extract_prob(match['over_odds'])
        under_prob = extract_prob(match['under_odds'])
        fair_over = over_prob / (over_prob + under_prob) if not pd.isna(over_prob) and not pd.isna(under_prob) and (over_prob + under_prob) > 0 else np.nan

        home_odds = float(match['home_odds']) if 'home_odds' in match.index and pd.notna(match['home_odds']) else np.nan
        draw_odds = float(match['draw_odds']) if 'draw_odds' in match.index and pd.notna(match['draw_odds']) else np.nan
        away_odds = float(match['away_odds']) if 'away_odds' in match.index and pd.notna(match['away_odds']) else np.nan
        home_prob = extract_prob(home_odds)
        draw_prob = extract_prob(draw_odds)
        away_prob = extract_prob(away_odds)
        denom = sum(v for v in [home_prob, draw_prob, away_prob] if not pd.isna(v))
        if denom > 0:
            home_prob = home_prob / denom if not pd.isna(home_prob) else np.nan
            draw_prob = draw_prob / denom if not pd.isna(draw_prob) else np.nan
            away_prob = away_prob / denom if not pd.isna(away_prob) else np.nan

        row = {
            'ou_line': float(match['ou_line']),
            'over_odds': float(match['over_odds']),
            'under_odds': float(match['under_odds']),
            'fair_over_open': fair_over,
            'ou_margin_open': (over_prob + under_prob - 1.0) if not pd.isna(over_prob) and not pd.isna(under_prob) else np.nan,
            'home_odds': home_odds,
            'draw_odds': draw_odds,
            'away_odds': away_odds,
            'home_prob': home_prob,
            'draw_prob': draw_prob,
            'away_prob': away_prob,
            'home_away_prob_gap': (home_prob - away_prob) if not pd.isna(home_prob) and not pd.isna(away_prob) else np.nan,
            'home_gf_avg': mean_or_default(hh['gf'], 1.4),
            'home_ga_avg': mean_or_default(hh['ga'], 1.4),
            'away_gf_avg': mean_or_default(aa['gf'], 1.2),
            'away_ga_avg': mean_or_default(aa['ga'], 1.2),
            'home_tot_avg': mean_or_default(hh['tot'], 2.6),
            'away_tot_avg': mean_or_default(aa['tot'], 2.6),
            'home_over_rate': mean_or_default(hh['over'], 0.5),
            'away_over_rate': mean_or_default(aa['over'], 0.5),
            'home_gf_last5': mean_or_default(hh['gf'][-5:], mean_or_default(hh['gf'], 1.4)),
            'home_ga_last5': mean_or_default(hh['ga'][-5:], mean_or_default(hh['ga'], 1.4)),
            'away_gf_last5': mean_or_default(aa['gf'][-5:], mean_or_default(aa['gf'], 1.2)),
            'away_ga_last5': mean_or_default(aa['ga'][-5:], mean_or_default(aa['ga'], 1.2)),
            'home_over_last5': mean_or_default(hh['over'][-5:], mean_or_default(hh['over'], 0.5)),
            'away_over_last5': mean_or_default(aa['over'][-5:], mean_or_default(aa['over'], 0.5)),
        }
        row['expected_goals_proxy'] = float(np.nanmean([
            row['home_gf_avg'], row['away_ga_avg'], row['away_gf_avg'], row['home_ga_avg']
        ]))
        row['combined_over_rate'] = float(np.nanmean([
            row['home_over_rate'], row['away_over_rate'], row['home_over_last5'], row['away_over_last5']
        ]))
        rows.append(row)

        home_goals = float(match['FTHG'])
        away_goals = float(match['FTAG'])
        total_goals = home_goals + away_goals
        hh['gf'].append(home_goals); hh['ga'].append(away_goals); hh['tot'].append(total_goals); hh['over'].append(1.0 if total_goals > 2.5 else 0.0)
        aa['gf'].append(away_goals); aa['ga'].append(home_goals); aa['tot'].append(total_goals); aa['over'].append(1.0 if total_goals > 2.5 else 0.0)

    return pd.DataFrame(rows, columns=FEATURE_COLUMNS)


def build_models() -> Dict[str, Pipeline]:
    return {
        'LR': Pipeline([('imp', SimpleImputer(strategy='median')), ('scaler', StandardScaler()), ('clf', LogisticRegression(max_iter=2000, class_weight='balanced', C=0.5))]),
        'RF': Pipeline([('imp', SimpleImputer(strategy='median')), ('clf', RandomForestClassifier(n_estimators=400, max_depth=6, min_samples_leaf=8, random_state=42, class_weight='balanced_subsample'))]),
        'ET': Pipeline([('imp', SimpleImputer(strategy='median')), ('clf', ExtraTreesClassifier(n_estimators=500, max_depth=7, min_samples_leaf=6, random_state=42, class_weight='balanced'))]),
        'HGB': Pipeline([('imp', SimpleImputer(strategy='median')), ('clf', HistGradientBoostingClassifier(max_depth=5, learning_rate=0.05, max_iter=300, random_state=42))]),
    }


def main() -> None:
    args = parse_args()
    df = pd.read_csv(args.data, low_memory=False)
    missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
    if missing:
        raise SystemExit(f'Missing required columns: {missing}')

    df = df.copy()
    df['date_obj'] = safe_to_datetime(df['Date'])
    df = df.dropna(subset=['date_obj', 'HomeTeam', 'AwayTeam', 'FTHG', 'FTAG', 'ou_line', 'over_odds', 'under_odds']).sort_values('date_obj').reset_index(drop=True)
    df['total_goals'] = df['FTHG'].astype(float) + df['FTAG'].astype(float)
    df['label'] = [settle_binary_full_only(int(tg), float(line)) for tg, line in zip(df['total_goals'], df['ou_line'])]

    dropped = int(df['label'].isna().sum())
    df = df.dropna(subset=['label']).reset_index(drop=True)
    df['label'] = df['label'].astype(int)
    if len(df) < 200:
        raise SystemExit(f'Not enough settled rows after dropping push/half results: {len(df)}')

    X = build_features(df)
    y = df['label']
    split = int(len(df) * 0.8)
    X_train, X_test = X.iloc[:split], X.iloc[split:]
    y_train, y_test = y.iloc[:split], y.iloc[split:]

    best = None
    best_model = None
    for model_name, model in build_models().items():
        model.fit(X_train, y_train)
        probs = model.predict_proba(X_test)[:, 1]
        preds = (probs >= 0.5).astype(int)
        for thr in np.arange(0.50, 0.81, 0.01):
            mask = (probs >= thr) | (probs <= 1 - thr)
            bet_count = int(mask.sum())
            if bet_count < args.min_bets:
                continue
            bet_acc = float(accuracy_score(y_test[mask], preds[mask]))
            rec = {
                'model': model_name,
                'threshold': round(float(thr), 2),
                'bet_count': bet_count,
                'bet_acc': bet_acc,
                'overall_acc': float(accuracy_score(y_test, preds)),
                'rows_after_filter': int(len(df)),
                'dropped_push_or_half_rows': dropped,
                'label_policy': 'binary_full_win_only_drop_push_and_half',
            }
            if best is None or bet_acc > best['bet_acc'] or (bet_acc == best['bet_acc'] and bet_count > best['bet_count']):
                best = rec
                best_model = model
    if best is None or best_model is None:
        raise SystemExit('No candidate satisfied min_bets')

    args.output_dir.mkdir(parents=True, exist_ok=True)
    joblib.dump(best_model, args.output_dir / 'goal_number_dynamic_pipeline.pkl')
    (args.output_dir / 'goal_number_dynamic_summary.json').write_text(json.dumps(best, ensure_ascii=False, indent=2), encoding='utf-8')
    print(json.dumps(best, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()
