#!/usr/bin/env python3
"""Clasificación de entrenos VRSimracers.

Escanea http://2.139.243.255:8003/results, descarga cada sesión y
construye el ranking de mejores tiempos válidos por piloto.
"""

from __future__ import annotations

import base64
import html as htmlmod
import json
import os
import re
import socket
import struct
import sys
import threading
import time
import unicodedata
import webbrowser
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import parse_qs
from urllib.request import Request, urlopen

BASE_URL = "http://2.139.243.255:8003"
BIND_HOST = "0.0.0.0"
PUBLIC_HOST = "2.139.243.255"
LOCAL_HOST = "127.0.0.1"
PORT = 8765
ROOT = Path(__file__).resolve().parent
STATIC_DIR = ROOT / "static"
DATA_DIR = ROOT / "data"
CACHE_DIR = DATA_DIR / "sessions"
STATE_PATH = DATA_DIR / "leaderboard.json"
PORT_PATH = DATA_DIR / "port.txt"
PID_PATH = DATA_DIR / "server.pid"
LOG_PATH = DATA_DIR / "app.log"
PYTHONW = Path(r"C:\Users\bbjor\AppData\Local\Programs\Python\Python311\pythonw.exe")

FILE_RE = re.compile(
    r"/server/(\d+)/results/download/(results_\d{8}_\d{6}_\w+\.json)"
)
COUNT_RE = re.compile(r"Showing results\s+\d+\s*-\s*\d+\s+of\s+(\d+)", re.I)
FILENAME_RE = re.compile(r"results_(\d{8})_(\d{6})_(\w+)\.json")

VALID_FLAG = 2
USER_AGENT = "VRSimracers-Entremos/1.0"
RATE_MAX = 4
RATE_WINDOW = 21.0
RESERVE_SECONDS = 90.0
AC_REFRESH_SECONDS = 25.0
REFRESH_LIVE_SESSIONS = 2


def log(message: str) -> None:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    line = time.strftime("%Y-%m-%d %H:%M:%S") + " " + message + "\n"
    try:
        with LOG_PATH.open("a", encoding="utf-8") as handle:
            handle.write(line)
    except OSError:
        pass


def running_port() -> int | None:
    if not PORT_PATH.exists():
        return None
    try:
        port = int(PORT_PATH.read_text(encoding="utf-8").strip())
    except ValueError:
        return None
    try:
        probe = socket.create_connection((LOCAL_HOST, port), timeout=1.2)
        probe.sendall(b"GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
        got = probe.recv(16)
        probe.close()
    except OSError:
        return None
    return port if got.startswith(b"HTTP") else None


def write_runtime(port: int) -> None:
    PORT_PATH.write_text(str(port), encoding="utf-8")
    PID_PATH.write_text(str(os.getpid()), encoding="utf-8")
    (DATA_DIR / "url.txt").write_text(
        f"http://{PUBLIC_HOST}:{port}/\nhttp://{LOCAL_HOST}:{port}/\n",
        encoding="utf-8",
    )


def public_url(port: int) -> str:
    return f"http://{PUBLIC_HOST}:{port}/"


def kid(obj: dict | None) -> tuple[str, str]:
    if not obj:
        return ("", "")
    return (str(obj.get("a", "")), str(obj.get("b", "")))


def format_time(ms: int | None) -> str:
    if not ms or ms <= 0:
        return "-"
    minutes, rest = divmod(int(ms), 60_000)
    return f"{minutes}:{rest / 1000:06.3f}"


def format_gap(ms: int | None) -> str:
    if ms is None:
        return "-"
    if ms == 0:
        return "-"
    return f"+{ms / 1000:.3f}"


def format_sector(ms: int | None) -> str:
    if not ms or ms <= 0:
        return "-"
    return f"{ms / 1000:.3f}"


def parse_filename_meta(filename: str) -> dict[str, Any]:
    match = FILENAME_RE.search(filename)
    if not match:
        return {
            "filename": filename,
            "date": None,
            "date_iso": None,
            "session_type": "unknown",
        }
    day, hms, stype = match.groups()
    dt = datetime.strptime(f"{day}{hms}", "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
    return {
        "filename": filename,
        "date": dt,
        "date_iso": dt.isoformat(),
        "session_type": stype,
    }


def best_consecutive(laps: list[dict[str, Any]], count: int) -> dict[str, Any] | None:
    """Mejor media de `count` vueltas consecutivas, todas validas.

    La media es en milisegundos enteros (suma // count), igual que el server.
    Python round() en el .5 subía 1 ms en algunos casos.
    """
    best: dict[str, Any] | None = None
    for index in range(0, len(laps) - count + 1):
        window = laps[index : index + count]
        if not all(item.get("valid") and int(item.get("time_ms") or 0) > 0 for item in window):
            continue
        times = [int(item["time_ms"]) for item in window]
        total = sum(times)
        avg_ms = total // count
        if best is None or avg_ms < best["avg_ms"]:
            best = {
                "avg_ms": avg_ms,
                "laps": times,
                "splits": window[0].get("splits") or [],
                "car": window[0].get("car") or "",
            }
    return best


def parse_ac_time(text: str | None) -> int | None:
    if not text:
        return None
    match = re.search(r"(\d+):(\d+)\.(\d+)", text.strip())
    if not match:
        return None
    minutes = int(match.group(1))
    seconds = int(match.group(2))
    millis = int(match.group(3)[:3].ljust(3, "0"))
    return minutes * 60_000 + seconds * 1000 + millis


def public_ranking(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    clean = []
    for row in rows:
        item = dict(row)
        item.pop("best_sectors", None)
        item.pop("lap_log", None)  # la lista de vueltas va aparte, al abrir la ficha
        clean.append(item)
    return clean


class RateLimiter:
    def __init__(self, max_requests: int = RATE_MAX, window: float = RATE_WINDOW):
        self.max_requests = max_requests
        self.window = window
        self.times: list[float] = []
        self.lock = threading.Lock()

    def wait(self) -> None:
        while True:
            sleep_for = 0.0
            with self.lock:
                now = time.time()
                self.times = [t for t in self.times if now - t < self.window]
                if len(self.times) < self.max_requests:
                    self.times.append(now)
                    return
                sleep_for = self.window - (now - self.times[0]) + 0.25
            time.sleep(max(0.05, sleep_for))


class Scanner:
    def __init__(self) -> None:
        self.limiter = RateLimiter()
        self.lock = threading.Lock()
        self.stop_event = threading.Event()
        self.trigger = threading.Event()
        self.state: dict[str, Any] = self._empty_state("Iniciando...")
        self._load_saved()

    def _empty_state(self, message: str) -> dict[str, Any]:
        return {
            "scanning": False,
            "message": message,
            "progress": {"done": 0, "total": 0},
            "last_scan": None,
            "error": None,
            "live": {},
            "tracks": [],
            "ranking": [],
            "sessions_indexed": 0,
            "pilots_seen": 0,
            "source": BASE_URL + "/results",
        }

    def _load_saved(self) -> None:
        if not STATE_PATH.exists():
            return
        try:
            saved = json.loads(STATE_PATH.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            return
        if isinstance(saved, dict) and saved.get("ranking"):
            self.state.update(
                {
                    "ranking": saved.get("ranking") or [],
                    "tracks": saved.get("tracks") or [],
                    "sessions_indexed": saved.get("sessions_indexed") or 0,
                    "pilots_seen": saved.get("pilots_seen") or 0,
                    "last_scan": saved.get("last_scan"),
                    "live": saved.get("live") or {},
                    "message": "Clasificacion en cache. Actualizando en segundo plano...",
                }
            )

    def _save(self) -> None:
        payload = {
            "ranking": public_ranking(self.state.get("ranking") or []),
            "tracks": self.state.get("tracks") or [],
            "sessions_indexed": self.state.get("sessions_indexed") or 0,
            "pilots_seen": self.state.get("pilots_seen") or 0,
            "last_scan": self.state.get("last_scan"),
            "live": self.state.get("live") or {},
        }
        tmp = STATE_PATH.with_suffix(".tmp")
        tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
        tmp.replace(STATE_PATH)

    def snapshot(self) -> dict[str, Any]:
        with self.lock:
            state = dict(self.state)
            state["ranking"] = public_ranking(state.get("ranking") or [])
        return state

    def _update(self, **kwargs: Any) -> None:
        with self.lock:
            self.state.update(kwargs)

    def fetch(self, path_or_url: str, as_json: bool = False) -> Any:
        url = path_or_url if path_or_url.startswith("http") else BASE_URL + path_or_url
        last_error: Exception | None = None
        for attempt in range(5):
            self.limiter.wait()
            req = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"})
            try:
                with urlopen(req, timeout=25) as resp:
                    raw = resp.read()
                text = raw.decode("utf-8-sig")
                if as_json:
                    return json.loads(text)
                return text
            except HTTPError as exc:
                last_error = exc
                if exc.code == 429:
                    time.sleep(8 + attempt * 4)
                    continue
                raise
        raise last_error or RuntimeError(f"Fallo al pedir {url}")

    def list_result_files(self) -> list[dict[str, str]]:
        found: dict[str, dict[str, str]] = {}
        page = 0
        total = None
        while True:
            html = self.fetch(f"/results?page={page}")
            for server_id, filename in FILE_RE.findall(html):
                found[filename] = {
                    "server_id": server_id,
                    "filename": filename,
                    "url": f"/server/{server_id}/results/download/{filename}",
                }
            if total is None:
                count = COUNT_RE.search(html)
                total = int(count.group(1)) if count else len(found)
            if len(found) >= total or page > 40:
                break
            page += 1
        files = list(found.values())
        files.sort(key=lambda item: item["filename"], reverse=True)
        return files

    def cached_files(self) -> list[dict[str, str]]:
        files = []
        if not CACHE_DIR.exists():
            return files
        for path in CACHE_DIR.glob("results_*.json"):
            files.append(
                {
                    "server_id": "0",
                    "filename": path.name,
                    "url": f"/server/0/results/download/{path.name}",
                }
            )
        files.sort(key=lambda item: item["filename"], reverse=True)
        return files

    def load_session(self, item: dict[str, str]) -> dict[str, Any] | None:
        cache_path = CACHE_DIR / item["filename"]
        if cache_path.exists() and cache_path.stat().st_size > 20:
            try:
                return json.loads(cache_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                cache_path.unlink(missing_ok=True)
        try:
            data = self.fetch(item["url"], as_json=True)
        except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc:
            self._update(error=f"No se pudo leer {item['filename']}: {exc}")
            return None
        cache_path.write_text(json.dumps(data), encoding="utf-8")
        return data

    def fetch_live(self) -> dict[str, Any]:
        try:
            health = self.fetch("/healthcheck.json", as_json=True)
        except Exception:
            return {}
        server = (health.get("Servers") or {}).get("0") or {}
        return {
            "ok": bool(health.get("OK")),
            "event_in_progress": bool(server.get("EventInProgress", health.get("EventInProgress"))),
            "connected_drivers": int(server.get("NumConnectedDrivers") or 0),
            "current_session": server.get("CurrentSession") or "",
            "track_name": server.get("TrackName") or "",
            "server_name": server.get("ServerName") or health.get("Game") or "VRSimracers",
            "uptime": health.get("Uptime") or "",
        }

    def scan_once(self) -> None:
        cached = self.cached_files()
        if cached:
            self._update(scanning=True, message="Recalculando medias consecutivas...")
            ranking, tracks = self._rebuild(cached)
            self._update(
                ranking=ranking,
                tracks=tracks,
                sessions_indexed=len(cached),
                pilots_seen=len(ranking),
                progress={"done": len(cached), "total": len(cached)},
                message="Clasificacion local lista. Consultando el servidor...",
            )

        self._update(scanning=True, error=None)
        try:
            live = self.fetch_live()
            self._update(live=live, message="Buscando archivos de resultados...")
            files = self.list_result_files()
        except Exception as exc:
            files = cached
            live = self.state.get("live") or {}
            self._update(
                error=str(exc),
                message="Sin conexion al servidor. Mostrando tiempos en cache.",
                scanning=False,
                live=live,
            )
            if files and not self.state.get("ranking"):
                ranking, tracks = self._rebuild(files)
                self._update(
                    ranking=ranking,
                    tracks=tracks,
                    sessions_indexed=len(files),
                    pilots_seen=len(ranking),
                )
            return

        total = len(files)
        new_files = 0
        for index, item in enumerate(files, start=1):
            if self.stop_event.is_set():
                break
            cache_path = CACHE_DIR / item["filename"]
            already = cache_path.exists()
            self._update(
                scanning=True,
                progress={"done": index - 1, "total": total},
                message=f"Leyendo {item['filename']} ({index}/{total})",
                live=live,
            )
            payload = self.load_session(item)
            if payload is None:
                continue
            if not already:
                new_files += 1

        ranking, tracks = self._rebuild(files)
        now = datetime.now(timezone.utc).isoformat()
        self._update(
            scanning=False,
            message="Clasificacion actualizada." if new_files or not cached else "Sin sesiones nuevas.",
            last_scan=now,
            ranking=ranking,
            tracks=tracks,
            sessions_indexed=len(files),
            pilots_seen=len(ranking),
            progress={"done": len(files), "total": len(files)},
            live=self.fetch_live() or live,
            error=None,
        )
        try:
            self._save()
        except OSError:
            pass

    def _rebuild(self, files: list[dict[str, str]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
        pilots: dict[str, dict[str, Any]] = {}
        tracks: dict[str, int] = {}

        for item in files:
            cache_path = CACHE_DIR / item["filename"]
            if not cache_path.exists():
                continue
            try:
                data = json.loads(cache_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                continue
            self._ingest_session(pilots, tracks, data, item["filename"])
            time.sleep(0)

        ranking = sorted(
            pilots.values(),
            key=lambda row: (
                row["best_ms"] is None,
                row["best_ms"] if row["best_ms"] is not None else 10**12,
                row["name"],
            ),
        )
        leader = next((row["best_ms"] for row in ranking if row["best_ms"]), None)
        for pos, row in enumerate(ranking, start=1):
            row["rank"] = pos
            row["best_display"] = format_time(row["best_ms"])
            row["gap_ms"] = None if not row["best_ms"] or not leader else row["best_ms"] - leader
            row["gap_display"] = "Lider" if row["gap_ms"] == 0 else format_gap(row["gap_ms"])
            row["sectors_display"] = [format_sector(s) for s in row["sectors"]]
            row["theoretical_display"] = format_time(row["theoretical_ms"])
            row["avg2_display"] = format_time(row.get("avg2_ms"))
            row["avg2_parts"] = [format_time(t) for t in (row.get("avg2_laps") or [])]
            row["history"] = sorted(
                row["history"],
                key=lambda h: h.get("date_iso") or "",
                reverse=True,
            )
            for hist in row["history"]:
                hist["time_display"] = format_time(hist.get("time_ms"))
                hist["avg2_display"] = format_time(hist.get("avg2_ms"))

        track_list = [
            {"id": key, "name": key, "sessions": value}
            for key, value in sorted(tracks.items(), key=lambda kv: (-kv[1], kv[0]))
        ]
        return ranking, track_list

    def _ingest_session(
        self,
        pilots: dict[str, dict[str, Any]],
        tracks: dict[str, int],
        data: dict[str, Any],
        filename: str,
    ) -> None:
        meta = parse_filename_meta(filename)
        track = " ".join(
            part for part in [data.get("track_name") or "", data.get("track_layout_name") or ""] if part
        ).strip() or "Desconocido"
        tracks[track] = tracks.get(track, 0) + 1

        drivers_by_guid = {kid(drv.get("guid")): drv for drv in data.get("drivers") or []}
        cars_by_id = {kid(car.get("car_id")): car for car in data.get("cars") or []}

        session_best: dict[str, dict[str, Any]] = {}
        session_seen: dict[str, dict[str, Any]] = {}
        sequences: dict[str, list[dict[str, Any]]] = {}

        for drv in data.get("drivers") or []:
            pid = self._pilot_id(drv)
            session_seen[pid] = drv

        for lap in data.get("laps") or []:
            time_ms = int(lap.get("time") or 0)
            flags = int(lap.get("flags") or 0)
            driver = drivers_by_guid.get(kid(lap.get("driver_key")))
            if not driver:
                continue
            pid = self._pilot_id(driver)
            car = cars_by_id.get(kid(lap.get("car_key"))) or {}
            splits = [int(x) for x in (lap.get("split") or [])]
            row = self._ensure_pilot(pilots, pid, driver, track)
            row["total_laps"] += 1
            valid = flags == VALID_FLAG and time_ms > 0
            sequences.setdefault(pid, []).append(
                {
                    "time_ms": time_ms,
                    "valid": valid,
                    "splits": splits,
                    "car": car.get("model_displayname") or "-",
                }
            )
            if valid:
                row["valid_laps"] += 1
                if len(splits) >= 3:
                    for i in range(3):
                        if splits[i] > 0:
                            row["best_sectors"][i] = min(row["best_sectors"][i], splits[i])
                prev = session_best.get(pid)
                if prev is None or time_ms < prev["time_ms"]:
                    session_best[pid] = {
                        "time_ms": time_ms,
                        "splits": splits,
                        "car": car.get("model_displayname") or "-",
                    }
                if row["best_ms"] is None or time_ms < row["best_ms"]:
                    row["best_ms"] = time_ms
                    row["sectors"] = splits[:3] + [0] * max(0, 3 - len(splits))
                    row["car"] = car.get("model_displayname") or row["car"]
                    row["best_session_at"] = meta.get("date_iso")
                    row["best_session_type"] = data.get("session_type") or meta.get("session_type")
                    row["best_track"] = track
                    row["best_file"] = filename

        for pid, seq in sequences.items():
            row = pilots.get(pid)
            if not row:
                continue
            combo2 = best_consecutive(seq, 2)
            if combo2:
                session_best.setdefault(pid, {})
                session_best[pid]["avg2_ms"] = combo2["avg_ms"]
                session_best[pid]["avg2_laps"] = combo2["laps"]
                if row.get("avg2_ms") is None or combo2["avg_ms"] < row["avg2_ms"]:
                    row["avg2_ms"] = combo2["avg_ms"]
                    row["avg2_laps"] = combo2["laps"]
                    row["avg2_track"] = track
                    row["avg2_car"] = combo2["car"] or row.get("car")
                    row["avg2_session_at"] = meta.get("date_iso")

        for pid, drv in session_seen.items():
            row = self._ensure_pilot(pilots, pid, drv, track)
            row["sessions"] += 1
            if not row.get("last_session_at") or (meta.get("date_iso") or "") > row["last_session_at"]:
                row["last_session_at"] = meta.get("date_iso")
                row["last_session_type"] = data.get("session_type") or meta.get("session_type")
                self._apply_identity(row, drv)
            best = session_best.get(pid)
            row["history"].append(
                {
                    "filename": filename,
                    "date_iso": meta.get("date_iso"),
                    "session_type": data.get("session_type") or meta.get("session_type"),
                    "track": track,
                    "time_ms": None if not best else best.get("time_ms"),
                    "avg2_ms": None if not best else best.get("avg2_ms"),
                    "car": None if not best else best.get("car"),
                    "splits": None if not best else best.get("splits"),
                }
            )

        for row in pilots.values():
            secs = row["best_sectors"]
            if all(s < 10**12 for s in secs):
                row["theoretical_ms"] = int(sum(secs))
            else:
                row["theoretical_ms"] = None

    def _pilot_id(self, driver: dict[str, Any]) -> str:
        player_id = str(driver.get("player_id") or "").strip()
        if player_id:
            return player_id
        guid = kid(driver.get("guid"))
        if guid != ("", ""):
            return "guid:" + "_".join(guid)
        name = f"{driver.get('first_name') or ''} {driver.get('last_name') or ''}".strip()
        return "name:" + (name or driver.get("nickname") or "desconocido")

    def _apply_identity(self, row: dict[str, Any], driver: dict[str, Any]) -> None:
        first = (driver.get("first_name") or "").strip()
        last = (driver.get("last_name") or "").strip()
        nick = (driver.get("nickname") or "").strip()
        name = f"{first} {last}".strip() or nick or row["name"]
        row["name"] = name
        row["nickname"] = nick
        row["nation"] = driver.get("nation") or row.get("nation") or ""
        row["player_id"] = str(driver.get("player_id") or row.get("player_id") or "")

    def _ensure_pilot(
        self, pilots: dict[str, dict[str, Any]], pid: str, driver: dict[str, Any], track: str
    ) -> dict[str, Any]:
        if pid not in pilots:
            pilots[pid] = {
                "id": pid,
                "name": "",
                "nickname": "",
                "nation": "",
                "player_id": "",
                "best_ms": None,
                "sectors": [0, 0, 0],
                "best_sectors": [10**12, 10**12, 10**12],
                "theoretical_ms": None,
                "car": "-",
                "valid_laps": 0,
                "total_laps": 0,
                "sessions": 0,
                "track": track,
                "best_track": track,
                "last_session_at": None,
                "last_session_type": None,
                "best_session_at": None,
                "best_session_type": None,
                "best_file": None,
                "avg2_ms": None,
                "avg2_laps": [],
                "avg2_track": track,
                "avg2_car": "-",
                "avg2_session_at": None,
                "history": [],
            }
        self._apply_identity(pilots[pid], driver)
        return pilots[pid]

    def request_rescan(self) -> None:
        self.trigger.set()

    def loop(self) -> None:
        while not self.stop_event.is_set():
            self.trigger.clear()
            try:
                self.scan_once()
            except Exception as exc:
                self._update(scanning=False, error=str(exc), message="Error en el escaner.")
            if self.trigger.is_set():
                continue
            self.trigger.wait(RESERVE_SECONDS)


SESSION_ID_RE = re.compile(r"sessiondetails\?sessionid=(\d+)", re.I)
PLAYER_SESS_RE = re.compile(r"sessiondetails\?playerInSessionId=(\d+)", re.I)
STRACKER_BASES = [
    "http://2.139.243.255:8771/stracker",
    "http://2.139.243.255:8772/stracker",
]
LATEST_SESSION_PAGES = 1
MAX_NEW_PER_SERVER = 160
LATEST_PER_PORT = 150


def strip_html(text: str) -> str:
    text = re.sub(r"<[^>]+>", " ", text)
    return re.sub(r"\s+", " ", text).replace("&nbsp;", " ").strip()


def cache_session_key(port: str, session_id: str) -> str:
    return f"{port}_{session_id}"


def parse_cache_stem(stem: str) -> tuple[str, int] | None:
    parts = stem.split("_", 1)
    if len(parts) != 2 or not parts[1].isdigit():
        return None
    return parts[0], int(parts[1])


def unique_session_ids(raw_ids: list[str]) -> list[str]:
    seen: set[str] = set()
    ordered: list[str] = []
    for sid in raw_ids:
        if sid in seen:
            continue
        seen.add(sid)
        ordered.append(sid)
    return ordered


def plan_latest_sessions(
    page_ids: list[str], cached_ids: set[int], max_new: int
) -> tuple[list[str], list[str]]:
    """Solo la tanda reciente (página nueva, ids de más a menos). No backfill."""
    latest = unique_session_ids(page_ids)
    to_fetch: list[str] = []
    for sid in latest:
        try:
            num = int(sid)
        except ValueError:
            continue
        if num not in cached_ids and len(to_fetch) < max_new:
            to_fetch.append(sid)
    return to_fetch, latest


def select_latest_cache(
    cache_dir: Path, per_port: int = LATEST_PER_PORT, only_keys: set[str] | None = None
) -> list[Path]:
    if only_keys is not None:
        files = []
        for key in only_keys:
            path = cache_dir / f"{key}.json"
            if path.exists() and path.stat().st_size > 20:
                files.append(path)
        return files
    by_port: dict[str, list[tuple[int, Path]]] = {}
    for path in cache_dir.glob("*.json"):
        parsed = parse_cache_stem(path.stem)
        if parsed is None:
            continue
        port, sid = parsed
        by_port.setdefault(port, []).append((sid, path))
    files: list[Path] = []
    for items in by_port.values():
        items.sort(key=lambda item: item[0], reverse=True)
        files.extend(path for _, path in items[:per_port])
    return files


def cached_ids_for_port(cache_dir: Path, port: str) -> set[int]:
    ids: set[int] = set()
    for path in cache_dir.glob(f"{port}_*.json"):
        parsed = parse_cache_stem(path.stem)
        if parsed is None:
            continue
        ids.add(parsed[1])
    return ids


TRACK_PRETTY = {
    "d7fox_mid_ohio-no_chicane": "Mid Ohio",
    "ks_laguna_seca": "Laguna Seca",
    "doningtonpark2018-gp": "Donington Park GP",
}


def pretty_track(track: str) -> str:
    if not track:
        return "Desconocido"
    if track in TRACK_PRETTY:
        return TRACK_PRETTY[track]
    name = re.sub(r"^ks_", "", track).replace("-", " ").replace("_", " ")
    name = re.sub(r"\s+", " ", name).strip()
    return name.title()


CAR_LABELS = {
    "ks_porsche_962c_shorttail": "Porsche",
    "ks_porsche_962c_longtail": "Porsche",
    "ks_mazda_787b": "Mazda",
}


def car_label(model: str | None) -> str:
    """Nombre corto del coche para la tabla: Porsche / Mazda; otros, legible."""
    model = (model or "").strip()
    if not model or model == "-":
        return "—"
    if model in CAR_LABELS:
        return CAR_LABELS[model]
    name = re.sub(r"^ks_", "", model).replace("_", " ")
    return re.sub(r"\s+", " ", name).strip().title()


def server_number(port: str | int | None) -> int | None:
    """Server 1 = puerto acabado en 1 (8771), Server 2 = acabado en 2 (8772)."""
    digits = re.sub(r"\D", "", str(port or ""))
    return int(digits[-1]) if digits else None

def fold_name(name: str) -> str:
    nfkd = unicodedata.normalize("NFKD", name or "")
    ascii_name = "".join(ch for ch in nfkd if not unicodedata.combining(ch))
    return re.sub(r"\s+", " ", ascii_name).strip().lower()


def name_tokens(name: str) -> list[str]:
    return [tok for tok in fold_name(name).split() if tok]


def names_match(left: str, right: str) -> bool:
    """Mismo piloto aunque el nick cambie (Salvador/Salvi, Toño/Tono, Núñez/Nunez)."""
    left_toks = name_tokens(left)
    right_toks = name_tokens(right)
    if not left_toks or not right_toks:
        return False
    if left_toks == right_toks:
        return True
    if left_toks[-1] != right_toks[-1]:
        return False
    first_left = " ".join(left_toks[:-1])
    first_right = " ".join(right_toks[:-1])
    if not first_left or not first_right:
        return False
    if first_left == first_right:
        return True
    short, long = (first_left, first_right) if len(first_left) <= len(first_right) else (first_right, first_left)
    if len(short) >= 4 and long.startswith(short):
        return True
    # Salvador/Salvi: solo con un nombre de pila; "Jose Luis" y "Jose Manuel" son dos personas.
    if " " not in first_left and " " not in first_right:
        if len(first_left) >= 4 and len(first_right) >= 4 and first_left[:4] == first_right[:4]:
            return True
    # Iniciales: "JL Nuñez" / "J.L. Nuñez" es "Jose Luis Nuñez".
    for short_part, long_part in ((first_left, first_right), (first_right, first_left)):
        letters = re.sub(r"[^a-z]", "", short_part)
        words = long_part.split()
        if len(words) >= 2 and len(letters) == len(words) and letters == "".join(w[0] for w in words):
            return True
    return False


def resolve_pilot_id(name: str, known: list[tuple[str, str]]) -> str:
    """El mismo nombre escrito igual manda; si se parece a UN solo piloto, es ese; si se parece a
    dos ("JL Nuñez" con José Luis y Juan Luis), no se adivina: va aparte."""
    folded = fold_name(name)
    for pid, other in known:
        if fold_name(other) == folded:
            return pid
    hits = {pid for pid, other in known if names_match(name, other)}
    if len(hits) == 1:
        return hits.pop()
    return "ac:" + folded


def parse_sessionstat_rows(html: str) -> list[dict[str, str]]:
    rows: list[dict[str, str]] = []
    seen: set[str] = set()
    for match in re.finditer(
        r'href="sessiondetails\?sessionid=(\d+)[^"]*"[^>]*>(.*?)</tr>',
        html,
        re.I | re.S,
    ):
        sid = match.group(1)
        if sid in seen:
            continue
        tds = [strip_html(td) for td in re.findall(r"<td[^>]*>(.*?)</td>", match.group(2), re.S | re.I)]
        if not tds:
            continue
        seen.add(sid)
        rows.append(
            {
                "id": sid,
                "track": tds[0],
                "session_type": tds[1] if len(tds) > 1 else "",
                "date_text": tds[-1] if tds else "",
            }
        )
    return rows


LIVEMAP_STREAM_RE = re.compile(r"getJSON\('(livemap_stream\?[^']+)'", re.I)


def occupancy_from_stream(raw: str) -> dict[str, Any]:
    """Pilotos realmente conectados, no el número de servers stracker."""
    blob = htmlmod.unescape(raw or "")
    names: list[str] = []
    idx = blob.find('"class_data"')
    if idx >= 0:
        fragment = blob[idx : idx + 12000]
        found = re.match(r'"class_data"\s*:\s*"((?:[^"\\]|\\.)*)"', fragment, re.S)
        if found:
            try:
                fragment = json.loads('"' + found.group(1) + '"')
            except ValueError:
                fragment = found.group(1)
        for match in re.finditer(r"<tr[^>]*>(.*?)</tr>", fragment, re.S | re.I):
            tds = [strip_html(td) for td in re.findall(r"<td[^>]*>(.*?)</td>", match.group(1), re.S | re.I)]
            if len(tds) >= 3 and tds[2] and tds[2].lower() != "driver":
                names.append(tds[2])
        if not names:
            # Lo que manda el server de verdad: la tabla llega sin <tr>/<td>, una fila por piloto
            # que empieza con "<b>•</b> 1." y sigue con coche, nombre, tiempo, vueltas y diferencia.
            for chunk in re.split(r"<b>[^<]*</b>\s*\d+\.", fragment)[1:]:
                lines = [strip_html(line) for line in chunk.splitlines()]
                lines = [line for line in lines if line]
                if len(lines) >= 2:
                    names.append(lines[1])
    return {"connected_drivers": len(names), "drivers_now": names}


def parse_driver_laps(fragment: str, car: str = "-") -> list[dict[str, Any]]:
    """Tabla de vueltas de stracker: num, time, pitlane, pit, valid, ..."""
    laps: list[dict[str, Any]] = []
    for tr in re.finditer(r"<tr[^>]*>(.*?)</tr>", fragment, re.S | re.I):
        tds = [strip_html(td) for td in re.findall(r"<td[^>]*>(.*?)</td>", tr.group(1), re.S | re.I)]
        if len(tds) < 5:
            continue
        valid_cell = tds[4].lower()
        if valid_cell not in ("yes", "no", "si", "sí"):
            flags = [c.lower() for c in tds if c.lower() in ("yes", "no", "si", "sí")]
            valid_cell = flags[0] if flags else "no"
        time_ms = parse_ac_time(tds[1])
        if not time_ms:
            continue
        laps.append(
            {
                "time_ms": time_ms,
                "valid": valid_cell in ("yes", "si", "sí"),
                "splits": [0, 0, 0],
                "car": car,
            }
        )
    return laps


def connected_now(live_tracks: list[dict[str, Any]], track_id: str | None = None) -> int:
    if track_id:
        for item in live_tracks:
            if item.get("id") == track_id:
                return int(item.get("connected_drivers") or 0)
        return 0
    return sum(int(item.get("connected_drivers") or 0) for item in live_tracks)


def plan_current_event(
    rows: list[dict[str, str]],
    cached_ids: set[int],
    max_new: int,
    refresh_latest: int = REFRESH_LIVE_SESSIONS,
) -> tuple[list[str], str | None, list[str]]:
    """Evento actual. Siempre relée las N sesiones más nuevas: la de pista sigue creciendo."""
    if not rows:
        return [], None, []
    live_track = rows[0].get("track") or ""
    event: list[dict[str, str]] = []
    for row in rows:
        if (row.get("track") or "") != live_track:
            break
        event.append(row)
    latest_ids = [row["id"] for row in event]
    to_fetch: list[str] = []
    seen: set[str] = set()
    for index, row in enumerate(event):
        try:
            num = int(row["id"])
        except ValueError:
            continue
        sid = row["id"]
        if sid in seen:
            continue
        missing = num not in cached_ids
        live_refresh = index < refresh_latest
        if (missing or live_refresh) and len(to_fetch) < max_new:
            seen.add(sid)
            to_fetch.append(sid)
    return to_fetch, live_track or None, latest_ids


def files_for_tracks(cache_dir: Path, tracks: set[str], port: str | None = None) -> list[Path]:
    """Sesiones del stracker de esos circuitos; con port, solo las de ese server."""
    files: list[Path] = []
    for path in cache_dir.glob(f"{port}_*.json" if port else "*.json"):
        if path.stat().st_size <= 20:
            continue
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            continue
        if (data.get("track") or "") in tracks:
            files.append(path)
    return files


def empty_track_pilot(pid: str, name: str, track: str) -> dict[str, Any]:
    return {
        "id": pid,
        "name": name,
        "nickname": "",
        "nation": "",
        "player_id": pid,
        "best_ms": None,
        "sectors": [0, 0, 0],
        "best_sectors": [10**12, 10**12, 10**12],
        "theoretical_ms": None,
        "car": "-",
        "valid_laps": 0,
        "total_laps": 0,
        "sessions": 0,
        "track": track,
        "best_track": track,
        "last_session_at": None,
        "last_session_type": None,
        "best_session_at": None,
        "best_session_type": None,
        "avg2_ms": None,
        "avg2_laps": [],
        "avg2_track": track,
        "avg2_car": "-",
        "avg2_session_at": None,
        "history": [],
        "lap_log": [],
    }


def finalize_track_rows(pilots: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
    ranking = sorted(
        pilots.values(),
        key=lambda row: (
            row["best_ms"] is None,
            row["best_ms"] if row["best_ms"] is not None else 10**12,
            row["name"],
        ),
    )
    leader = next((row["best_ms"] for row in ranking if row["best_ms"]), None)
    for pos, row in enumerate(ranking, start=1):
        row["rank"] = pos
        row["best_display"] = format_time(row["best_ms"])
        row["gap_ms"] = None if not row["best_ms"] or not leader else row["best_ms"] - leader
        row["gap_display"] = "Lider" if row["gap_ms"] == 0 else format_gap(row["gap_ms"])
        row["sectors_display"] = [format_sector(s) for s in row["sectors"]]
        row["theoretical_display"] = format_time(row["theoretical_ms"])
        row["avg2_display"] = format_time(row.get("avg2_ms"))
        row["avg2_parts"] = [format_time(t) for t in (row.get("avg2_laps") or [])]
        row["car_label"] = car_label(row.get("car"))
        row["avg2_car_label"] = car_label(row.get("avg2_car"))
        for hist in row.get("history") or []:
            hist["time_display"] = format_time(hist.get("time_ms"))
            hist["avg2_display"] = format_time(hist.get("avg2_ms"))
            hist["car_label"] = car_label(hist.get("car"))
            hist["track_name"] = pretty_track(hist.get("track") or "")
    return ranking


def build_track_rankings(files: list[Path], only_tracks: set[str] | None = None) -> dict[str, dict[str, Any]]:
    per_track: dict[str, dict[str, dict[str, Any]]] = {}
    sessions: dict[str, int] = {}
    loaded: list[dict[str, Any]] = []
    for path in files:
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            continue
        track = data.get("track") or "Desconocido"
        if only_tracks is not None and track not in only_tracks:
            continue
        loaded.append(data)
    # Los nombres largos primero: así "JL Nuñez" ve a todos los candidatos antes de decidir,
    # sin depender del orden en que salieron las sesiones.
    names = []
    for data in loaded:
        for drv in data.get("drivers") or []:
            name = (drv.get("name") or "").strip() or str(drv.get("id") or "piloto")
            if name not in names:
                names.append(name)
    known: list[tuple[str, str]] = []
    for name in sorted(names, key=lambda n: -len(n)):
        pid = resolve_pilot_id(name, known)
        if not any(item[0] == pid for item in known):
            known.append((pid, name))
    for data in loaded:
        track = data.get("track") or "Desconocido"
        sessions[track] = sessions.get(track, 0) + 1
        date_iso = data.get("date_text") or ""
        bucket = per_track.setdefault(track, {})
        for drv in data.get("drivers") or []:
            name = (drv.get("name") or "").strip() or str(drv.get("id") or "piloto")
            pid = resolve_pilot_id(name, known)
            # El mismo piloto con dos coches son dos filas: cada coche es un resultado aparte.
            by_car: dict[str, list[dict[str, Any]]] = {}
            for lap in drv.get("laps") or []:
                by_car.setdefault(lap.get("car") or drv.get("car") or "-", []).append(lap)
            for car, laps in by_car.items():
                _add_car_session(bucket, pid, name, car, laps, data, track, date_iso)
    result: dict[str, dict[str, Any]] = {}
    for track, pilots in per_track.items():
        ranking = finalize_track_rows(pilots)
        result[track] = {
            "id": track,
            "name": pretty_track(track),
            "sessions": sessions.get(track, 0),
            "ranking": ranking,
        }
    return result


def _add_car_session(
    bucket: dict[str, dict[str, Any]],
    pid: str,
    name: str,
    car: str,
    laps: list[dict[str, Any]],
    data: dict[str, Any],
    track: str,
    date_iso: str,
) -> None:
    """Suma una sesión de un piloto con un coche a su fila (piloto+coche)."""
    row_id = f"{pid}|{car}"
    row = bucket.get(row_id)
    if row is None:
        row = empty_track_pilot(row_id, name, track)
        row["pilot_id"] = pid
        row["car"] = car
        row["avg2_car"] = car
        bucket[row_id] = row
    elif len(name) > len(row.get("name") or ""):
        row["name"] = name
    row["lap_log"].append(
        {
            "date_iso": date_iso,
            "session_type": data.get("session_type"),
            "laps": [{"ms": int(lap.get("time_ms") or 0), "valid": bool(lap.get("valid"))} for lap in laps],
        }
    )
    row["sessions"] += 1
    row["total_laps"] += len(laps)
    row["valid_laps"] += sum(1 for lap in laps if lap.get("valid"))
    if date_iso and (not row.get("last_session_at") or date_iso > (row.get("last_session_at") or "")):
        row["last_session_at"] = date_iso
        row["last_session_type"] = data.get("session_type")
    session_best = None
    for lap in laps:
        if not lap.get("valid"):
            continue
        time_ms = int(lap.get("time_ms") or 0)
        splits = lap.get("splits") or [0, 0, 0]
        if session_best is None or time_ms < session_best:
            session_best = time_ms
        if row["best_ms"] is None or time_ms < row["best_ms"]:
            row["best_ms"] = time_ms
            row["sectors"] = splits[:3] + [0] * max(0, 3 - len(splits))
            row["best_session_at"] = date_iso
            row["best_session_type"] = data.get("session_type")
            row["best_track"] = track
        for i, sec in enumerate(splits[:3]):
            if sec and sec > 0:
                row["best_sectors"][i] = min(row["best_sectors"][i], sec)
    combo2 = best_consecutive(laps, 2)
    if combo2 and (row.get("avg2_ms") is None or combo2["avg_ms"] < row["avg2_ms"]):
        row["avg2_ms"] = combo2["avg_ms"]
        row["avg2_laps"] = combo2["laps"]
        row["avg2_track"] = track
        row["avg2_car"] = car
        row["avg2_session_at"] = date_iso
    row["history"].append(
        {
            "date_iso": date_iso,
            "session_type": data.get("session_type"),
            "track": track,
            "time_ms": session_best,
            "avg2_ms": combo2["avg_ms"] if combo2 else None,
            "car": car,
            "valid_laps": sum(1 for lap in laps if lap.get("valid")),
            "total_laps": len(laps),
        }
    )
    secs = row["best_sectors"]
    row["theoretical_ms"] = int(sum(secs)) if all(s < 10**12 for s in secs) else None


def combined_ranking(
    by_track: dict[str, dict[str, Any]], track_ids: list[str], mode: str = "avg2"
) -> list[dict[str, Any]]:
    key = "avg2_ms" if mode == "avg2" else "best_ms"
    parts_key = "avg2_parts" if mode == "avg2" else None
    pilots: dict[str, dict[str, Any]] = {}
    for tid in track_ids:
        block = by_track.get(tid) or {}
        for row in block.get("ranking") or []:
            pid = row.get("id") or ""
            name = (row.get("name") or pid).strip()
            if not pid and not name:
                continue
            item = None
            for existing in pilots.values():
                if existing["id"] == pid or names_match(name, existing["name"]):
                    item = existing
                    break
            if item is None:
                item = {"id": pid or ("ac:" + fold_name(name)), "name": name, "parts_ms": {}, "parts_display": {}, "cars": {}}
                pilots[item["id"]] = item
            elif len(name) > len(item.get("name") or ""):
                item["name"] = name
            item["parts_ms"][tid] = row.get(key)
            item["parts_display"][tid] = row.get("avg2_display" if mode == "avg2" else "best_display")
            item["cars"][tid] = row.get("avg2_car" if mode == "avg2" else "car")
            if parts_key:
                item.setdefault("combo", {})[tid] = row.get(parts_key) or []
    result = []
    for item in pilots.values():
        values = [item["parts_ms"].get(tid) for tid in track_ids]
        if any(value is None for value in values):
            continue
        total = int(sum(int(v) for v in values))
        result.append(
            {
                "id": item["id"],
                "name": item["name"],
                "total_ms": total,
                "total_display": format_time(total),
                "parts_ms": [item["parts_ms"].get(tid) for tid in track_ids],
                "parts_display": [item["parts_display"].get(tid) or format_time(item["parts_ms"].get(tid)) for tid in track_ids],
                "cars": [item["cars"].get(tid) or "-" for tid in track_ids],
            }
        )
    result.sort(key=lambda row: row["total_ms"])
    leader = result[0]["total_ms"] if result else None
    for pos, row in enumerate(result, start=1):
        row["rank"] = pos
        row["gap_display"] = "Lider" if leader is not None and row["total_ms"] == leader else format_gap(row["total_ms"] - (leader or 0))
    return result


# --- Resultados del Server Manager (/results): compuesto de rueda y lastre por vuelta ---
# El stracker no guarda ni la rueda ni el lastre; el Server Manager sí, en un JSON por sesión.
# Van con retraso (la sesión en curso puede no estar completa), así que solo añaden datos
# a las filas del stracker: nunca cambian el mejor tiempo ni el orden por defecto.
RESULTS_ROW_RE = re.compile(
    r'data-href="/results/([^"]+)">\s*<td>\s*([^<]*?)\s*</td>\s*<td>\s*([^<]*?)\s*</td>\s*<td>\s*([^<]*?)\s*</td>',
    re.S,
)
RESULTS_MAX_PAGES = 8
RESULTS_REFRESH_LATEST = 2


def results_base(stracker_base: str) -> str:
    return stracker_base.rsplit("/stracker", 1)[0]


def parse_results_rows(html: str) -> list[dict[str, str]]:
    return [
        {"file": f, "date_text": strip_html(d), "type": strip_html(t), "track_text": strip_html(tr)}
        for f, d, t, tr in RESULTS_ROW_RE.findall(html or "")
    ]


def plan_results_event(
    rows: list[dict[str, str]], cached: set[str], refresh_latest: int = RESULTS_REFRESH_LATEST
) -> tuple[list[str], list[str], bool]:
    """Sesiones del circuito actual (seguidas desde la más nueva). Devuelve (a bajar, del evento, ¿acabó el evento?)."""
    if not rows:
        return [], [], True
    track = rows[0]["track_text"]
    event: list[str] = []
    ended = False
    for row in rows:
        if row["track_text"] != track:
            ended = True
            break
        event.append(row["file"])
    to_fetch = [f for i, f in enumerate(event) if f not in cached or i < refresh_latest]
    return to_fetch, event, ended


def result_track_id(data: dict[str, Any]) -> str:
    name = data.get("TrackName") or ""
    config = data.get("TrackConfig") or ""
    return f"{name}-{config}" if config else name


def compact_result(data: dict[str, Any]) -> dict[str, Any]:
    ballast: dict[str, int] = {}
    entry_ballast: list[list[Any]] = []
    for car in data.get("Cars") or []:
        name = ((car.get("Driver") or {}).get("Name") or "").strip()
        if name:
            ballast[name] = int(car.get("BallastKG") or 0)
            if car.get("Model"):
                entry_ballast.append([name, car["Model"], int(car.get("BallastKG") or 0)])
    laps = []
    for lap in data.get("Laps") or []:
        name = (lap.get("DriverName") or "").strip()
        if not name or not lap.get("LapTime"):
            continue
        laps.append(
            {
                "driver": name,
                "car": lap.get("CarModel") or "",
                "tyre": (lap.get("Tyre") or "?").strip() or "?",
                "ballast": int(lap.get("BallastKG") or 0),
                "ms": int(lap.get("LapTime") or 0),
                "cuts": int(lap.get("Cuts") or 0),
                "ts": int(lap.get("Timestamp") or 0),
            }
        )
    return {
        "track": result_track_id(data),
        "date": data.get("Date") or "",
        "type": data.get("Type") or "",
        "ballast": ballast,
        "entry_ballast": entry_ballast,
        "laps": laps,
    }


def tyre_key(car_model: str, tyre: str) -> str:
    # El modelo entero: el Porsche corto y el largo son coches distintos aunque la tabla diga "Porsche".
    return f"{car_model}|{tyre}"


# Hueco máximo entre la fecha de una sesión del stracker y la de la misma sesión en resultados o
# directo. Medido el 30-sep: casi siempre 0 o ~180 min (resultados fecha el FINAL de sesiones de
# 3 h), alguna vez 117-118; nunca más de 181. Por encima, es otra sesión aunque el tiempo cuadre.
MATCH_MAX_GAP_MINUTES = 240


def minute_of(date_text: str) -> int | None:
    """'2026-09-29 20:36' o '2026-09-29T20:54:00+02:00' -> minuto absoluto, para comparar fechas de
    fuentes distintas. Sin huso (el stracker) es hora local del PC, que es la del server."""
    m = re.match(
        r"(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})(?::\d{2}(?:\.\d+)?)?\s*(Z|[+-]\d{2}:?\d{2})?$",
        (date_text or "").strip(),
    )
    if not m:
        return None
    y, mo, d, h, mi = (int(x) for x in m.groups()[:5])
    zone = m.group(6)
    try:
        if zone is None:
            stamp = datetime(y, mo, d, h, mi).astimezone()
        elif zone == "Z":
            stamp = datetime(y, mo, d, h, mi, tzinfo=timezone.utc)
        else:
            sign = -1 if zone[0] == "-" else 1
            digits = zone[1:].replace(":", "")
            offset = timedelta(hours=int(digits[:2]), minutes=int(digits[2:]))
            stamp = datetime(y, mo, d, h, mi, tzinfo=timezone(sign * offset))
    except (ValueError, OverflowError, OSError):
        return None
    return int(stamp.timestamp() // 60)


def tyre_stats(sessions: list[dict[str, Any]]) -> dict[str, Any]:
    """Por piloto: mejor vuelta limpia con cada coche+compuesto, el lastre más reciente con cada coche
    y todas sus vueltas con rueda y lastre (para ponérselos a la lista de vueltas del stracker)."""
    drivers: dict[str, dict[str, Any]] = {}
    keys: dict[str, int] = {}

    def new_item() -> dict[str, Any]:
        return {"by_tyre": {}, "ballast_kg": None, "ballast_ts": -1, "car_ballast": {}, "laps": []}

    for sess in sorted(sessions, key=lambda s: s.get("date") or ""):
        # La marca de tiempo de cada vuelta es relativa a su sesión: se reinicia al cambiar de sesión.
        for item in drivers.values():
            item["ballast_ts"] = -1
        date = sess.get("date") or ""
        for name, kg in (sess.get("ballast") or {}).items():
            item = drivers.setdefault(name, new_item())
            item["ballast_kg"] = kg
            item["ballast_ts"] = -1
            item["ballast_date"] = date
        # Lista de inscritos con su coche: vale como lastre de ese coche aunque no haya dado vueltas.
        for name, model, kg in sess.get("entry_ballast") or []:
            item = drivers.setdefault(name, new_item())
            mark = item["car_ballast"].get(model)
            if mark is None or (date, -1) >= (mark["date"], mark["ts"]):
                item["car_ballast"][model] = {"kg": kg, "date": date, "ts": -1}
        for lap in sess.get("laps") or []:
            item = drivers.setdefault(lap["driver"], new_item())
            car = lap["car"]
            if lap["ts"] >= item["ballast_ts"]:
                item["ballast_kg"] = lap["ballast"]
                item["ballast_ts"] = lap["ts"]
                item["ballast_date"] = date
            mark = item["car_ballast"].get(car)
            if mark is None or (date, lap["ts"]) >= (mark["date"], mark["ts"]):
                item["car_ballast"][car] = {"kg": lap["ballast"], "date": date, "ts": lap["ts"]}
            if lap["ms"] > 0:
                item["laps"].append({"car": car, "ms": lap["ms"], "tyre": lap["tyre"], "ballast": lap["ballast"], "date": date})
            if lap["cuts"] or lap["ms"] <= 0:
                continue
            key = tyre_key(lap["car"], lap["tyre"])
            keys[key] = keys.get(key, 0) + 1
            best = item["by_tyre"].get(key)
            if best is None or lap["ms"] < best["ms"]:
                item["by_tyre"][key] = {"ms": lap["ms"], "tyre": lap["tyre"], "car": car_label(car)}
    cars = {k.split("|", 1)[0] for k in keys}
    labels = {car: car_label(car) for car in cars}
    # Dos modelos con el mismo nombre corto (Porsche corto/largo): se usa el nombre largo.
    for car in cars:
        if sum(1 for other in cars if labels[other] == labels[car]) > 1:
            labels[car] = re.sub(r"\s+", " ", re.sub(r"^ks_", "", car).replace("_", " ")).strip().title()
    order = sorted(keys, key=lambda k: (labels[k.split("|", 1)[0]], -keys[k], k))
    compounds = [
        {"key": k, "tyre": k.split("|", 1)[1], "car": labels[k.split("|", 1)[0]],
         "label": k.split("|", 1)[1] if len(cars) <= 1 else f"{labels[k.split('|', 1)[0]]} {k.split('|', 1)[1]}"}
        for k in order
    ]
    return {"drivers": drivers, "compounds": compounds}


def tag_lap_log(lap_log: list[dict[str, Any]], known: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Pone compuesto y lastre a cada vuelta del stracker buscando la misma vuelta (±1 ms) en los
    resultados o en el directo; si hay dos iguales, la de fecha más cercana. Sesión más nueva primero."""
    used: set[int] = set()
    out = []
    for sess in sorted(lap_log, key=lambda s: s.get("date_iso") or "", reverse=True):
        when = minute_of(sess.get("date_iso") or "")
        laps = []
        for num, lap in enumerate(sess.get("laps") or [], start=1):
            cands = []
            for i, cand in enumerate(known):
                if i in used or abs(cand["ms"] - lap["ms"]) > 1:
                    continue
                other = minute_of(cand.get("date") or "")
                if other is None or when is None or abs(other - when) > MATCH_MAX_GAP_MINUTES:
                    continue
                cands.append((abs(other - when), i))
            hit = None
            if cands:
                gap = min(c[0] for c in cands)
                closest = [i for g, i in cands if g == gap]
                # Dos vueltas iguales al milisegundo con rueda o lastre distintos: no se sabe cuál es.
                if len({(known[i]["tyre"], known[i]["ballast"]) for i in closest}) == 1:
                    used.add(min(closest))
                    hit = known[min(closest)]
            laps.append(
                {
                    "n": num,
                    "ms": lap["ms"],
                    "display": format_time(lap["ms"]),
                    "valid": lap["valid"],
                    "tyre": hit["tyre"] if hit else None,
                    "ballast": hit["ballast"] if hit else None,
                }
            )
        out.append({"date_iso": sess.get("date_iso"), "session_type": sess.get("session_type"), "laps": laps})
    return out


def apply_tyre_stats(block: dict[str, Any], stats: dict[str, Any]) -> None:
    """Pega a cada fila (piloto+coche) sus tiempos por compuesto con ESE coche, su lastre con ese coche
    y el compuesto y lastre de cada vuelta de su lista."""
    block["compounds"] = stats.get("compounds") or []
    drivers = stats.get("drivers") or {}
    rows = block.get("ranking") or []
    pilots: list[tuple[str, str]] = []
    for row in rows:
        pid = row.get("pilot_id") or "ac:" + fold_name(row.get("name") or "")
        if not any(p == pid for p, _ in pilots):
            pilots.append((pid, row.get("name") or ""))
    # El mismo piloto puede venir escrito de varias formas en distintas sesiones
    # ("jordi martinez", "Jordi Martinez", "JL Nuñez"): se juntan, pero solo si el nombre
    # apunta a UN piloto de la tabla; si vale para dos, no se le da a ninguno.
    owner = {n: resolve_pilot_id(n, pilots) for n in drivers}
    for row in rows:
        pid = row.get("pilot_id") or "ac:" + fold_name(row.get("name") or "")
        car = row.get("car")
        items = [item for n, item in drivers.items() if owner[n] == pid]
        by_tyre: dict[str, Any] = {}
        for item in items:
            for key, val in (item.get("by_tyre") or {}).items():
                if key.split("|", 1)[0] != car:
                    continue
                if key not in by_tyre or val["ms"] < by_tyre[key]["ms"]:
                    by_tyre[key] = val
        row["by_tyre"] = {key: {**val, "display": format_time(val["ms"])} for key, val in by_tyre.items()}
        # Solo el lastre visto con ESE coche; el de otro coche no vale para este.
        marks = [item["car_ballast"][car] for item in items if car in (item.get("car_ballast") or {})]
        row["ballast_kg"] = max(marks, key=lambda m: (m["date"], m["ts"]))["kg"] if marks else None
        known = [lap for item in items for lap in item.get("laps") or [] if lap["car"] == car]
        row["lap_log"] = tag_lap_log(row.get("lap_log") or [], known)


# --- Directo del Server Manager (el mismo panel "Live Timing" de la web del server) ---
# Al conectar, el server manda una foto completa (EventType 200) con quién está dentro,
# su rueda actual, su lastre y la mejor vuelta de la sesión en curso con el compuesto que llevaba.
# Así el compuesto sale al momento, sin esperar a que la sesión acabe y aparezca en /results.
LIVE_REFRESH_SECONDS = 10.0
LIVE_KEEP_SECONDS = 90.0
LIVE_WS_TIMEOUT = 6.0
LIVE_SNAPSHOT_EVENT = 200
NO_LAP_NS = 999999999 * 1_000_000


def board_key(port: str | int | None, track: str) -> str:
    """Cada server tiene su propia tabla aunque los dos carguen el mismo circuito."""
    return f"{port}:{track}" if port else track


def parse_ws_frames(buf: bytes) -> tuple[list[bytes], bytes]:
    """Trocea tramas websocket del server. Devuelve los mensajes de texto completos y lo que sobra."""
    out: list[bytes] = []
    pos = 0
    while len(buf) - pos >= 2:
        b1, b2 = buf[pos], buf[pos + 1]
        size = b2 & 0x7F
        head = 2
        if size == 126:
            if len(buf) - pos < 4:
                break
            size = struct.unpack(">H", buf[pos + 2 : pos + 4])[0]
            head = 4
        elif size == 127:
            if len(buf) - pos < 10:
                break
            size = struct.unpack(">Q", buf[pos + 2 : pos + 10])[0]
            head = 10
        if b2 & 0x80:
            head += 4
        if len(buf) - pos < head + size:
            break
        data = buf[pos + head : pos + head + size]
        if b2 & 0x80:
            mask = buf[pos + head - 4 : pos + head]
            data = bytes(c ^ mask[i % 4] for i, c in enumerate(data))
        if b1 & 0x0F in (0, 1):
            out.append(data)
        pos += head + size
    return out, buf[pos:]


def ws_snapshot(host: str, port: int, timeout: float = LIVE_WS_TIMEOUT) -> dict[str, Any] | None:
    """Abre el directo del Server Manager y devuelve la foto completa (o None si no llega a tiempo)."""
    deadline = time.time() + timeout
    key = base64.b64encode(os.urandom(16)).decode()
    with socket.create_connection((host, port), timeout=timeout) as sock:
        sock.sendall(
            (
                f"GET /api/race-control HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n"
                f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n"
                f"User-Agent: {USER_AGENT}\r\n\r\n"
            ).encode()
        )
        buf = b""
        while b"\r\n\r\n" not in buf:
            chunk = sock.recv(4096)
            if not chunk or time.time() > deadline:
                return None
            buf += chunk
        head, buf = buf.split(b"\r\n\r\n", 1)
        if b" 101 " not in head.split(b"\r\n", 1)[0] + b" ":
            return None
        while time.time() < deadline:
            messages, buf = parse_ws_frames(buf)
            for raw in messages:
                try:
                    msg = json.loads(raw)
                except ValueError:
                    continue
                if isinstance(msg, dict) and msg.get("EventType") == LIVE_SNAPSHOT_EVENT:
                    return msg.get("Message") or {}
            sock.settimeout(max(0.2, deadline - time.time()))
            try:
                chunk = sock.recv(65536)
            except socket.timeout:
                return None
            if not chunk:
                return None
            buf += chunk
    return None


def live_from_snapshot(snap: dict[str, Any]) -> dict[str, Any]:
    """Quién está conectado y la mejor vuelta de la sesión en curso de cada uno, con su compuesto."""
    info = snap.get("SessionInfo") or {}
    track = result_track_id({"TrackName": info.get("Track") or "", "TrackConfig": info.get("TrackConfig") or ""})
    connected = (snap.get("ConnectedDrivers") or {}).get("Drivers") or {}
    order = (snap.get("ConnectedDrivers") or {}).get("GUIDsInPositionalOrder") or []
    guids = [g for g in order if g in connected] + [g for g in connected if g not in order]
    names = [((connected[g].get("CarInfo") or {}).get("DriverName") or "").strip() for g in guids]
    names = [n for n in names if n]
    ballast: dict[str, int] = {}
    entry_ballast: list[list[Any]] = []
    laps: list[dict[str, Any]] = []
    for group in ("DisconnectedDrivers", "ConnectedDrivers"):
        for drv in ((snap.get(group) or {}).get("Drivers") or {}).values():
            name = ((drv.get("CarInfo") or {}).get("DriverName") or "").strip()
            if not name:
                continue
            ballast[name] = int(drv.get("Ballast") or 0)
            if (drv.get("CarInfo") or {}).get("CarModel"):
                entry_ballast.append([name, drv["CarInfo"]["CarModel"], ballast[name]])
            for model, car in (drv.get("Cars") or {}).items():
                best_ns = int(car.get("BestLap") or 0)
                tyre = (car.get("TyreBestLap") or "").strip()
                # Sin vuelta limpia el server deja 0 o 999999999 ms: eso no es un tiempo.
                if best_ns <= 0 or best_ns >= NO_LAP_NS or not tyre:
                    continue
                laps.append(
                    {
                        "driver": name,
                        "car": model,
                        "tyre": tyre,
                        "ballast": ballast[name],
                        "ms": best_ns // 1_000_000,
                        "cuts": 0,
                        "ts": 0,
                    }
                )
    # Marcas para ir guardando CADA vuelta: el server solo da la mejor por coche, pero sí el nº de
    # vueltas y la última; al subir el nº, esa última es una vuelta nueva.
    marks: list[dict[str, Any]] = []
    for drv in connected.values():
        info_car = drv.get("CarInfo") or {}
        name = (info_car.get("DriverName") or "").strip()
        if not name:
            continue
        for model, car in (drv.get("Cars") or {}).items():
            marks.append(
                {
                    "driver": name,
                    "car": model,
                    "num_laps": int(car.get("NumLaps") or 0),
                    "last_ms": int(car.get("LastLap") or 0) // 1_000_000,
                    # la rueda puesta ahora solo vale para el coche que lleva ahora
                    "tyre": (info_car.get("Tyres") or "").strip() if model == info_car.get("CarModel") else "",
                    "ballast": int(drv.get("Ballast") or 0),
                }
            )
    return {
        "track": track,
        "connected_drivers": len(names),
        "drivers_now": names,
        "marks": marks,
        "session": {
            "track": track,
            "date": snap.get("SessionStartTime") or "",
            "type": "live",
            "ballast": ballast,
            "entry_ballast": entry_ballast,
            "laps": laps,
        },
    }


def capture_live_laps(store: dict[str, Any], live: dict[str, Any]) -> None:
    """Va guardando cada vuelta que se completa en la sesión en curso, con la rueda y el lastre que
    lleva el coche en ese momento. Al cambiar de sesión o de circuito, empieza de cero."""
    session = live.get("session") or {}
    key = [live.get("track") or "", session.get("date") or ""]
    if store.get("key") != key:
        store.clear()
        store.update(key=key, seen={}, gear={}, laps=[], ts=0)
    seen = store["seen"]
    gear = store.setdefault("gear", {})
    for mark in live.get("marks") or []:
        who = f"{mark['driver']}|{mark['car']}"
        before = seen.get(who)
        seen[who] = mark["num_laps"]
        before_gear = gear.get(who)
        gear[who] = [mark["tyre"], mark["ballast"]]
        # La primera vez que se le ve no se sabe si esa última vuelta ya estaba guardada.
        if before is None or mark["num_laps"] <= before:
            continue
        # Si entre dos lecturas cambió de rueda o de lastre Y acabó una vuelta, no se sabe con cuál
        # la hizo (vuelta de entrada a boxes): mejor sin dato que con el equivocado.
        if before_gear is not None and before_gear != gear[who]:
            continue
        ms = mark["last_ms"]
        if ms <= 0 or ms * 1_000_000 >= NO_LAP_NS or not mark["tyre"]:
            continue
        store["ts"] += 1
        store["laps"].append(
            {
                "driver": mark["driver"],
                "car": mark["car"],
                "tyre": mark["tyre"],
                "ballast": mark["ballast"],
                "ms": ms,
                "cuts": 0,
                "ts": store["ts"],
            }
        )


def valid_times_by_driver(files: list[Path]) -> dict[tuple[str, str], set[int]]:
    """Vueltas válidas por (piloto, coche) de la sesión MÁS NUEVA del stracker de ese server, que es
    la que está en marcha: una limpia de ayer o con otro coche no sirve para dar por buena una de hoy."""
    loaded = []
    for path in files:
        try:
            loaded.append(json.loads(path.read_text(encoding="utf-8")))
        except (OSError, json.JSONDecodeError):
            continue
    newest = max((minute_of(d.get("date_text") or "") or -1 for d in loaded), default=-1)
    out: dict[tuple[str, str], set[int]] = {}
    for data in loaded:
        if (minute_of(data.get("date_text") or "") or -1) != newest:
            continue
        for drv in data.get("drivers") or []:
            name = drv.get("name") or ""
            for lap in drv.get("laps") or []:
                times = out.setdefault((name, lap.get("car") or drv.get("car") or "-"), set())
                if lap.get("valid"):
                    times.add(int(lap.get("time_ms") or 0))
    return out


def checked_live_session(session: dict[str, Any], valid: dict[tuple[str, str], set[int]]) -> dict[str, Any]:
    """Solo se fía del compuesto del directo si ese mismo tiempo es una vuelta válida en el stracker,
    del mismo piloto con el mismo coche en la sesión en curso."""
    names = [(name, name) for name in {name for name, _ in valid}]
    laps = []
    for lap in session.get("laps") or []:
        who = resolve_pilot_id(lap["driver"], names)
        times = valid.get((who, lap["car"]), set())
        ok = any(abs(lap["ms"] - t) <= 1 for t in times)
        laps.append({**lap, "cuts": 0 if ok else 1})
    return {**session, "laps": laps}


def live_session_laps(best: list[dict[str, Any]], captured: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """La mejor vuelta que da el server (su rueda es la buena) y detrás cada vuelta guardada, sin
    contar dos veces la misma: la mejor también se guardó al pasar por meta."""
    out = list(best)
    for lap in captured:
        twin = next((b for b in best if b["driver"] == lap["driver"] and b["car"] == lap["car"]
                     and abs(b["ms"] - lap["ms"]) <= 1), None)
        if twin is None:
            out.append(lap)
    return out


class AcScanner:
    def __init__(self) -> None:
        self.lock = threading.Lock()
        self.stop_event = threading.Event()
        self.trigger = threading.Event()
        self.cache_dir = DATA_DIR / "ac"
        self.results_dir = DATA_DIR / "results"
        self.state_path = DATA_DIR / "ac_leaderboard.json"
        self.occupancy_by_port: dict[str, dict[str, Any]] = {}
        self.occupancy_seen: dict[str, float] = {}
        self.live_by_port: dict[str, dict[str, Any]] = {}
        self.captured_by_port: dict[str, dict[str, Any]] = {}
        self.state: dict[str, Any] = {
            "scanning": False,
            "message": "Stracker AC en espera.",
            "progress": {"done": 0, "total": 0},
            "last_scan": None,
            "error": None,
            "live": {},
            "tracks": [],
            "live_tracks": [],
            "by_track": {},
            "ranking": [],
            "sessions_indexed": 0,
            "pilots_seen": 0,
            "servers_online": [],
            "source": STRACKER_BASES[0] + "/lapstat",
        }
        self._load_saved()

    def snapshot(self) -> dict[str, Any]:
        with self.lock:
            state = dict(self.state)
            state["ranking"] = public_ranking(state.get("ranking") or [])
            cleaned: dict[str, Any] = {}
            for tid, block in (state.get("by_track") or {}).items():
                item = dict(block)
                item["ranking"] = public_ranking(item.get("ranking") or [])
                cleaned[tid] = item
            state["by_track"] = cleaned
        return state

    def lap_log(self, board: str, row_id: str) -> list[dict[str, Any]]:
        """Todas las vueltas de una fila (piloto+coche) de una tabla, sesión más nueva primero."""
        with self.lock:
            block = (self.state.get("by_track") or {}).get(board) or {}
            for row in block.get("ranking") or []:
                if row.get("id") == row_id:
                    return list(row.get("lap_log") or [])
        return []

    def _update(self, **kwargs: Any) -> None:
        with self.lock:
            self.state.update(kwargs)

    def _load_saved(self) -> None:
        if not self.state_path.exists():
            return
        try:
            saved = json.loads(self.state_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            return
        if isinstance(saved, dict) and (saved.get("ranking") or saved.get("by_track")):
            self.state.update(
                {
                    "ranking": saved.get("ranking") or [],
                    "tracks": saved.get("tracks") or [],
                    "live_tracks": saved.get("live_tracks") or [],
                    "by_track": saved.get("by_track") or {},
                    "sessions_indexed": saved.get("sessions_indexed") or 0,
                    "pilots_seen": saved.get("pilots_seen") or 0,
                    "last_scan": saved.get("last_scan"),
                    "servers_online": saved.get("servers_online") or [],
                    "message": "Clasificacion AC en cache. Actualizando...",
                }
            )

    def _save(self) -> None:
        by_track = {}
        for tid, block in (self.state.get("by_track") or {}).items():
            item = dict(block)
            item["ranking"] = public_ranking(item.get("ranking") or [])
            by_track[tid] = item
        payload = {
            "ranking": public_ranking(self.state.get("ranking") or []),
            "tracks": self.state.get("tracks") or [],
            "live_tracks": self.state.get("live_tracks") or [],
            "by_track": by_track,
            "sessions_indexed": self.state.get("sessions_indexed") or 0,
            "pilots_seen": self.state.get("pilots_seen") or 0,
            "last_scan": self.state.get("last_scan"),
            "servers_online": self.state.get("servers_online") or [],
        }
        tmp = self.state_path.with_suffix(".tmp")
        tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
        tmp.replace(self.state_path)

    def fetch_text(self, url: str, timeout: int = 12) -> str:
        req = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
        with urlopen(req, timeout=timeout) as resp:
            return resp.read().decode("utf-8", "replace")

    def probe(self, base: str) -> bool:
        try:
            self.fetch_text(base + "/lapstat", timeout=4)
            return True
        except Exception:
            return False

    def request_rescan(self) -> None:
        self.trigger.set()

    def loop(self) -> None:
        while not self.stop_event.is_set():
            self.trigger.clear()
            try:
                self.scan_once()
            except Exception as exc:
                self._update(scanning=False, error=str(exc), message="Error en stracker de AC.")
            if self.trigger.is_set():
                continue
            self.trigger.wait(AC_REFRESH_SECONDS)

    def detect_live_tracks(self) -> list[dict[str, str]]:
        live: list[dict[str, str]] = []
        # Si ya se sabe qué servers responden, el apagado no asoma ni durante el repaso.
        online = self.state.get("servers_online") or []
        for base in STRACKER_BASES:
            if online and base not in online:
                continue
            port = base.split(":")[2].split("/")[0]
            ids = cached_ids_for_port(self.cache_dir, port)
            if not ids:
                continue
            newest = max(ids)
            path = self.cache_dir / f"{cache_session_key(port, str(newest))}.json"
            try:
                data = json.loads(path.read_text(encoding="utf-8"))
            except (OSError, json.JSONDecodeError):
                continue
            track = data.get("track") or ""
            if not track:
                continue
            live.append(
                {
                    "id": track,
                    "name": pretty_track(track),
                    "port": port,
                    "server": server_number(port),
                    "session_id": str(newest),
                    "date_text": data.get("date_text") or "",
                    "session_type": data.get("session_type") or "",
                }
            )
        return live

    def apply_occupancy(self, live_tracks: list[dict[str, Any]]) -> list[dict[str, Any]]:
        out: list[dict[str, Any]] = []
        for item in live_tracks:
            row = dict(item)
            occ = self.occupancy_by_port.get(str(row.get("port") or "")) or {}
            row["connected_drivers"] = int(occ.get("connected_drivers") or 0)
            row["drivers_now"] = list(occ.get("drivers_now") or [])
            out.append(row)
        return out

    def fetch_occupancy(self, base: str) -> dict[str, Any] | None:
        """Plan B (mapa del stracker). None si no se pudo leer: eso no es lo mismo que cero pilotos."""
        try:
            page = self.fetch_text(base + "/livemap", timeout=6)
            match = LIVEMAP_STREAM_RE.search(page)
            if not match:
                return None
            raw = self.fetch_text(base + "/" + match.group(1), timeout=6)
            return occupancy_from_stream(raw)
        except Exception:
            return None

    def fetch_live(self, base: str) -> dict[str, Any] | None:
        host = base.split("//", 1)[1].split(":", 1)[0]
        port = int(base.split(":")[2].split("/")[0])
        try:
            snap = ws_snapshot(host, port)
        except (OSError, ValueError):
            return None
        return live_from_snapshot(snap) if snap is not None else None

    def refresh_live(self, bases: list[str]) -> None:
        """Quién está dentro, sacado del propio server; si falla, del stracker; si fallan los dos, se
        mantiene lo último bueno un rato en vez de caer a 0 (un fallo de red suelto no vacía la pista)."""
        now = time.time()
        for base in bases:
            port = base.split(":")[2].split("/")[0]
            live = self.fetch_live(base)
            if live is not None:
                with self.lock:
                    # Dos lecturas pueden cruzarse: una atrasada de una sesión anterior del mismo
                    # circuito no puede pisar a la nueva ni borrar sus vueltas guardadas.
                    prev = self.live_by_port.get(port)
                    if prev and prev.get("track") == live.get("track"):
                        old = minute_of((prev.get("session") or {}).get("date") or "")
                        new = minute_of((live.get("session") or {}).get("date") or "")
                        if old is not None and new is not None and new < old:
                            live = prev
                    if live is not prev:
                        self.live_by_port[port] = live
                        capture_live_laps(self.captured_by_port.setdefault(port, {}), live)
                occ = {"connected_drivers": live["connected_drivers"], "drivers_now": live["drivers_now"]}
            else:
                occ = self.fetch_occupancy(base)
            if occ is not None:
                self.occupancy_by_port[port] = occ
                self.occupancy_seen[port] = now
            elif now - self.occupancy_seen.get(port, 0) > LIVE_KEEP_SECONDS:
                self.occupancy_by_port[port] = {"connected_drivers": 0, "drivers_now": []}
        with self.lock:
            tracks = self.apply_occupancy(self.state.get("live_tracks") or [])
            connected = connected_now(tracks)
            live_state = dict(self.state.get("live") or {})
            live_state.update(connected_drivers=connected, event_in_progress=connected > 0)
            self.state["live_tracks"] = tracks
            self.state["live"] = live_state

    def live_loop(self) -> None:
        while not self.stop_event.is_set():
            self.stop_event.wait(LIVE_REFRESH_SECONDS)
            if self.stop_event.is_set():
                break
            try:
                self.refresh_live(self.state.get("servers_online") or STRACKER_BASES)
            except Exception as exc:
                log(f"directo: {exc}")

    def build_boards(self, live_tracks: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
        """Una tabla por server: solo sus sesiones, sus resultados y su directo."""
        boards: dict[str, dict[str, Any]] = {}
        for item in live_tracks:
            port = str(item.get("port") or "")
            track = item["track"]
            files = files_for_tracks(self.cache_dir, {track}, port or None)
            block = build_track_rankings(files, {track}).get(track) or {"sessions": 0, "ranking": []}
            block.update(id=item["id"], track=track, name=pretty_track(track), port=port)
            sessions = self.cached_results(track, port or None)
            with self.lock:
                live = self.live_by_port.get(port)
                store = self.captured_by_port.get(port) or {}
                captured = []
                if live and store.get("key") == [track, live["session"].get("date") or ""]:
                    captured = list(store.get("laps") or [])
            if live and live.get("track") == track:
                current = {**live["session"], "laps": live_session_laps(live["session"].get("laps") or [], captured)}
                sessions = sessions + [checked_live_session(current, valid_times_by_driver(files))]
            apply_tyre_stats(block, tyre_stats(sessions))
            boards[item["id"]] = block
        return boards

    def _publish(self, live_tracks: list[dict[str, str]] | None = None, **extra: Any) -> None:
        raw = live_tracks if live_tracks is not None else self.detect_live_tracks()
        keyed = []
        for item in raw:
            track = item.get("track") or item.get("id") or ""
            if track:
                keyed.append({**item, "id": board_key(item.get("port"), track), "track": track})
        live_tracks = self.apply_occupancy(keyed)
        by_track = self.build_boards(live_tracks)
        ranking = []
        if live_tracks:
            ranking = (by_track.get(live_tracks[0]["id"]) or {}).get("ranking") or []
        tracks = [
            {"id": item["id"], "name": item.get("name") or pretty_track(item["id"]), "sessions": (by_track.get(item["id"]) or {}).get("sessions") or 0}
            for item in live_tracks
        ]
        names = [item.get("name") or item.get("id") for item in live_tracks]
        connected = connected_now(live_tracks)
        payload = {
            "ranking": ranking,
            "tracks": tracks,
            "live_tracks": live_tracks,
            "by_track": by_track,
            "pilots_seen": sum(len((block.get("ranking") or [])) for block in by_track.values()),
            "sessions_indexed": sum(int(block.get("sessions") or 0) for block in by_track.values()),
            "live": {
                "event_in_progress": connected > 0,
                "connected_drivers": connected,
                "server_name": " + ".join(names) if names else "Assetto Corsa",
                "track_name": " + ".join(names) if names else "Assetto Corsa",
            },
        }
        payload.update(extra)
        self._update(**payload)
        try:
            self._save()
        except OSError:
            pass

    def scan_once(self) -> None:
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self._publish(
            scanning=True,
            error=None,
            message="Mostrando el evento actual en cache. Consultando stracker...",
        )
        online: list[str] = []
        for base in STRACKER_BASES:
            if self.stop_event.is_set():
                break
            if self.probe(base):
                online.append(base)
        self._update(servers_online=online)
        self.refresh_live(online)
        self._publish(
            scanning=True,
            error=None,
            message="Consultando quién está en pista...",
        )
        if not online:
            self._publish(
                scanning=False,
                message="No se alcanza el stracker (8771/8772). Mostrando cache si existe.",
                error="Puertos 8771/8772 no responden desde esta maquina.",
            )
            return

        total_new = 0
        live_tracks: list[dict[str, str]] = []
        for base in online:
            if self.stop_event.is_set():
                break
            added, live = self._scan_server(base)
            total_new += added
            if live:
                live_tracks.append(live)
                self._publish(
                    scanning=True,
                    message=f"Evento actual: {' + '.join(item['name'] for item in live_tracks)}",
                )

        for base in online:
            if self.stop_event.is_set():
                break
            try:
                self._scan_results(base)
            except Exception as exc:
                log(f"results {base}: {exc}")

        now = datetime.now(timezone.utc).isoformat()
        self._publish(
            live_tracks=live_tracks or None,
            scanning=False,
            last_scan=now,
            message="Evento actual actualizado." if total_new else "Sesión en curso releída.",
            error=None,
        )

    def _scan_server(self, base: str) -> tuple[int, dict[str, str] | None]:
        port = base.split(":")[2].split("/")[0]
        try:
            html = self.fetch_text(f"{base}/sessionstat?page=0")
        except Exception as exc:
            self._update(error=f"AC {port}: {exc}")
            return 0, None
        rows = parse_sessionstat_rows(html)
        cached_ids = cached_ids_for_port(self.cache_dir, port)
        to_fetch, live_track, latest_ids = plan_current_event(rows, cached_ids, MAX_NEW_PER_SERVER)
        if not live_track:
            return 0, None
        live = {
            "id": live_track,
            "name": pretty_track(live_track),
            "port": port,
            "server": server_number(port),
            "session_id": latest_ids[0] if latest_ids else "",
            "date_text": rows[0].get("date_text") if rows else "",
            "session_type": rows[0].get("session_type") if rows else "",
        }
        new_count = 0
        for index, sid in enumerate(to_fetch, start=1):
            if self.stop_event.is_set():
                break
            cache_path = self.cache_dir / f"{cache_session_key(port, sid)}.json"
            self._update(
                message=f"{live['name']}: sesión {sid} ({index}/{len(to_fetch)})",
                progress={"done": index - 1, "total": len(to_fetch)},
            )
            parsed = self._fetch_session(base, sid)
            if parsed:
                cache_path.write_text(json.dumps(parsed), encoding="utf-8")
                new_count += 1
            time.sleep(0.05)
        return new_count, live

    def cached_results(self, track_id: str, port: str | None = None) -> list[dict[str, Any]]:
        out: list[dict[str, Any]] = []
        if not self.results_dir.exists():
            return out
        for path in self.results_dir.glob(f"{port}_*.json" if port else "*.json"):
            try:
                data = json.loads(path.read_text(encoding="utf-8"))
            except (OSError, json.JSONDecodeError):
                continue
            if data.get("track") == track_id:
                out.append(data)
        return out

    def _scan_results(self, base: str) -> int:
        """Baja los JSON de resultados del circuito actual de ese server (rueda y lastre por vuelta)."""
        port = base.split(":")[2].split("/")[0]
        rbase = results_base(base)
        self.results_dir.mkdir(parents=True, exist_ok=True)
        cached = {p.stem.split("_", 1)[1] for p in self.results_dir.glob(f"{port}_*.json")}
        rows: list[dict[str, str]] = []
        to_fetch: list[str] = []
        for page in range(RESULTS_MAX_PAGES):
            page_rows = parse_results_rows(self.fetch_text(f"{rbase}/results?page={page}"))
            if not page_rows:
                break
            rows.extend(page_rows)
            to_fetch, _event, ended = plan_results_event(rows, cached)
            if ended:
                break
        done = 0
        for name in to_fetch:
            if self.stop_event.is_set():
                break
            try:
                raw = self.fetch_text(f"{rbase}/results/download/{name}.json", timeout=20)
                compact = compact_result(json.loads(raw))
            except Exception as exc:
                log(f"results {port} {name}: {exc}")
                continue
            path = self.results_dir / f"{port}_{name}.json"
            tmp = path.with_suffix(".tmp")
            tmp.write_text(json.dumps(compact), encoding="utf-8")
            tmp.replace(path)
            done += 1
        return done

    def _fetch_session(self, base: str, session_id: str) -> dict[str, Any] | None:
        try:
            html = self.fetch_text(f"{base}/sessiondetails?sessionid={session_id}")
        except Exception:
            return None
        track = "Desconocido"
        session_type = ""
        date_text = ""
        tables = re.findall(r"<table[^>]*>(.*?)</table>", html, re.S | re.I)
        if tables:
            cells = [strip_html(c) for c in re.findall(r"<td[^>]*>(.*?)</td>", tables[0], re.S | re.I)]
            if len(cells) >= 4:
                track, session_type, _duration, date_text = cells[0], cells[1], cells[2], cells[3]
        cars: dict[str, str] = {}
        names: dict[str, str] = {}
        for row in re.finditer(
            r'playerInSessionId=(\d+)[^>]*>\s*<td>[^<]*</td>\s*<td>(.*?)</td>\s*<td>(.*?)</td>',
            html,
            re.S | re.I,
        ):
            pid, name, car = row.group(1), strip_html(row.group(2)), strip_html(row.group(3))
            names[pid] = name
            cars[pid] = car
        player_ids = PLAYER_SESS_RE.findall(html)
        drivers = []
        for pid in player_ids:
            time.sleep(0.05)
            try:
                ph = self.fetch_text(f"{base}/sessiondetails?playerInSessionId={pid}")
            except Exception:
                continue
            name_m = re.search(r"Laps of driver ([^<]+)", ph, re.I)
            name = (name_m.group(1).strip() if name_m else names.get(pid) or pid)
            body = re.search(r"Laps of driver.*?<tbody>(.*?)</tbody>", ph, re.S | re.I)
            fragment = body.group(1) if body else ph
            laps = parse_driver_laps(fragment, cars.get(pid) or "-")
            drivers.append(
                {
                    "id": pid,
                    "name": name,
                    "car": cars.get(pid) or "-",
                    "laps": laps,
                }
            )
        return {
            "session_id": session_id,
            "track": track,
            "session_type": session_type,
            "date_text": date_text,
            "drivers": drivers,
        }

    def _rebuild_from_cache(
        self, files: list[Path] | None = None
    ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
        pilots: dict[str, dict[str, Any]] = {}
        tracks: dict[str, int] = {}
        if files is None:
            files = select_latest_cache(self.cache_dir)
        for path in files:
            try:
                data = json.loads(path.read_text(encoding="utf-8"))
            except (OSError, json.JSONDecodeError):
                continue
            track = data.get("track") or "Desconocido"
            tracks[track] = tracks.get(track, 0) + 1
            date_iso = data.get("date_text") or ""
            for drv in data.get("drivers") or []:
                name = (drv.get("name") or "").strip() or drv.get("id")
                pid = "ac:" + name.lower()
                dummy = {"first_name": name, "last_name": "", "nickname": "", "player_id": pid, "nation": ""}
                row = scanner._ensure_pilot(pilots, pid, dummy, track)
                laps = drv.get("laps") or []
                row["sessions"] += 1
                row["total_laps"] += len(laps)
                row["valid_laps"] += sum(1 for lap in laps if lap.get("valid"))
                if date_iso and (not row.get("last_session_at") or date_iso > (row.get("last_session_at") or "")):
                    row["last_session_at"] = date_iso
                    row["last_session_type"] = data.get("session_type")
                    row["car"] = drv.get("car") or row["car"]
                for lap in laps:
                    if not lap.get("valid"):
                        continue
                    time_ms = int(lap.get("time_ms") or 0)
                    splits = lap.get("splits") or [0, 0, 0]
                    if row["best_ms"] is None or time_ms < row["best_ms"]:
                        row["best_ms"] = time_ms
                        row["sectors"] = splits[:3] + [0] * max(0, 3 - len(splits))
                        row["car"] = lap.get("car") or drv.get("car") or row["car"]
                        row["best_session_at"] = date_iso
                        row["best_session_type"] = data.get("session_type")
                        row["best_track"] = track
                    for i, sec in enumerate(splits[:3]):
                        if sec and sec > 0:
                            row["best_sectors"][i] = min(row["best_sectors"][i], sec)
                combo2 = best_consecutive(laps, 2)
                if combo2 and (row.get("avg2_ms") is None or combo2["avg_ms"] < row["avg2_ms"]):
                    row["avg2_ms"] = combo2["avg_ms"]
                    row["avg2_laps"] = combo2["laps"]
                    row["avg2_track"] = track
                    row["avg2_car"] = combo2["car"] or drv.get("car")
                    row["avg2_session_at"] = date_iso
                row["history"].append(
                    {
                        "date_iso": date_iso,
                        "session_type": data.get("session_type"),
                        "track": track,
                        "time_ms": row["best_ms"] if row.get("best_session_at") == date_iso else None,
                        "avg2_ms": combo2["avg_ms"] if combo2 else None,
                        "car": drv.get("car"),
                        "valid_laps": sum(1 for lap in laps if lap.get("valid")),
                        "total_laps": len(laps),
                    }
                )
                secs = row["best_sectors"]
                row["theoretical_ms"] = int(sum(secs)) if all(s < 10**12 for s in secs) else None
        ranking = sorted(
            pilots.values(),
            key=lambda row: (
                row["best_ms"] is None,
                row["best_ms"] if row["best_ms"] is not None else 10**12,
                row["name"],
            ),
        )
        leader = next((row["best_ms"] for row in ranking if row["best_ms"]), None)
        for pos, row in enumerate(ranking, start=1):
            row["rank"] = pos
            row["best_display"] = format_time(row["best_ms"])
            row["gap_ms"] = None if not row["best_ms"] or not leader else row["best_ms"] - leader
            row["gap_display"] = "Lider" if row["gap_ms"] == 0 else format_gap(row["gap_ms"])
            row["sectors_display"] = [format_sector(s) for s in row["sectors"]]
            row["theoretical_display"] = format_time(row["theoretical_ms"])
            row["avg2_display"] = format_time(row.get("avg2_ms"))
            row["avg2_parts"] = [format_time(t) for t in (row.get("avg2_laps") or [])]
            for hist in row.get("history") or []:
                hist["time_display"] = format_time(hist.get("time_ms"))
                hist["avg2_display"] = format_time(hist.get("avg2_ms"))
        track_list = [
            {"id": key, "name": key, "sessions": value}
            for key, value in sorted(tracks.items(), key=lambda kv: (-kv[1], kv[0]))
        ]
        return ranking, track_list


scanner = Scanner()
ac_scanner = AcScanner()
STOP = threading.Event()


def _http_reply(conn: socket.socket, body: bytes, content_type: str, code: int = 200) -> None:
    reason = "OK" if code == 200 else "ERROR"
    header = (
        f"HTTP/1.0 {code} {reason}\r\n"
        f"Content-Type: {content_type}\r\n"
        f"Content-Length: {len(body)}\r\n"
        f"Connection: close\r\n"
        f"Cache-Control: no-store\r\n"
        f"\r\n"
    ).encode("ascii")
    conn.sendall(header + body)


def handle_client(conn: socket.socket, addr: tuple[str, int]) -> None:
    conn.settimeout(8)
    try:
        buf = b""
        while b"\r\n\r\n" not in buf and b"\n\n" not in buf:
            chunk = conn.recv(4096)
            if not chunk:
                break
            buf += chunk
            if len(buf) > 65536:
                break
        line = buf.split(b"\n", 1)[0].decode("latin1", "replace").strip()
        parts = line.split(" ")
        method = parts[0] if parts else "GET"
        path = parts[1].split("?", 1)[0] if len(parts) > 1 else "/"
        if path not in ("/api/state", "/favicon.ico") and not path.startswith("/api/state"):
            print(f"[http] {method} {path} from {addr[0]}", flush=True)
        if method == "POST" and path.startswith("/api/rescan"):
            scanner.request_rescan()
            ac_scanner.request_rescan()
            _http_reply(conn, b'{"ok": true}', "application/json")
            return
        if path == "/api/laps":
            query = parse_qs(parts[1].split("?", 1)[1] if len(parts) > 1 and "?" in parts[1] else "")
            laps = ac_scanner.lap_log((query.get("board") or [""])[0], (query.get("row") or [""])[0])
            _http_reply(conn, json.dumps({"sessions": laps}, ensure_ascii=True).encode("utf-8"), "application/json; charset=utf-8")
            return
        if path.startswith("/api/state"):
            evo = scanner.snapshot()
            ac = ac_scanner.snapshot()
            payload = json.dumps(
                {
                    "evo": evo,
                    "ac": ac,
                    "scanning": bool(evo.get("scanning") or ac.get("scanning")),
                },
                ensure_ascii=True,
            ).encode("utf-8")
            _http_reply(conn, payload, "application/json; charset=utf-8")
            return
        page = STATIC_DIR / "index.html"
        _http_reply(conn, page.read_bytes(), "text/html; charset=utf-8")
    except Exception as exc:
        print("[http] error:", exc, flush=True)
        try:
            _http_reply(conn, b"error", "text/plain", 500)
        except Exception:
            pass
    finally:
        try:
            conn.shutdown(socket.SHUT_WR)
        except Exception:
            pass
        try:
            conn.close()
        except Exception:
            pass


def bind_listen() -> tuple[socket.socket, int]:
    for port in (PORT, 8766, 18765):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            sock.bind((BIND_HOST, port))
            sock.listen(64)
            sock.settimeout(0.5)
            return sock, port
        except OSError:
            sock.close()
    raise OSError("No hay puerto libre")


def http_loop(sock: socket.socket) -> None:
    while not STOP.is_set():
        try:
            conn, addr = sock.accept()
        except socket.timeout:
            continue
        except OSError:
            if STOP.is_set():
                return
            continue
        threading.Thread(target=handle_client, args=(conn, addr), daemon=True).start()


def self_test(port: int) -> None:
    try:
        probe = socket.create_connection((LOCAL_HOST, port), timeout=3)
        probe.sendall(b"GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n")
        got = probe.recv(32)
        probe.close()
        log("Autotest: OK" if got.startswith(b"HTTP/1.0 200") else f"Autotest {got!r}")
    except Exception as exc:
        log(f"Autotest fallo: {exc}")


def main() -> None:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    STATIC_DIR.mkdir(parents=True, exist_ok=True)

    silent = "--silent" in sys.argv
    alive = running_port()
    if alive:
        url = public_url(alive)
        log(f"Ya estaba en marcha en {url}")
        if not silent:
            webbrowser.open(f"http://{LOCAL_HOST}:{alive}/")
        return

    try:
        sock, port = bind_listen()
    except OSError as exc:
        log(f"No se pudo abrir puerto: {exc}")
        sys.exit(1)

    write_runtime(port)
    threading.Thread(target=http_loop, args=(sock,), daemon=True).start()
    self_test(port)
    url = public_url(port)
    log(f"Clasificacion en marcha {url} pid={os.getpid()}")

    threading.Thread(target=scanner.loop, name="scanner", daemon=True).start()
    threading.Thread(target=ac_scanner.loop, name="ac-scanner", daemon=True).start()
    threading.Thread(target=ac_scanner.live_loop, name="ac-directo", daemon=True).start()
    if not silent:
        threading.Timer(0.6, lambda: webbrowser.open(f"http://{LOCAL_HOST}:{port}/")).start()

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        log("Cierre manual")
    finally:
        STOP.set()
        scanner.stop_event.set()
        scanner.trigger.set()
        ac_scanner.stop_event.set()
        ac_scanner.trigger.set()
        try:
            sock.close()
        except OSError:
            pass
        try:
            PID_PATH.unlink(missing_ok=True)
        except OSError:
            pass


if __name__ == "__main__":
    main()
