#!/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 html as htmlmod
import json
import os
import re
import socket
import sys
import threading
import time
import unicodedata
import webbrowser
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
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)
        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["avg3_display"] = format_time(row.get("avg3_ms"))
            row["avg3_parts"] = [format_time(t) for t in (row.get("avg3_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"))
                hist["avg3_display"] = format_time(hist.get("avg3_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)
            combo3 = best_consecutive(seq, 3)
            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")
            if combo3:
                session_best.setdefault(pid, {})
                session_best[pid]["avg3_ms"] = combo3["avg_ms"]
                session_best[pid]["avg3_laps"] = combo3["laps"]
                if row.get("avg3_ms") is None or combo3["avg_ms"] < row["avg3_ms"]:
                    row["avg3_ms"] = combo3["avg_ms"]
                    row["avg3_laps"] = combo3["laps"]
                    row["avg3_track"] = track
                    row["avg3_car"] = combo3["car"] or row.get("car")
                    row["avg3_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"),
                    "avg3_ms": None if not best else best.get("avg3_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,
                "avg3_ms": None,
                "avg3_laps": [],
                "avg3_track": track,
                "avg3_car": "-",
                "avg3_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",
}


def pretty_track(track: str) -> str:
    if not track:
        return "Desconocido"
    if track in TRACK_PRETTY:
        return TRACK_PRETTY[track]
    name = track.replace("-", " ").replace("_", " ")
    name = re.sub(r"\s+", " ", name).strip()
    return name.title()


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
    head_left, head_right = first_left.split()[0], first_right.split()[0]
    return len(head_left) >= 4 and len(head_right) >= 4 and head_left[:4] == head_right[:4]


def resolve_pilot_id(name: str, known: list[tuple[str, str]]) -> str:
    for pid, other in known:
        if names_match(name, other):
            return pid
    return "ac:" + fold_name(name)


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]
        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])
    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]) -> list[Path]:
    files: list[Path] = []
    for path in cache_dir.glob("*.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,
        "avg3_ms": None,
        "avg3_laps": [],
        "avg3_track": track,
        "avg3_car": "-",
        "avg3_session_at": None,
        "history": [],
    }


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["avg3_display"] = format_time(row.get("avg3_ms"))
        row["avg3_parts"] = [format_time(t) for t in (row.get("avg3_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"))
            hist["avg3_display"] = format_time(hist.get("avg3_ms"))
    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] = {}
    known: list[tuple[str, str]] = []
    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
        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)
            if not any(item[0] == pid for item in known):
                known.append((pid, name))
            row = bucket.get(pid)
            if row is None:
                row = empty_track_pilot(pid, name, track)
                bucket[pid] = row
            elif len(name) > len(row.get("name") or ""):
                row["name"] = name
            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"]
            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["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)
            combo3 = best_consecutive(laps, 3)
            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
            if combo3 and (row.get("avg3_ms") is None or combo3["avg_ms"] < row["avg3_ms"]):
                row["avg3_ms"] = combo3["avg_ms"]
                row["avg3_laps"] = combo3["laps"]
                row["avg3_track"] = track
                row["avg3_car"] = combo3["car"] or drv.get("car")
                row["avg3_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,
                    "avg3_ms": combo3["avg_ms"] if combo3 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
    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 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 "avg3_ms" if mode == "avg3" else "best_ms"
    parts_key = "avg2_parts" if mode == "avg2" else "avg3_parts" if mode == "avg3" 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 "avg3_display" if mode == "avg3" else "best_display")
            item["cars"][tid] = row.get("avg2_car" if mode == "avg2" else "avg3_car" if mode == "avg3" 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


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.state_path = DATA_DIR / "ac_leaderboard.json"
        self.occupancy_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 _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]] = []
        for base in STRACKER_BASES:
            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,
                    "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]:
        empty = {"connected_drivers": 0, "drivers_now": []}
        try:
            page = self.fetch_text(base + "/livemap", timeout=6)
            match = LIVEMAP_STREAM_RE.search(page)
            if not match:
                return empty
            raw = self.fetch_text(base + "/" + match.group(1), timeout=6)
            return occupancy_from_stream(raw)
        except Exception:
            return empty

    def _publish(self, files: list[Path] | None = None, live_tracks: list[dict[str, str]] | None = None, **extra: Any) -> None:
        live_tracks = self.apply_occupancy(
            live_tracks if live_tracks is not None else self.detect_live_tracks()
        )
        only = {item["id"] for item in live_tracks if item.get("id")}
        if files is None:
            files = files_for_tracks(self.cache_dir, only) if only else []
        by_track = build_track_rankings(files, only or None)
        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)
        for base in online:
            port = base.split(":")[2].split("/")[0]
            self.occupancy_by_port[port] = self.fetch_occupancy(base)
        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)}",
                )

        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,
            "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 _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)
                combo3 = best_consecutive(laps, 3)
                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
                if combo3 and (row.get("avg3_ms") is None or combo3["avg_ms"] < row["avg3_ms"]):
                    row["avg3_ms"] = combo3["avg_ms"]
                    row["avg3_laps"] = combo3["laps"]
                    row["avg3_track"] = track
                    row["avg3_car"] = combo3["car"] or drv.get("car")
                    row["avg3_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,
                        "avg3_ms": combo3["avg_ms"] if combo3 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 [])]
            row["avg3_display"] = format_time(row.get("avg3_ms"))
            row["avg3_parts"] = [format_time(t) for t in (row.get("avg3_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"))
                hist["avg3_display"] = format_time(hist.get("avg3_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.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()
    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()
