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

import html
import json
import os
import re
import threading
import traceback
from datetime import datetime, timedelta, timezone
from difflib import SequenceMatcher
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse, quote

import pandas as pd
import requests

from generate_predictions_48h import REPO_ROOT, save_outputs, generate_predictions, get_output_paths, DEFAULT_HOURS, BEIJING_TZ, parse_asian_handicap
from goal_number_api_dynamic import build_client as build_goal_client, train_dynamic_model, predict_dynamic_model

PRED_DIR = REPO_ROOT / 'predictions'
ARCHIVE_DIR = PRED_DIR / 'archive'
CSV_PATH, XLSX_PATH = get_output_paths(DEFAULT_HOURS, PRED_DIR)
GOAL_CSV_PATH = PRED_DIR / f'goal_number_api_dynamic_{DEFAULT_HOURS}h.csv'
OVERLAP_CSV_PATH = PRED_DIR / f'overlap_pro_ah.csv'
DOWNLOADS_DIR = REPO_ROOT / 'scripts' / 'static' / 'downloads'
OVERLAP_DISPLAY_COLS = [
    '开赛时间', '联赛', '主队', '客队', '让球说明',
    '预测方向', '上盘概率', '下盘概率', '置信度', '推荐等级',
    '历史准确率', '模型', '实际比分', '预判结果',
]
OVERLAP_NUMERIC_COLS = {'上盘概率', '下盘概率', '置信度', '历史准确率'}
OVERLAP_SELECT_COLS = {'联赛', '预测方向', '推荐等级'}

_TEAM_NAME_CACHE: dict[str, str] | None = None
_TEAM_ALIAS_INDEX: dict[str, str] | None = None
TEAM_ALIAS_PATH = REPO_ROOT / 'artifacts' / 'team_aliases_manual.json'


def _cached_team_name_map(names) -> dict[str, str]:
    global _TEAM_NAME_CACHE
    if _TEAM_NAME_CACHE is None:
        _TEAM_NAME_CACHE = {}
        try:
            from team_name_zh import CACHE_PATH
            if CACHE_PATH.exists():
                _TEAM_NAME_CACHE.update(json.loads(CACHE_PATH.read_text(encoding='utf-8')))
        except Exception:
            pass
    return {str(n): _TEAM_NAME_CACHE.get(str(n), str(n)) for n in names}


def _basic_normalize_team_name(name: str) -> str:
    s = str(name or '').strip().lower()
    if not s:
        return ''
    replacements = {
        '足球俱乐部': '', '俱乐部': '', '足球': '', '体育': '', '联队': '',
        'fc': '', 'cf': '', 'afc': '', 'sc': '', 'sv': '', 'fk': '',
        'club': '', ' ': '', '.': '', '-': '', '_': '',
        '柏林联盟': '柏林联合', '圣保利足球俱乐部': '圣保利', '1科隆': '科隆',
        '阿鲁卡': '阿罗卡', '格拉纳达cf': '格拉纳达', '布莱克本流浪者': '布莱克本',
        '巴萨克希尔': '巴沙克谢希尔', '巴沙克希尔': '巴沙克谢希尔',
        'basaksehir': 'başakşehir'
    }
    for k, v in replacements.items():
        s = s.replace(k, v)
    s = re.sub(r'[^0-9a-z\u00c0-\u017f\u4e00-\u9fff]+', '', s)
    return s


def _team_alias_index() -> dict[str, str]:
    global _TEAM_ALIAS_INDEX
    if _TEAM_ALIAS_INDEX is not None:
        return _TEAM_ALIAS_INDEX
    idx: dict[str, str] = {}
    try:
        if TEAM_ALIAS_PATH.exists():
            raw = json.loads(TEAM_ALIAS_PATH.read_text(encoding='utf-8'))
            for canonical, aliases in raw.items():
                canon_norm = _basic_normalize_team_name(canonical)
                idx[canon_norm] = canon_norm
                for alias in aliases or []:
                    idx[_basic_normalize_team_name(alias)] = canon_norm
    except Exception:
        pass
    _TEAM_ALIAS_INDEX = idx
    return idx


def compute_overlap_csv() -> pd.DataFrame:
    """合并 pro_v8 与 ah_1year_v6 预测，只保留两模型方向一致的比赛。
    使用 API-Football fixture 对齐，避免球队别名导致同场比赛未命中。
    """
    pro_csv = REPO_ROOT / 'predictions' / 'pro_v8_24h.csv'
    ah_csv = REPO_ROOT / 'predictions' / 'ah_1year_v6_24h.csv'
    if not pro_csv.exists() or not ah_csv.exists():
        return pd.DataFrame()

    pro = pd.read_csv(pro_csv, encoding='utf-8-sig')
    ah = pd.read_csv(ah_csv, encoding='utf-8-sig')
    if pro.empty or ah.empty:
        return pd.DataFrame()

    def attach_fixture(df: pd.DataFrame) -> pd.DataFrame:
        df = df.copy()
        fixture_ids, home_zhs, away_zhs = [], [], []
        for _, row in df.iterrows():
            fx = _best_fixture_for_row(row)
            fixture_ids.append(fx.get('fixture_id') if fx else None)
            home_zhs.append((fx or {}).get('home_zh', row.get('主队', '')))
            away_zhs.append((fx or {}).get('away_zh', row.get('客队', '')))
        df['_fixture_id'] = fixture_ids
        df['主队_zh'] = home_zhs
        df['客队_zh'] = away_zhs
        df = df[df['_fixture_id'].notna()].copy()
        return df

    pro2 = attach_fixture(pro)
    ah2 = attach_fixture(ah)
    if pro2.empty or ah2.empty:
        OVERLAP_CSV_PATH.write_text('', encoding='utf-8')
        return pd.DataFrame()

    ah_rename = {c: f'{c}_ah' for c in ah2.columns if c not in ('_fixture_id', '主队_zh', '客队_zh')}
    ah2 = ah2.rename(columns=ah_rename)

    merged = pd.merge(pro2, ah2, on='_fixture_id', how='inner', suffixes=('', '_ah'))
    if merged.empty:
        OVERLAP_CSV_PATH.write_text('', encoding='utf-8')
        return pd.DataFrame()

    agree = merged[merged['预测方向'] == merged['预测方向_ah']].copy()
    if agree.empty:
        OVERLAP_CSV_PATH.write_text('', encoding='utf-8')
        return pd.DataFrame()

    if '置信度' in agree.columns and '置信度_ah' in agree.columns:
        agree['_avg_conf'] = (agree['置信度'].fillna(0) + agree['置信度_ah'].fillna(0)) / 2
    elif '上盘概率' in agree.columns and '上盘概率_ah' in agree.columns:
        agree['_avg_conf'] = (agree['上盘概率'].fillna(0) + agree['上盘概率_ah'].fillna(0)) / 2
    else:
        agree['_avg_conf'] = agree.get('置信度', 0).fillna(0)

    def joint_rec_level(conf: float) -> str:
        if conf >= 72:
            return '★★★ 强烈推荐'
        if conf >= 62:
            return '★★ 推荐'
        if conf >= 52:
            return '★ 谨慎推荐'
        return '✗ 不建议'

    agree['置信度'] = agree['_avg_conf'].round(1)
    agree['推荐等级'] = agree['置信度'].apply(joint_rec_level)
    if '历史准确率' in agree.columns and '历史准确率_ah' in agree.columns:
        agree['历史准确率'] = ((agree['历史准确率'].fillna(0) + agree['历史准确率_ah'].fillna(0)) / 2).round(1)
    agree['模型'] = '重合(pro_v8+ah_1year_v6)'
    agree = agree.sort_values('开赛时间')

    out_cols = ['开赛时间', '联赛', '主队_zh', '客队_zh', '让球说明',
                '预测方向', '上盘概率', '下盘概率', '置信度', '推荐等级',
                '历史准确率', '模型']
    out = agree[[c for c in out_cols if c in agree.columns]].copy()
    out = out.rename(columns={'主队_zh': '主队', '客队_zh': '客队'})
    out.to_csv(OVERLAP_CSV_PATH, index=False, encoding='utf-8-sig')
    return out
GOAL_XLSX_PATH = PRED_DIR / f'goal_number_api_dynamic_{DEFAULT_HOURS}h.xlsx'
GOAL_ARTIFACT_DIR = REPO_ROOT / 'artifacts' / 'goal_number_api_dynamic_current'
GOAL_CACHE_DIR = REPO_ROOT / 'artifacts' / 'goal_number_api_dynamic_cache'

AH_STATE = {
    'running': False,
    'last_run': None,
    'last_error': None,
    'last_rows': 0,
}
GOAL_STATE = {
    'running': False,
    'last_run': None,
    'last_error': None,
    'last_rows': 0,
}
LOCK = threading.Lock()
DISPLAY_COLS = [
    '开赛时间', '联赛', '主队', '客队', '让球说明', '预测方向',
    '上盘概率', '下盘概率', '置信度', '推荐等级', '模型', '特征', '历史准确率',
    '实际比分', '预判结果'
]
NUMERIC_COLS = {'上盘概率', '下盘概率', '置信度', '历史准确率'}
SELECT_COLS = {'联赛', '预测方向', '推荐等级'}
RESULT_EXTRA_COLS = ['实际比分', '预判结果']
FINISHED_STATUSES = {'FT', 'AET', 'PEN'}
FIXTURE_DAY_CACHE: dict[str, list[dict]] = {}

GOAL_DISPLAY_COLS = [
    '开赛时间', '联赛', '主队', '客队', '大小球盘口', '大球赔率', '小球赔率',
    '大球概率', '小球概率', '预测方向', '置信度', '是否达阈值', '推荐等级', '模型类型', '模型回测命中率'
]
GOAL_NUMERIC_COLS = {'大小球盘口', '大球赔率', '小球赔率', '大球概率', '小球概率', '置信度', '模型回测命中率'}
GOAL_SELECT_COLS = {'联赛', '预测方向', '是否达阈值', '推荐等级', '模型类型'}


def ensure_predictions() -> None:
    if CSV_PATH.exists() and XLSX_PATH.exists():
        return
    refresh_predictions_sync()


def refresh_predictions_sync() -> None:
    with LOCK:
        AH_STATE['running'] = True
        AH_STATE['last_error'] = None
    try:
        df = generate_predictions()
        save_outputs(df)
        with LOCK:
            AH_STATE['last_run'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            AH_STATE['last_rows'] = len(df)
    except Exception:
        with LOCK:
            AH_STATE['last_error'] = traceback.format_exc()
    finally:
        with LOCK:
            AH_STATE['running'] = False


def refresh_predictions_async() -> bool:
    with LOCK:
        if AH_STATE['running']:
            return False
        AH_STATE['running'] = True
        AH_STATE['last_error'] = None
    thread = threading.Thread(target=refresh_predictions_sync, daemon=True)
    thread.start()
    return True


def _goal_args() -> SimpleNamespace:
    return SimpleNamespace(
        cache_dir=GOAL_CACHE_DIR,
        artifact_dir=GOAL_ARTIFACT_DIR,
        predictions_dir=PRED_DIR,
        force_refresh=False,
        api_sleep_seconds=0.0,
        end_date=None,
        max_lookback_days=21,
        min_bets=25,
        min_train_rows=300,
        threshold_start=0.50,
        threshold_stop=0.80,
        threshold_step=0.01,
        hours=DEFAULT_HOURS,
        now=None,
    )



def refresh_goal_predictions_sync() -> None:
    with LOCK:
        GOAL_STATE['running'] = True
        GOAL_STATE['last_error'] = None
    try:
        args = _goal_args()
        client = build_goal_client(args)
        train_dynamic_model(args, client)
        df = predict_dynamic_model(args, client)
        with LOCK:
            GOAL_STATE['last_run'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            GOAL_STATE['last_rows'] = len(df)
    except Exception:
        with LOCK:
            GOAL_STATE['last_error'] = traceback.format_exc()
    finally:
        with LOCK:
            GOAL_STATE['running'] = False



def refresh_goal_predictions_async() -> bool:
    with LOCK:
        if GOAL_STATE['running']:
            return False
        GOAL_STATE['running'] = True
        GOAL_STATE['last_error'] = None
    thread = threading.Thread(target=refresh_goal_predictions_sync, daemon=True)
    thread.start()
    return True


def list_review_dates() -> list[str]:
    if not ARCHIVE_DIR.exists():
        return []
    return sorted([p.name for p in ARCHIVE_DIR.iterdir() if p.is_dir()], reverse=True)


def load_snapshot_for_date(date_str: str) -> tuple[pd.DataFrame | None, str | None]:
    day_dir = ARCHIVE_DIR / date_str
    if not day_dir.exists():
        return None, None
    files = sorted(day_dir.glob(f'*_{DEFAULT_HOURS}h_predictions.csv'))
    if not files:
        return None, None
    latest = files[-1]
    return pd.read_csv(latest), latest.name


def get_fixture_result(session: requests.Session, fixture_id: int) -> dict:
    base_url = __import__('os').environ['API_FOOTBALL_BASE_URL'].rstrip('/')
    r = session.get(base_url + '/fixtures', params={'id': fixture_id}, timeout=30)
    r.raise_for_status()
    resp = r.json().get('response', [])
    return resp[0] if resp else {}


def get_fixture_latest_ah(session: requests.Session, fixture_id: int) -> tuple[str, str, str]:
    base_url = __import__('os').environ['API_FOOTBALL_BASE_URL'].rstrip('/')
    try:
        r = session.get(base_url + '/odds', params={'fixture': fixture_id}, timeout=30)
        r.raise_for_status()
        resp = r.json().get('response', [])
        if not resp:
            return '', '', ''
        bookmakers = resp[0].get('bookmakers', [])
        handicap, home_odd, away_odd = parse_asian_handicap(bookmakers)
        if handicap is None:
            return '', '', ''
        return str(handicap), f'{float(home_odd):.2f}', f'{float(away_odd):.2f}'
    except Exception:
        return '', '', ''


def compute_review_df(date_str: str) -> tuple[pd.DataFrame | None, str | None]:
    snapshot, snap_name = load_snapshot_for_date(date_str)
    if snapshot is None or snapshot.empty:
        return None, snap_name
    session = requests.Session()
    session.headers.update({'x-apisports-key': __import__('os').environ['API_FOOTBALL_KEY']})
    rows = []
    for _, row in snapshot.iterrows():
        fixture = get_fixture_result(session, int(row['fixture_id'])) if 'fixture_id' in snapshot.columns else {}
        status = fixture.get('fixture', {}).get('status', {}).get('short')
        goals = fixture.get('goals', {})
        home_goals = goals.get('home')
        away_goals = goals.get('away')
        actual = '未赛'
        hit = '待定'
        if status in {'FT', 'AET', 'PEN'} and home_goals is not None and away_goals is not None:
            gd_h = float(home_goals) - float(away_goals) + float(row['让球数'])
            if gd_h > 0:
                actual = '上盘'
            elif gd_h < 0:
                actual = '下盘'
            else:
                actual = '走盘'
            if actual == '走盘':
                hit = '走'
            else:
                hit = '命中' if str(row['预测方向']) == actual else '失误'
        latest_handicap, latest_home_odd, latest_away_odd = get_fixture_latest_ah(session, int(row['fixture_id'])) if 'fixture_id' in snapshot.columns else ('', '', '')
        rows.append({
            '预测日期': row.get('预测日期', date_str),
            '开赛时间': row.get('开赛时间', ''),
            '联赛': row.get('联赛', ''),
            '主队': row.get('主队', ''),
            '客队': row.get('客队', ''),
            '预测盘口': row.get('让球数', ''),
            '最新盘口': latest_handicap,
            '最新上盘赔率': latest_home_odd,
            '最新下盘赔率': latest_away_odd,
            '预测方向': row.get('预测方向', ''),
            '置信度': row.get('置信度', ''),
            '推荐等级': row.get('推荐等级', ''),
            '实际比分': '' if home_goals is None or away_goals is None else f"{home_goals}-{away_goals}",
            '实际盘路': actual,
            '复盘结果': hit,
        })
    return pd.DataFrame(rows), snap_name


def _normalize_team_name(name: str) -> str:
    s = _basic_normalize_team_name(name)
    if not s:
        return ''
    return _team_alias_index().get(s, s)


def _row_date_str(row: pd.Series) -> str:
    if '日期' in row and pd.notna(row.get('日期')):
        s = str(row.get('日期')).strip()
        if re.match(r'^\d{4}-\d{2}-\d{2}$', s):
            return s
    kickoff = str(row.get('开赛时间', '')).strip()
    if re.match(r'^\d{2}-\d{2}\s+\d{2}:\d{2}$', kickoff):
        now = datetime.now(BEIJING_TZ)
        dt = datetime.strptime(f"{now.year}-{kickoff}", '%Y-%m-%d %H:%M').replace(tzinfo=BEIJING_TZ)
        if dt - now > timedelta(days=180):
            dt = dt.replace(year=dt.year - 1)
        elif now - dt > timedelta(days=180):
            dt = dt.replace(year=dt.year + 1)
        return dt.strftime('%Y-%m-%d')
    return ''


def _row_time_hm(row: pd.Series) -> str:
    kickoff = str(row.get('开赛时间', '')).strip()
    m = re.search(r'(\d{2}:\d{2})$', kickoff)
    return m.group(1) if m else ''


def _build_fixture_day_cache(date_str: str) -> list[dict]:
    if not date_str:
        return []
    cached = FIXTURE_DAY_CACHE.get(date_str)
    if cached is not None:
        return cached
    api_key = os.environ.get('API_FOOTBALL_KEY', '')
    if not api_key:
        FIXTURE_DAY_CACHE[date_str] = []
        return []
    base_url = os.environ.get('API_FOOTBALL_BASE_URL', 'https://v3.football.api-sports.io').rstrip('/')
    try:
        r = requests.get(
            base_url + '/fixtures',
            params={'date': date_str, 'timezone': 'Asia/Shanghai'},
            headers={'x-apisports-key': api_key},
            timeout=20,
        )
        r.raise_for_status()
        resp = r.json().get('response', [])
    except Exception:
        FIXTURE_DAY_CACHE[date_str] = []
        return []

    en_names = []
    for fx in resp:
        en_names.append(fx['teams']['home']['name'])
        en_names.append(fx['teams']['away']['name'])
    zh_map = _cached_team_name_map(en_names) if en_names else {}

    fixtures = []
    for fx in resp:
        home_en = fx['teams']['home']['name']
        away_en = fx['teams']['away']['name']
        home_zh = zh_map.get(home_en, home_en)
        away_zh = zh_map.get(away_en, away_en)
        kickoff = datetime.fromisoformat(fx['fixture']['date'].replace('Z', '+00:00')).astimezone(BEIJING_TZ)
        fixtures.append({
            'fixture_id': fx.get('fixture', {}).get('id'),
            'home_zh': home_zh,
            'away_zh': away_zh,
            'home_aliases': {_normalize_team_name(home_en), _normalize_team_name(home_zh)},
            'away_aliases': {_normalize_team_name(away_en), _normalize_team_name(away_zh)},
            'time_hm': kickoff.strftime('%H:%M'),
            'status': fx.get('fixture', {}).get('status', {}).get('short', ''),
            'home_goals': fx.get('goals', {}).get('home'),
            'away_goals': fx.get('goals', {}).get('away'),
        })
    FIXTURE_DAY_CACHE[date_str] = fixtures
    return fixtures


def _best_fixture_for_row(row: pd.Series) -> dict | None:
    # 已有复盘/回测结果时不需要匹配接口
    date_str = _row_date_str(row)
    if not date_str:
        return None
    fixtures = _build_fixture_day_cache(date_str)
    if not fixtures:
        return None
    home_aliases = {_normalize_team_name(row.get('主队', '')), _normalize_team_name(row.get('_主队原始', ''))}
    away_aliases = {_normalize_team_name(row.get('客队', '')), _normalize_team_name(row.get('_客队原始', ''))}
    home_aliases.discard('')
    away_aliases.discard('')
    hm = _row_time_hm(row)

    def alias_score(src_aliases: set[str], dst_aliases: set[str]) -> float:
        if not src_aliases or not dst_aliases:
            return 0.0
        if src_aliases & dst_aliases:
            return 1.0
        best = 0.0
        for a in src_aliases:
            for b in dst_aliases:
                best = max(best, SequenceMatcher(None, a, b).ratio())
        return best

    best_fx = None
    best_score = 0.0
    for fx in fixtures:
        score = alias_score(home_aliases, fx['home_aliases']) + alias_score(away_aliases, fx['away_aliases'])
        if hm and fx['time_hm'] == hm:
            score += 0.5
        if score > best_score:
            best_score = score
            best_fx = fx
    return best_fx if best_score >= 1.6 else None


def _row_handicap_value(row: pd.Series) -> float:
    if '让球数' in row and pd.notna(row.get('让球数')):
        try:
            return float(row.get('让球数'))
        except Exception:
            pass
    s = str(row.get('让球说明', '')).strip()
    if not s or s == '平手':
        return 0.0
    m = re.search(r'([主客]队)让([0-9.]+)', s)
    if not m:
        return 0.0
    side, val = m.group(1), float(m.group(2))
    return -val if side == '主队' else val


def _predict_result_from_score(row: pd.Series, home_goals, away_goals) -> str:
    if home_goals is None or away_goals is None:
        return '未结束'
    pred = str(row.get('预测方向', '')).strip()
    h = _row_handicap_value(row)
    diff = float(home_goals) + h - float(away_goals)
    if abs(diff) < 1e-9:
        return '走盘'
    actual = '主队' if diff > 0 else '客队'
    return '正确' if pred == actual else '错误'


def _enrich_actual_result_columns(df: pd.DataFrame) -> pd.DataFrame:
    if df.empty:
        return df
    df = df.copy()
    if '主队' in df.columns and '_主队原始' not in df.columns:
        df['_主队原始'] = df['主队']
    if '客队' in df.columns and '_客队原始' not in df.columns:
        df['_客队原始'] = df['客队']

    # 已有回测/复盘数据：直接映射
    if '比分' in df.columns:
        df['实际比分'] = df['比分'].fillna('')
    if '结果' in df.columns:
        df['预判结果'] = df['结果'].replace({'命中': '正确', '失误': '错误'}).fillna('')
    if '复盘结果' in df.columns:
        df['预判结果'] = df['复盘结果'].replace({'命中': '正确', '失误': '错误'}).fillna(df.get('预判结果', ''))

    need_api = ('实际比分' not in df.columns) or ('预判结果' not in df.columns)
    if not need_api:
        return df

    actual_scores, results = [], []
    for _, row in df.iterrows():
        fx = _best_fixture_for_row(row)
        if not fx or fx.get('status') not in FINISHED_STATUSES:
            actual_scores.append('')
            results.append('未结束')
            continue
        hg, ag = fx.get('home_goals'), fx.get('away_goals')
        actual_scores.append('' if hg is None or ag is None else f'{hg}-{ag}')
        results.append(_predict_result_from_score(row, hg, ag))

    if '实际比分' not in df.columns:
        df['实际比分'] = actual_scores
    else:
        df['实际比分'] = df['实际比分'].where(df['实际比分'].astype(str) != '', actual_scores)
    if '预判结果' not in df.columns:
        df['预判结果'] = results
    else:
        df['预判结果'] = df['预判结果'].where(df['预判结果'].astype(str) != '', results)
    return df


def _load_df(csv_path: Path, display_cols: list[str]) -> pd.DataFrame:
    if not csv_path.exists():
        return pd.DataFrame(columns=display_cols)
    df = pd.read_csv(csv_path)
    if df.empty:
        return pd.DataFrame(columns=display_cols)
    helper_cols = ['比分', '结果', '复盘结果', '让球数', '日期']
    keep_cols = [c for c in display_cols if c in df.columns] + [c for c in helper_cols if c in df.columns and c not in display_cols]
    return df[keep_cols].copy()


def render_table(
    csv_path: Path,
    display_cols: list[str],
    numeric_cols: set[str],
    select_cols: set[str],
    empty_msg: str,
    toolbar_buttons_html: str = '',
    translate_teams: bool = False,
) -> str:
    df = _load_df(csv_path, display_cols)
    if df.empty:
        return f'<p>{html.escape(empty_msg)}</p>'

    # pro_v8 英文队名 → 中文
    if translate_teams and '主队' in df.columns:
        all_en = set(df['主队'].dropna().tolist() + df['客队'].dropna().tolist())
        zh_map = _cached_team_name_map(all_en)
        df = df.copy()
        df['_主队原始'] = df['主队']
        df['_客队原始'] = df['客队']
        df['主队'] = df['主队'].map(zh_map).fillna(df['主队'])
        df['客队'] = df['客队'].map(zh_map).fillna(df['客队'])

    # 统一补充“实际比分”“预判结果”两列
    df = _enrich_actual_result_columns(df)

    # 按开赛时间升序排列
    if '开赛时间' in df.columns:
        df = df.sort_values('开赛时间').reset_index(drop=True)
    elif '日期' in df.columns:
        df = df.sort_values('日期').reset_index(drop=True)

    header = ''.join(
        f"<th class='sortable' data-col='{html.escape(col)}'>{html.escape(col)} <span class='sort-indicator'></span></th>"
        for col in display_cols
    )

    filter_cells = []
    for col in display_cols:
        if col in select_cols:
            options = ''.join(
                f"<option value='{html.escape(str(v))}'>{html.escape(str(v))}</option>"
                for v in sorted(x for x in df[col].dropna().astype(str).unique() if x)
            )
            filter_cells.append(
                f"<th><select class='filter-select' data-col='{html.escape(col)}'><option value=''>全部</option>{options}</select></th>"
            )
        elif col in numeric_cols:
            filter_cells.append(
                f"<th><input class='filter-min' data-col='{html.escape(col)}' type='number' step='0.1' placeholder='≥'></th>"
            )
        else:
            filter_cells.append(
                f"<th><input class='filter-text' data-col='{html.escape(col)}' type='text' placeholder='筛选'></th>"
            )

    body_rows = []
    for _, row in df.iterrows():
        attrs = ' '.join(
            f"data-{col}='{html.escape(str(row.get(col, '')))}'" for col in display_cols
        )
        tds = ''.join(f'<td>{html.escape(str(row.get(col, "")))}</td>' for col in display_cols)
        body_rows.append(f'<tr {attrs}>{tds}</tr>')

    return (
        "<div class='toolbar'>"
        f"{toolbar_buttons_html}"
        "<button type='button' class='secondary' onclick='clearFilters()'>清空筛选</button>"
        "<span id='resultCount'></span>"
        "</div>"
        f"<table id='predTable'><thead><tr>{header}</tr><tr>{''.join(filter_cells)}</tr></thead>"
        f"<tbody>{''.join(body_rows)}</tbody></table>"
    )


def render_downloads_table() -> str:
    DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
    files = sorted(DOWNLOADS_DIR.glob('*.zip'), key=lambda p: p.stat().st_mtime, reverse=True)
    if not files:
        return '<p>暂无可下载文件。</p>'
    rows = []
    for f in files:
        size_mb = f.stat().st_size / 1024 / 1024
        rows.append(
            '<tr>'
            f'<td>{html.escape(f.name)}</td>'
            f'<td>{size_mb:.1f} MB</td>'
            f"<td><a href='/download/package/{quote(f.name)}'>下载</a></td>"
            '</tr>'
        )
    return (
        "<table><thead><tr><th>文件名</th><th>大小</th><th>下载</th></tr></thead>"
        f"<tbody>{''.join(rows)}</tbody></table>"
    )


def render_review_table(review_date: str | None) -> str:
    dates = list_review_dates()
    options = ''.join(
        f"<option value='{html.escape(d)}' {'selected' if d == review_date else ''}>{html.escape(d)}</option>" for d in dates
    )
    form = (
        "<form method='GET' action='/' style='display:flex;gap:12px;align-items:center;flex-wrap:wrap;'>"
        "<input type='hidden' name='view' value='review'>"
        f"<select name='review_date'><option value=''>选择日期</option>{options}</select>"
        "<button type='submit'>查看复盘</button>"
        "</form>"
    )
    if not review_date:
        return form + '<p>请选择一个已有归档预测日期。</p>'
    df, snap_name = compute_review_df(review_date)
    if df is None or df.empty:
        return form + '<p>该日期暂无可复盘数据。</p>'
    df = df.copy()
    df['预判结果'] = df['复盘结果'].replace({'命中': '正确', '失误': '错误', '走': '走盘'}).fillna('未结束')
    cols = ['预测日期','开赛时间','联赛','主队','客队','预测盘口','最新盘口','最新上盘赔率','最新下盘赔率','预测方向','置信度','推荐等级','实际比分','预判结果','实际盘路']
    header = ''.join(f'<th>{html.escape(c)}</th>' for c in cols)
    body = []
    for _, row in df.iterrows():
        body.append('<tr>' + ''.join(f'<td>{html.escape(str(row.get(c, "")))}</td>' for c in cols) + '</tr>')
    stats = {
        '总场次': len(df),
        '已完赛': int((df['复盘结果'] != '待定').sum()),
        '命中': int((df['复盘结果'] == '命中').sum()),
        '失误': int((df['复盘结果'] == '失误').sum()),
        '走盘': int((df['复盘结果'] == '走').sum()),
    }
    finished = max(stats['已完赛'] - stats['走盘'], 1)
    acc = stats['命中'] / finished if finished > 0 else 0.0
    summary = (
        f"<p>归档文件：{html.escape(str(snap_name or ''))} ｜ 总场次：{stats['总场次']} ｜ 已完赛：{stats['已完赛']} ｜ 命中：{stats['命中']} ｜ 失误：{stats['失误']} ｜ 走盘：{stats['走盘']} ｜ 已完赛命中率：{acc:.1%}</p>"
    )
    return form + summary + f"<div class='table-wrap'><table><thead><tr>{header}</tr></thead><tbody>{''.join(body)}</tbody></table></div>"


def render_page(message: str = '', view: str = 'predict', review_date: str | None = None) -> bytes:
    active_view = view if view in {'predict', 'goal', 'review', 'ah1', 'ah1_backtest', 'downloads'} else 'predict'
    with LOCK:
        ah_status = dict(AH_STATE)
        goal_status = dict(GOAL_STATE)

    recommended_rule_toolbar = (
        "<button type='button' id='ruleToggleBtn' class='secondary' onclick='toggleRecommendedRule()'>只看符合规则</button>"
        "<span class='rule-note'>规则：置信度≥55、|让球数|＞0.25、模型准确率≥75</span>"
    )

    if active_view == 'downloads':
        title = '下载专区'
        intro = '这里提供当前 dashboard + pro_v8 + ah_1year_v6 的可迁移完整压缩包，可在另一台机器复现。'
        refresh_action = '/refresh-downloads'
        refresh_label = '刷新下载列表'
        current_status = {}
        status_html = [
            f"<li>下载目录：{html.escape(str(DOWNLOADS_DIR))}</li>",
        ]
        table_html = render_downloads_table()
        display_cols = []
        numeric_cols = set()
        section_title = '下载文件列表'
    elif active_view == 'goal':
        # 重合高置信度：合并 pro_v8 与 ah_1year_v6 预测
        title = '重合高置信度比赛'
        intro = '显示 A预测 与 亚盘-1year 两个模型预测方向一致的比赛。刷新预测后此页面自动更新。'
        refresh_action = '/refresh'
        refresh_label = '刷新预测'
        current_status = ah_status

        # 读取两个 CSV 统计
        pro_csv = REPO_ROOT / 'predictions' / 'pro_v8_24h.csv'
        ah_csv = REPO_ROOT / 'predictions' / 'ah_1year_v6_24h.csv'
        pro_count = len(pd.read_csv(pro_csv, encoding='utf-8-sig')) if pro_csv.exists() else 0
        ah_count = len(pd.read_csv(ah_csv, encoding='utf-8-sig')) if ah_csv.exists() else 0

        # 计算重合
        overlap_df = compute_overlap_csv()
        overlap_count = len(overlap_df)
        status_html = [
            f"<li>A预测总数：{pro_count} 场</li>",
            f"<li>亚盘-1year总数：{ah_count} 场</li>",
            f"<li>重合比赛：{overlap_count} 场（两模型方向一致）</li>",
        ]

        if overlap_df.empty:
            table_html = '<p style="padding:20px;color:#888;">暂无两个模型方向一致的比赛。</p>'
        else:
            table_html = render_table(
                OVERLAP_CSV_PATH,
                OVERLAP_DISPLAY_COLS,
                OVERLAP_NUMERIC_COLS,
                OVERLAP_SELECT_COLS,
                '当前没有重合比赛',
            )
        display_cols = OVERLAP_DISPLAY_COLS
        numeric_cols = OVERLAP_NUMERIC_COLS
        section_title = f'重合比赛（共 {overlap_count} 场，两模型方向一致）'
    else:
        title = 'V4 未来24小时比赛预测'
        intro = '点击按钮后，系统会立刻基于最新赛程与赔率重新计算未来24小时预测。'
        refresh_action = '/refresh'
        refresh_label = '立即刷新预测'
        current_status = ah_status
        status_html = [
            f"<li>运行中：{'是' if current_status['running'] else '否'}</li>",
            f"<li>最后刷新：{html.escape(str(current_status['last_run']))}</li>",
            f"<li>最近结果数：{html.escape(str(current_status['last_rows']))}</li>",
            f"<li>Excel 下载：<a href='/download/xlsx'>{html.escape(XLSX_PATH.name)}</a></li>",
            f"<li>CSV 下载：<a href='/download/csv'>{html.escape(CSV_PATH.name)}</a></li>",
        ]
        if active_view == 'review':
            table_html = render_review_table(review_date)
            display_cols = []
            numeric_cols = set()
            section_title = '按日期复盘'
        elif active_view == 'ah1':
            # 亚盘-1year 预测
            ah1_csv = REPO_ROOT / 'predictions' / 'ah_1year_v6_24h.csv'
            ah1_display = ['开赛时间','联赛','主队','客队','让球说明',
                           '上盘概率','下盘概率','预测方向','置信度','推荐等级','历史准确率',
                           '实际比分','预判结果']
            ah1_numeric = {'上盘概率','下盘概率','置信度','历史准确率'}
            ah1_select = {'联赛','预测方向','推荐等级'}
            table_html = render_table(ah1_csv, ah1_display, ah1_numeric, ah1_select, '暂无亚盘-1year预测结果。')
            display_cols = ah1_display
            numeric_cols = ah1_numeric
            section_title = '亚盘-1year 当前预测结果'
            refresh_action = '/refresh-ah1'
            refresh_label = '刷新亚盘-1year'
            ah1_status = AH_STATE  # 复用状态
            current_status = ah1_status
            status_html = [
                f"<li>运行中：{'是' if current_status['running'] else '否'}</li>",
                f"<li>最后刷新：{html.escape(str(current_status['last_run']))}</li>",
                f"<li>最近结果数：{html.escape(str(current_status['last_rows']))}</li>",
            ]
        elif active_view == 'ah1_backtest':
            # 亚盘-1year 复测
            ah1_bt_csv = REPO_ROOT / 'predictions' / 'ah1_backtest_all.csv'
            ah1_bt_display = ['日期','联赛','主队','客队','让球数','预测方向','预测概率','置信度',
                              '实际比分','预判结果','实际方向','命中','结果']
            ah1_bt_numeric = {'让球数','置信度'}
            ah1_bt_select = {'联赛','预测方向','命中','结果'}
            table_html = render_table(ah1_bt_csv, ah1_bt_display, ah1_bt_numeric, ah1_bt_select, '暂无复测结果。')
            display_cols = ah1_bt_display
            numeric_cols = ah1_bt_numeric
            section_title = '亚盘-1year 回测结果'
            refresh_action = ''
            refresh_label = ''
            current_status = {}
            status_html = []
        else:
            table_html = render_table(
                CSV_PATH,
                DISPLAY_COLS,
                NUMERIC_COLS,
                SELECT_COLS,
                '当前 24 小时内无可预测比赛。',
                toolbar_buttons_html=recommended_rule_toolbar,
                translate_teams=True,
            )
            display_cols = DISPLAY_COLS
            numeric_cols = NUMERIC_COLS
            section_title = '当前预测结果'

    error_block = ''
    if current_status.get('last_error'):
        error_block = f"<details><summary>最近错误</summary><pre>{html.escape(current_status['last_error'])}</pre></details>"

    html_doc = f"""
<!doctype html>
<html lang='zh-CN'>
<head>
  <meta charset='utf-8'>
  <meta name='viewport' content='width=device-width, initial-scale=1'>
  <title>{html.escape(title)}</title>
  <style>
    body {{ font-family: Arial, sans-serif; margin: 24px; background: #f7f8fa; color: #222; }}
    .box {{ background: white; padding: 16px 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,.06); margin-bottom: 16px; }}
    button {{ padding: 10px 16px; border: 0; border-radius: 8px; background: #1677ff; color: white; cursor: pointer; }}
    button.secondary {{ background: #eef2ff; color: #334155; border: 1px solid #cbd5e1; }}
    table {{ width: 100%; border-collapse: collapse; font-size: 14px; background: white; }}
    th, td {{ border: 1px solid #ddd; padding: 8px; text-align: center; white-space: nowrap; }}
    th {{ background: #fafafa; position: sticky; top: 0; z-index: 2; }}
    thead tr:nth-child(2) th {{ top: 38px; z-index: 1; }}
    input, select {{ width: 100%; box-sizing: border-box; padding: 4px 6px; border: 1px solid #d0d7de; border-radius: 6px; font-size: 12px; }}
    .msg {{ color: #0a7a2f; font-weight: bold; margin-bottom: 8px; }}
    .table-wrap {{ overflow: auto; max-height: 72vh; }}
    .toolbar {{ display: flex; gap: 12px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }}
    .sortable {{ cursor: pointer; user-select: none; }}
    .sort-indicator {{ font-size: 12px; color: #64748b; }}
    #resultCount {{ color: #475569; font-size: 13px; }}
    .rule-note {{ color: #475569; font-size: 13px; }}
    button.active-rule {{ background: #1677ff; color: #fff; border: 1px solid #1677ff; }}
  </style>
</head>
<body>
  <div class='box'>
    <h2>{html.escape(title)}</h2>
    <p>{html.escape(intro)}</p>
    {f"<div class='msg'>{html.escape(message)}</div>" if message else ''}
    <form method='POST' action='{html.escape(refresh_action)}'>
      <button type='submit'>{html.escape(refresh_label)}</button>
    </form>
  </div>

  <div class='box'>
    <h3>运行状态</h3>
    <ul>{''.join(status_html)}</ul>
    {error_block}
  </div>

  <div class='box'>
    <div style='display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;'>
      <a href='/'><button type='button' class='secondary'>A预测</button></a>
      <a href='/?view=goal'><button type='button' class='secondary'>重合高置信度</button></a>
      <a href='/?view=ah1'><button type='button' class='secondary'>亚盘-1year</button></a>
      <a href='/?view=ah1_backtest'><button type='button' class='secondary'>亚盘-1year复测</button></a>
      <a href='/?view=review'><button type='button' class='secondary'>亚盘按日期复盘</button></a>
      <a href='/?view=downloads'><button type='button' class='secondary'>下载专区</button></a>
    </div>
    <h3>{html.escape(section_title)}</h3>
    {table_html}
  </div>

  <script>
    const displayCols = {json.dumps(display_cols, ensure_ascii=False)};
    const numericCols = new Set({json.dumps(sorted(numeric_cols), ensure_ascii=False)});
    const recommendedRuleSupported = {json.dumps(active_view == 'predict', ensure_ascii=False)};
    let recommendedRuleOnly = false;
    let sortState = {{ col: null, asc: true }};

    function getRows() {{
      return Array.from(document.querySelectorAll('#predTable tbody tr'));
    }}

    function rowValue(row, col) {{
      return row.dataset[col] ?? '';
    }}

    function rowNumber(row, col) {{
      const val = parseFloat(rowValue(row, col));
      return Number.isNaN(val) ? null : val;
    }}

    function updateRuleButton() {{
      const btn = document.getElementById('ruleToggleBtn');
      if (!btn) return;
      btn.textContent = recommendedRuleOnly ? '显示全部' : '只看符合规则';
      btn.classList.toggle('active-rule', recommendedRuleOnly);
    }}

    function applyFilters() {{
      const rows = getRows();
      let visible = 0;
      rows.forEach(row => {{
        let ok = true;
        document.querySelectorAll('.filter-text').forEach(input => {{
          const col = input.dataset.col;
          const q = input.value.trim().toLowerCase();
          if (q && !String(rowValue(row, col)).toLowerCase().includes(q)) ok = false;
        }});
        document.querySelectorAll('.filter-select').forEach(select => {{
          const col = select.dataset.col;
          const q = select.value.trim();
          if (q && String(rowValue(row, col)) !== q) ok = false;
        }});
        document.querySelectorAll('.filter-min').forEach(input => {{
          const col = input.dataset.col;
          const q = input.value.trim();
          if (q !== '') {{
            const val = parseFloat(rowValue(row, col));
            if (Number.isNaN(val) || val < parseFloat(q)) ok = false;
          }}
        }});
        if (ok && recommendedRuleSupported && recommendedRuleOnly) {{
          const confidence = rowNumber(row, '置信度');
          const handicap = rowNumber(row, '让球数');
          const modelAcc = rowNumber(row, '模型准确率');
          if (confidence === null || handicap === null || modelAcc === null) {{
            ok = false;
          }} else if (confidence < 55 || Math.abs(handicap) <= 0.25 || modelAcc < 75) {{
            ok = false;
          }}
        }}
        row.style.display = ok ? '' : 'none';
        if (ok) visible += 1;
      }});
      const countNode = document.getElementById('resultCount');
      if (countNode) {{
        const suffix = recommendedRuleSupported && recommendedRuleOnly ? '（符合规则）' : '';
        countNode.textContent = rows.length ? `筛选后 ${{visible}} 场${{suffix}}` : '';
      }}
    }}

    function clearFilters() {{
      document.querySelectorAll('.filter-text, .filter-select, .filter-min').forEach(el => el.value = '');
      recommendedRuleOnly = false;
      updateRuleButton();
      applyFilters();
    }}

    function toggleRecommendedRule() {{
      if (!recommendedRuleSupported) return;
      recommendedRuleOnly = !recommendedRuleOnly;
      updateRuleButton();
      applyFilters();
    }}

    function sortTable(col) {{
      const tbody = document.querySelector('#predTable tbody');
      if (!tbody) return;
      const rows = getRows();
      const asc = sortState.col === col ? !sortState.asc : true;
      sortState = {{ col, asc }};
      rows.sort((a, b) => {{
        const av = rowValue(a, col);
        const bv = rowValue(b, col);
        if (numericCols.has(col)) {{
          const an = parseFloat(av); const bn = parseFloat(bv);
          const aa = Number.isNaN(an) ? -Infinity : an;
          const bb = Number.isNaN(bn) ? -Infinity : bn;
          return asc ? aa - bb : bb - aa;
        }}
        return asc ? String(av).localeCompare(String(bv), 'zh') : String(bv).localeCompare(String(av), 'zh');
      }});
      rows.forEach(r => tbody.appendChild(r));
      document.querySelectorAll('.sort-indicator').forEach(el => el.textContent = '');
      const th = document.querySelector(`th[data-col="${{col}}"] .sort-indicator`);
      if (th) th.textContent = asc ? '▲' : '▼';
      applyFilters();
    }}

    document.querySelectorAll('.filter-text, .filter-select, .filter-min').forEach(el => {{
      el.addEventListener('input', applyFilters);
      el.addEventListener('change', applyFilters);
    }});
    document.querySelectorAll('.sortable').forEach(th => {{
      th.addEventListener('click', () => sortTable(th.dataset.col));
    }});
    updateRuleButton();
    applyFilters();
  </script>
</body>
</html>
"""
    return html_doc.encode('utf-8')


class Handler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        if parsed.path == '/':
            qs = parse_qs(parsed.query)
            msg = qs.get('msg', [''])[0]
            view = qs.get('view', ['predict'])[0]
            review_date = qs.get('review_date', [''])[0] or None
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.end_headers()
            self.wfile.write(render_page(msg, view=view, review_date=review_date))
            return
        if parsed.path == '/status':
            with LOCK:
                payload = {
                    'ah': dict(AH_STATE),
                    'goal_number': dict(GOAL_STATE),
                }
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.end_headers()
            self.wfile.write(json.dumps(payload, ensure_ascii=False).encode('utf-8'))
            return
        if parsed.path == '/download/xlsx':
            if not XLSX_PATH.exists():
                self.send_error(HTTPStatus.NOT_FOUND, 'xlsx not found')
                return
            data = XLSX_PATH.read_bytes()
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
            self.send_header('Content-Disposition', f'attachment; filename={XLSX_PATH.name}')
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            self.wfile.write(data)
            return
        if parsed.path == '/download/csv':
            if not CSV_PATH.exists():
                self.send_error(HTTPStatus.NOT_FOUND, 'csv not found')
                return
            data = CSV_PATH.read_bytes()
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/csv; charset=utf-8')
            self.send_header('Content-Disposition', f'attachment; filename={CSV_PATH.name}')
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            self.wfile.write(data)
            return
        if parsed.path == '/download/goal/xlsx':
            if not GOAL_XLSX_PATH.exists():
                self.send_error(HTTPStatus.NOT_FOUND, 'goal xlsx not found')
                return
            data = GOAL_XLSX_PATH.read_bytes()
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
            self.send_header('Content-Disposition', f'attachment; filename={GOAL_XLSX_PATH.name}')
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            self.wfile.write(data)
            return
        if parsed.path == '/download/goal/csv':
            if not GOAL_CSV_PATH.exists():
                self.send_error(HTTPStatus.NOT_FOUND, 'goal csv not found')
                return
            data = GOAL_CSV_PATH.read_bytes()
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/csv; charset=utf-8')
            self.send_header('Content-Disposition', f'attachment; filename={GOAL_CSV_PATH.name}')
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            self.wfile.write(data)
            return
        if parsed.path.startswith('/download/package/'):
            fname = Path(parsed.path.removeprefix('/download/package/')).name
            target = DOWNLOADS_DIR / fname
            if not target.exists() or not target.is_file():
                self.send_error(HTTPStatus.NOT_FOUND, 'package not found')
                return
            data = target.read_bytes()
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'application/zip')
            self.send_header('Content-Disposition', f'attachment; filename={target.name}')
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            self.wfile.write(data)
            return
        self.send_error(HTTPStatus.NOT_FOUND, 'not found')

    def do_POST(self) -> None:
        if self.path == '/refresh':
            started = refresh_predictions_async()
            msg = '亚盘预测刷新已启动，请稍后手动刷新页面。' if started else '亚盘刷新任务已在运行，请稍后再看结果。'
            self.send_response(HTTPStatus.SEE_OTHER)
            self.send_header('Location', '/?msg=' + quote(msg, safe=''))
            self.end_headers()
            return
        if self.path == '/refresh-goal':
            started = refresh_goal_predictions_async()
            msg = 'Goal Number 刷新已启动，请稍后手动刷新页面。' if started else 'Goal Number 刷新任务已在运行，请稍后再看结果。'
            self.send_response(HTTPStatus.SEE_OTHER)
            self.send_header('Location', '/?view=goal&msg=' + quote(msg, safe=''))
            self.end_headers()
            return
        if self.path.startswith('/?view=downloads') or self.path == '/refresh-downloads':
            self.send_response(HTTPStatus.SEE_OTHER)
            self.send_header('Location', '/?view=downloads')
            self.end_headers()
            return
        self.send_error(HTTPStatus.NOT_FOUND, 'not found')

    def log_message(self, fmt: str, *args) -> None:
        return


if __name__ == '__main__':
    ensure_predictions()
    server = ThreadingHTTPServer(('0.0.0.0', 8765), Handler)
    print('Serving on http://0.0.0.0:8765')
    server.serve_forever()
