import asyncio
import hmac
import json
import os
import time
import uuid
from collections import defaultdict
from json import JSONEncoder
from urllib.parse import parse_qs

import socketio

from bridzik import Bridzik, BridzikException, Card
from db.db import init_db
from api import auth as auth_module, history
from api import bots as bots_module
from api import stats as stats_module
from api.auth import AuthError, RegistrationIncomplete
from rl.encoding import index_card


def _env_bool(name: str, default: bool) -> bool:
    val = os.environ.get(name)
    if val is None:
        return default
    return val.lower() in ("1", "true", "yes", "on")


# --- configuration (env-driven, dev-friendly defaults) --------------------
_cors = os.environ.get("CORS_ALLOWED_ORIGINS", "*")
CORS_ALLOWED_ORIGINS = "*" if _cors == "*" else [o.strip() for o in _cors.split(",")]
SIO_LOGGER = _env_bool("SOCKETIO_LOGGER", False)

LOBBY = "lobby"  # room every connection joins to receive the public game list

sio = socketio.AsyncServer(
    async_mode="asgi",
    cors_allowed_origins=CORS_ALLOWED_ORIGINS,
    logger=SIO_LOGGER,
    engineio_logger=SIO_LOGGER,
)


async def _read_body(receive) -> bytes:
    body = b""
    while True:
        message = await receive()
        body += message.get("body", b"")
        if not message.get("more_body"):
            break
    return body


async def _send_text(send, status: int, body: bytes, content_type: bytes = b"text/plain"):
    await send({"type": "http.response.start", "status": status,
                "headers": [(b"content-type", content_type)]})
    await send({"type": "http.response.body", "body": body})


def _admin_authorized(scope) -> bool:
    token = os.environ.get("ADMIN_TOKEN", "")
    if not token:
        return False
    headers = dict(scope.get("headers") or [])
    auth_header = headers.get(b"authorization", b"").decode("utf-8", "ignore")
    return hmac.compare_digest(auth_header, f"Bearer {token}")


def _client_ip(scope) -> str:
    # Behind nginx (docker-compose.prod.yaml) this is the real client IP --
    # the backend port is never published, so X-Forwarded-For can't be spoofed
    # by an external caller going around the proxy.
    headers = dict(scope.get("headers") or [])
    xff = headers.get(b"x-forwarded-for", b"").decode("utf-8", "ignore")
    if xff:
        return xff.split(",")[0].strip()
    client = scope.get("client")
    return client[0] if client else "unknown"


# Lockout na neuspesne pokusy o /api/admin/stats, per client IP -- rovnaky
# in-memory pattern ako login lockout v api/auth.py (jeden proces, ziadny Redis).
_ADMIN_ATTEMPT_LIMIT = 5
_ADMIN_ATTEMPT_WINDOW = 300  # sekund
_admin_failed_attempts: dict[str, list[float]] = defaultdict(list)


def _admin_locked_out(ip: str) -> bool:
    cutoff = time.monotonic() - _ADMIN_ATTEMPT_WINDOW
    attempts = [t for t in _admin_failed_attempts.get(ip, []) if t > cutoff]
    if attempts:
        _admin_failed_attempts[ip] = attempts
    else:
        _admin_failed_attempts.pop(ip, None)
    return len(attempts) >= _ADMIN_ATTEMPT_LIMIT


def _register_admin_failure(ip: str) -> None:
    _admin_failed_attempts[ip].append(time.monotonic())


async def _handle_track(scope, receive, send):
    try:
        data = json.loads((await _read_body(receive)) or b"{}")
    except json.JSONDecodeError:
        data = {}
    headers = dict(scope.get("headers") or [])
    user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300]
    # player_id sa sem nikdy nedava z klienta (nedovereny/spoofovatelny vstup na
    # neautentifikovanom endpointe) -- pripaja sa len server-side na evente
    # "login" (pozri handler @sio.on("login")).
    await stats_module.record_pageview(
        path=str(data.get("path", ""))[:200],
        referrer=str(data.get("referrer", ""))[:300],
        user_agent=user_agent,
        ip=_client_ip(scope),
    )
    await send({"type": "http.response.start", "status": 204, "headers": []})
    await send({"type": "http.response.body", "body": b""})


async def _handle_admin_stats(scope, send):
    ip = _client_ip(scope)
    if _admin_locked_out(ip):
        return await _send_text(send, 429, b"too many attempts")
    if not _admin_authorized(scope):
        _register_admin_failure(ip)
        return await _send_text(send, 403, b"forbidden")
    query = parse_qs((scope.get("query_string") or b"").decode("utf-8", "ignore"))
    logged_in_only = query.get("logged_in", ["0"])[0] == "1"
    data = await stats_module.get_daily_stats(logged_in_only=logged_in_only)
    await _send_text(send, 200, json.dumps(data).encode(), b"application/json")


async def _health_app(scope, receive, send):
    """Minimal ASGI handler for non-socket.io HTTP routes (liveness checks,
    pageview tracking beacon, admin stats)."""
    if scope["type"] == "lifespan":
        while True:
            message = await receive()
            if message["type"] == "lifespan.startup":
                await init_db()
                await _restore_unfinished_games()
                await send({"type": "lifespan.startup.complete"})
            elif message["type"] == "lifespan.shutdown":
                await send({"type": "lifespan.shutdown.complete"})
                return
    if scope["type"] == "http":
        path = scope.get("path", "")
        method = scope.get("method", "GET")
        if path in ("/health", "/healthz"):
            return await _send_text(send, 200, b"ok")
        if path == "/api/track" and method == "POST":
            return await _handle_track(scope, receive, send)
        if path == "/api/admin/stats" and method == "GET":
            return await _handle_admin_stats(scope, send)
        await _send_text(send, 404, b"not found")


# Run with: uvicorn api:app --host 0.0.0.0 --port 5000
app = socketio.ASGIApp(sio, other_asgi_app=_health_app)

# --- in-memory state ------------------------------------------------------
# Single-process only. For multi-worker deployments this moves to Redis
# (socketio.AsyncRedisManager) plus a shared game store.
games: dict[str, "Game"] = {}
# Maps a live connection (sid) to the seat it controls: {"gid": str, "order": int}.
# This is the source of truth for "who is acting" — never trust a client-supplied
# player number.
sessions: dict[str, dict] = {}
# Maps a live connection (sid) to its authenticated account: {"player_id": int,
# "username": str}. Set on login/confirm or on connect via the auth token.
# Required before a connection may create or join a game.
accounts: dict[str, dict] = {}


class Game:
    def __init__(self, gid: str, name: str):
        self.gid = gid
        self.name = name
        self.players: list["Player"] = []
        self.started = False
        self.bridzik_core: Bridzik | None = None
        # Serializuje tahovu slucku botov -- dva sucasne _run_bot_turns tasky
        # by inak mohli tahat za to iste sedadlo.
        self.bot_lock = asyncio.Lock()

    def start(self):
        self.bridzik_core = Bridzik()
        self.started = True

    def player_by_token(self, token: str) -> "Player | None":
        return next((p for p in self.players if p.token == token), None)

    def player_by_sid(self, sid: str) -> "Player | None":
        return next((p for p in self.players if p.sid == sid), None)

    def player_by_order(self, order: int) -> "Player | None":
        return next((p for p in self.players if p.order == order), None)


class Player:
    def __init__(self, sid: str, name: str, order: int, player_id: int):
        self.sid = sid
        self.name = name  # display name == account username
        self.order = order
        self.player_id = player_id  # persistent account id (db.models.Player.id)
        self.token = str(uuid.uuid4())  # secret token used for secure reconnect
        self.connected = True
        # Bot = sedadlo bez socketu; `brain` je rozhodovaci objekt s rozhranim
        # guess(rnd, seat) / play(rnd, seat) z rl/players.py.
        self.is_bot = False
        self.brain = None


class CardStatusEncoder(JSONEncoder):
    """Serializes the engine status, which may contain Card objects."""

    def default(self, obj):
        if isinstance(obj, Card):
            return {"color": obj.color.name, "value": obj.value.name}
        return JSONEncoder.default(self, obj)


def public_games() -> list:
    """Public lobby view — no sids, no reconnect tokens.

    A game that finished naturally (all 4 series played out) stays in the
    `games` dict for reconnect purposes (e.g. a reload while still on the
    GameOver screen), but it has nothing left to offer the lobby — drop it
    here rather than have it linger forever as a "started"/resumable entry.
    """
    return [
        {
            "gid": g.gid,
            "name": g.name,
            "started": g.started,
            "players": [
                {
                    "order": p.order,
                    "name": p.name,
                    "connected": p.connected,
                    "player_id": p.player_id,
                    "is_bot": p.is_bot,
                }
                for p in g.players
            ],
        }
        for g in games.values()
        if g.bridzik_core is None or not g.bridzik_core.is_completed()
    ]


# --- emit helpers ---------------------------------------------------------

async def broadcast_lobby():
    await sio.emit("get_games", {"games": public_games()}, room=LOBBY)


async def send_game_status(gid: str):
    game = games[gid]
    core = game.bridzik_core
    last_round = core.series[-1].get_last_round()
    status = json.loads(json.dumps(core.get_status(), cls=CardStatusEncoder))
    # Use DB-backed standings so the score is correct even after a server restart
    # (the engine only knows rounds completed since restart).
    status["standings"], status["standings_guesses"] = await history.get_standings(gid)
    await sio.emit(
        "game_status",
        {
            "gid": gid,
            "completed": core.is_completed(),
            # Self-contained roster so the game view doesn't depend on the lobby snapshot.
            "players": [
                {"order": p.order, "name": p.name, "connected": p.connected,
                 "is_bot": p.is_bot}
                for p in sorted(game.players, key=lambda p: p.order)
            ],
            "series_number": core.series[-1].series_number,
            "round_number": last_round.round_number,
            "cards_in_round": 8 - last_round.round_number,  # tricks == max bid
            "status": status,
        },
        room=gid,
    )


async def send_player_cards(gid: str, order: int, to: str):
    core = games[gid].bridzik_core
    await sio.emit(
        "player_cards",
        {"cards": json.loads(json.dumps(core.get_player_cards(int(order)), cls=Card.JSONEncoder))},
        to=to,
    )


async def send_error(sid: str, message: str):
    await sio.emit("error", {"error": message}, to=sid)


# Ako dlho prezije nezacata hra, ked su vsetci hraci naraz offline. Na mobile
# sa socket bezne strati uz pri zamknuti obrazovky, takze okamzite zmazanie
# hry rusilo lobby, v ktorom hraci len cakali so zhasnutym telefonom.
LOBBY_ABANDON_GRACE_SECONDS = 10 * 60


async def _cleanup_abandoned_lobby(gid: str):
    """Po grace periode zmaz nezacatu hru, ak sa medzitym nikto nevratil.
    Podmienka sa overuje az po uplynuti casu, takze pri navrate hraca je
    task neskodny no-op (netreba nic rusit)."""
    await asyncio.sleep(LOBBY_ABANDON_GRACE_SECONDS)
    game = games.get(gid)
    if game is not None and not game.started and not _any_human_connected(game):
        del games[gid]
        await broadcast_lobby()


def _any_human_connected(game: "Game") -> bool:
    """Boti su 'pripojeni' stale, pre opustenost lobby sa pocitaju len ludia."""
    return any(p.connected for p in game.players if not p.is_bot)


async def _mark_player_offline(game: "Game", player: "Player"):
    """Mark player disconnected. An unstarted game with nobody left gets a
    delayed cleanup (mobile sockets drop on screen lock, so an immediate
    delete would kill lobbies where everyone is just waiting); a started game
    is kept in memory so it stays in the lobby and can be resumed (it's torn
    down only by end_game)."""
    player.connected = False
    if not _any_human_connected(game) and not game.started:
        asyncio.create_task(_cleanup_abandoned_lobby(game.gid))
    await sio.emit(
        "player_connection",
        {"order": player.order, "connected": False},
        room=game.gid,
    )


def _active_game(sid: str) -> "tuple[Game, dict] | None":
    """Resolve the started game and seat for a connection, or None."""
    sess = sessions.get(sid)
    if sess is None:
        return None
    game = games.get(sess["gid"])
    if game is None or not game.started:
        return None
    return game, sess


async def _restore_unfinished_games():
    """Po starte servera obnov rozohrate hry z DB do pamate (hraci offline).

    Karty su rozdane nanovo (pozicia z `series`/`round`); hraci sa vratia cez
    `rejoin_game` podla svojej trvalej identity (per-hra tokeny restart neprezili).
    """
    for info in await history.get_unfinished_games():
        if info["gid"] in games:
            continue
        _load_game_into_memory(info)


def _load_game_into_memory(info: dict) -> "Game":
    """Postav in-memory Game z restore-info (gid/name/seats/core), hraci offline,
    a vlozi ju do `games`. Pouzite pri starte aj pri obnove hry z historie."""
    game = Game(info["gid"], info["name"])
    game.bridzik_core = info["core"]
    game.started = True
    for seat, (pid, uname) in enumerate(info["seats"]):
        player = Player(None, uname, seat, pid)
        player.connected = False
        # Boti sa rozpoznaju konvenciou mena a dostanu novy mozog -- ozivi ich
        # prvy _kick_bots (napr. ked sa clovek vrati cez rejoin_game).
        if bots_module.is_bot_username(uname):
            player.is_bot = True
            player.brain = bots_module.make_brain(uname)
            player.connected = True
        game.players.append(player)
    games[info["gid"]] = game
    return game


# --- bot turns --------------------------------------------------------------

# Pauza medzi tahmi bota, nech ludia stihaju sledovat hru (0 = okamzite).
BOT_MOVE_DELAY_SECONDS = float(os.environ.get("BOT_MOVE_DELAY_SECONDS", "0.8"))
# Kopka na stole sa po dohrati este chvilu zmieta smerom k vitazovi (SETTLE_MS +
# COLLECT_MS vo frontend/src/pages/GameTable.tsx, spolu 1650ms) -- kym tato
# animacia nedobehne vsetkym hracom, prvy bot na tahu nesmie zahodit kartu do
# novej kopky, inak by mu karta "vyletela" uprostred zmetania predoslej.
TRICK_SWEEP_SECONDS = 1.7


def _kick_bots(gid: str) -> None:
    """Ak je v hre bot, spusti (na pozadi) dohratie vsetkych botich tahov.
    Vola sa po kazdej akcii, ktora mohla posunut tah na botie sedadlo."""
    game = games.get(gid)
    if game is not None and game.started and any(p.is_bot for p in game.players):
        asyncio.create_task(_run_bot_turns(gid))


async def _run_bot_turns(gid: str):
    """Kym je na tahu botie sedadlo, vykonavaj jeho tahy tym istym internym
    postupom ako handlery add_guess/play_card (engine validuje, historia sa
    zapisuje, room dostava game_status). MC vypocet bezi v executori, aby
    nedrzal event loop ostatnych hier."""
    game = games.get(gid)
    if game is None or not game.started or game.bridzik_core is None:
        return
    async with game.bot_lock:
        core = game.bridzik_core
        loop = asyncio.get_running_loop()
        while not core.is_completed():
            rnd = core.series[-1].get_last_round()
            seat = rnd.get_active_player()
            bot = game.player_by_order(seat)
            if bot is None or not bot.is_bot:
                return  # na tahu je clovek
            delay = BOT_MOVE_DELAY_SECONDS
            if rnd.is_guessing_completed() and not rnd.get_last_stash().get_cards() \
                    and core.get_previous_stash() is not None:
                # Bot vedie novu kopku a este bezi zmetanie tej predoslej.
                delay = max(delay, TRICK_SWEEP_SECONDS)
            if delay > 0:
                await asyncio.sleep(delay)
            if games.get(gid) is not game:
                return  # hru medzitym niekto ukoncil (end_game)
            played_card = False
            try:
                if not rnd.is_guessing_completed():
                    guess = await loop.run_in_executor(None, bot.brain.guess, rnd, seat)
                    core.add_player_guess(seat, guess)
                else:
                    action = await loop.run_in_executor(None, bot.brain.play, rnd, seat)
                    core.play_card(seat, index_card(action))
                    await history.record_completed_rounds(gid, core)
                    played_card = True
            except BridzikException as exc:
                # Nemalo by nastat (bot hra len legalne tahy) -- nezacykli sa,
                # slucku znovu spusti dalsia akcia cloveka.
                await send_error_room(gid, str(exc))
                return
            # game_status musi ist PRED player_cards -- klient podla neho (novy
            # round_number + previous_stash) pozna, ze prave zacalo nove kolo, a
            # dovtedy si drzi starych karty na obrazovke (pozri "displayedHand" vo
            # frontend/src/pages/GameTable.tsx), kym nedobehne animacia zmetenia
            # poslednej kopky. Opacne poradie by novu ruku odhalilo predcasne.
            await send_game_status(gid)
            if played_card:
                for player in game.players:
                    if player.sid:
                        await send_player_cards(gid, player.order, player.sid)


async def send_error_room(gid: str, message: str):
    await sio.emit("error", {"error": message}, room=gid)


# --- connection lifecycle -------------------------------------------------

@sio.event
async def connect(sid, environ, auth=None):
    await sio.enter_room(sid, LOBBY)
    # Auto-login via the session token the client stored after a previous login.
    token = auth.get("token") if isinstance(auth, dict) else None
    identity = await auth_module.player_by_token(token)
    if identity is not None:
        accounts[sid] = identity
        await sio.emit("login", {"player": identity}, to=sid)
    await sio.emit("get_games", {"games": public_games()}, to=sid)


@sio.event
async def disconnect(sid):
    accounts.pop(sid, None)
    sess = sessions.pop(sid, None)
    game = games.get(sess["gid"]) if sess else None
    if game is not None:
        player = game.player_by_sid(sid)
        if player is not None:
            await _mark_player_offline(game, player)
    await broadcast_lobby()


# --- authentication (TOTP) ------------------------------------------------

@sio.on("register_account")
async def register_account(sid, username):
    try:
        data = await auth_module.register_account(username)
    except AuthError as exc:
        return await send_error(sid, str(exc))
    # otpauth_uri -> the client renders it as a QR code to scan into the app.
    await sio.emit("register_account", data, to=sid)


async def _record_login_event(sid: str, player_id: int) -> None:
    scope = (sio.get_environ(sid) or {}).get("asgi.scope", {})
    headers = dict(scope.get("headers") or [])
    await stats_module.record_pageview(
        path="login",
        referrer="",
        user_agent=headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300],
        ip=_client_ip(scope),
        player_id=player_id,
    )


@sio.on("confirm_account")
async def confirm_account(sid, username, code):
    try:
        identity = await auth_module.confirm_account(username, code)
    except AuthError as exc:
        return await send_error(sid, str(exc))
    accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
    await _record_login_event(sid, identity["player_id"])
    await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)


@sio.on("login")
async def login(sid, username, code):
    try:
        identity = await auth_module.login(username, code)
    except RegistrationIncomplete as exc:
        # Nedokoncena registracia -> vydame novy QR kod, klient sa prepne
        # na registracny tab a pouzivatel ju moze dokoncit.
        try:
            data = await auth_module.register_account(username)
        except AuthError as exc2:
            return await send_error(sid, str(exc2))
        await sio.emit("register_account", data, to=sid)
        return await send_error(sid, str(exc))
    except AuthError as exc:
        return await send_error(sid, str(exc))
    accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
    await _record_login_event(sid, identity["player_id"])
    await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)


# --- lobby ----------------------------------------------------------------

@sio.on("create_game")
async def create_game(sid, name):
    if sid not in accounts:
        return await send_error(sid, "Musíte byť prihlásený.")
    gid = str(uuid.uuid4())
    games[gid] = Game(gid, name)
    await sio.emit("create_game", {"gid": gid}, to=sid)
    await broadcast_lobby()


@sio.on("get_games")
async def get_games(sid, *args):
    await sio.emit("get_games", {"games": public_games()}, to=sid)


@sio.on("register_player")
async def register_player(sid, gid):
    account = accounts.get(sid)
    if account is None:
        return await send_error(sid, "Musíte byť prihlásený.")
    if sid in sessions:
        return await send_error(sid, "Uz ste v hre.")
    game = games.get(gid)
    if game is None:
        return await send_error(sid, "Hra neexistuje.")
    if game.started:
        return await send_error(sid, "Hra uz zacala.")
    if len(game.players) >= 4:
        return await send_error(sid, "Prekroceny pocet hracov.")
    if any(p.player_id == account["player_id"] for p in game.players):
        return await send_error(sid, "Uz ste v tejto hre.")

    # Lowest free seat (robust if someone left the lobby before start).
    used = {p.order for p in game.players}
    order = next(o for o in range(4) if o not in used)
    player = Player(sid, account["username"], order, account["player_id"])
    game.players.append(player)
    sessions[sid] = {"gid": gid, "order": order}
    await sio.enter_room(sid, gid)
    # The token is private to this player and required for a secure reconnect.
    await sio.emit(
        "register_player",
        {"player": {"order": order, "name": player.name}, "token": player.token},
        to=sid,
    )
    await broadcast_lobby()


@sio.on("add_bot")
async def add_bot(sid, gid, kind=None):
    """Hostitel prida bota na najnizsie volne sedadlo nezacatej hry."""
    sess = sessions.get(sid)
    if sess is None or sess["gid"] != gid:
        return await send_error(sid, "Nie ste v tejto hre.")
    if sess["order"] != 0:
        return await send_error(sid, "Iba hostitel moze pridavat botov.")
    game = games.get(gid)
    if game is None:
        return await send_error(sid, "Hra neexistuje.")
    if game.started:
        return await send_error(sid, "Hra uz zacala.")
    if len(game.players) >= 4:
        return await send_error(sid, "Prekroceny pocet hracov.")

    if kind == "neural" and not bots_module.neural_available():
        return await send_error(sid, "AI bot nie je na tomto serveri dostupny.")
    if kind not in bots_module.BOT_KINDS:
        kind = bots_module.DEFAULT_KIND
    account = await bots_module.ensure_bot_account(
        kind, {p.player_id for p in game.players}
    )
    used = {p.order for p in game.players}
    order = next(o for o in range(4) if o not in used)
    player = Player(None, account["username"], order, account["player_id"])
    player.is_bot = True
    player.brain = bots_module.make_brain(account["username"])
    game.players.append(player)
    await broadcast_lobby()


@sio.on("remove_bot")
async def remove_bot(sid, gid, order):
    """Hostitel odoberie bota z nezacatej hry (sedadlo sa uvolni)."""
    sess = sessions.get(sid)
    if sess is None or sess["gid"] != gid:
        return await send_error(sid, "Nie ste v tejto hre.")
    if sess["order"] != 0:
        return await send_error(sid, "Iba hostitel moze odoberat botov.")
    game = games.get(gid)
    if game is None:
        return await send_error(sid, "Hra neexistuje.")
    if game.started:
        return await send_error(sid, "Hra uz zacala.")
    try:
        seat = int(order)
    except (TypeError, ValueError):
        return await send_error(sid, "Neplatne sedadlo.")
    player = game.player_by_order(seat)
    if player is None or not player.is_bot:
        return await send_error(sid, "Na tomto sedadle nie je bot.")
    game.players.remove(player)
    await broadcast_lobby()


@sio.on("leave_game")
async def leave_game(sid):
    """Explicit exit (e.g. a 'Back to lobby' button). The socket stays
    connected and remains in the lobby room."""
    sess = sessions.pop(sid, None)
    if sess is None:
        return  # not in a game; nothing to do
    game = games.get(sess["gid"])
    if game is not None:
        await sio.leave_room(sid, game.gid)
        player = game.player_by_sid(sid)
        if game.started:
            # Game in progress: keep the seat (reconnect via token still works),
            # just mark the player offline.
            if player is not None:
                await _mark_player_offline(game, player)
        else:
            # Not started yet: free the seat entirely.
            if player is not None:
                game.players.remove(player)
            if not game.players:
                del games[game.gid]
    await broadcast_lobby()


@sio.on("start_game")
async def start_game(sid, gid):
    sess = sessions.get(sid)
    if sess is None or sess["gid"] != gid:
        return await send_error(sid, "Nie ste v tejto hre.")
    if sess["order"] != 0:
        return await send_error(sid, "Iba hostitel moze spustit hru.")
    game = games.get(gid)
    if game is None:
        return await send_error(sid, "Hra neexistuje.")
    if game.started:
        return await send_error(sid, "Hra uz zacala.")
    if len(game.players) != 4:
        return await send_error(sid, "Nedostatocny pocet hracov.")

    game.start()
    # Persist the game with its 4 seats (ordered 0..3) so history can attribute guesses.
    seated = sorted(game.players, key=lambda p: p.order)
    await history.record_game_started(gid, game.name, [p.player_id for p in seated])
    await broadcast_lobby()
    await send_game_status(gid)
    for player in game.players:
        # sid None = bot alebo offline sedadlo -- emit s to=None by karty
        # broadcastol VSETKYM klientom, preto sa preskakuje.
        if player.sid:
            await send_player_cards(gid, player.order, player.sid)
    _kick_bots(gid)


@sio.on("end_game")
async def end_game(sid, gid):
    """Any seated player can permanently end a game that won't be finished --
    not just the host, so the other players aren't stuck forever if the host
    abandons the game. Marks it ended in the DB (so it won't be restored) and
    sends everyone back to the lobby."""
    sess = sessions.get(sid)
    if sess is None or sess["gid"] != gid:
        return await send_error(sid, "Nie ste v tejto hre.")
    game = games.get(gid)
    if game is None:
        return await send_error(sid, "Hra neexistuje.")

    await history.mark_game_ended(gid)
    # Notify the room first (while players are still in it), then tear it down.
    await sio.emit("game_ended", {"gid": gid}, room=gid)
    for player in game.players:
        if player.sid:
            sessions.pop(player.sid, None)
            await sio.leave_room(player.sid, gid)
    del games[gid]
    await broadcast_lobby()


@sio.on("reconnect_to_game")
async def reconnect_to_game(sid, gid, token):
    # Best-effort background reconnect: fail silently (no error toast). After a
    # server restart the old token is gone -> the user rejoins from the lobby.
    game = games.get(gid)
    if game is None:
        return
    player = game.player_by_token(token)
    if player is None:
        return

    old_sid = player.sid
    if old_sid != sid:
        sessions.pop(old_sid, None)
    player.sid = sid
    player.connected = True
    sessions[sid] = {"gid": gid, "order": player.order}
    await sio.enter_room(sid, gid)
    await sio.emit(
        "register_player",
        {"player": {"order": player.order, "name": player.name}, "token": player.token},
        to=sid,
    )
    if game.started:
        await send_game_status(gid)
        await send_player_cards(gid, player.order, sid)
    await sio.emit("player_connection", {"order": player.order, "connected": True}, room=gid)
    await broadcast_lobby()
    _kick_bots(gid)


@sio.on("rejoin_game")
async def rejoin_game(sid, gid):
    """Re-seat into a game via the logged-in account (used after a server restart,
    when per-game reconnect tokens are gone). Identity comes from the session."""
    account = accounts.get(sid)
    if account is None:
        return await send_error(sid, "Musíte byť prihlásený.")
    if sid in sessions:
        return await send_error(sid, "Uz ste v hre.")
    game = games.get(gid)
    if game is None:
        return await send_error(sid, "Hra neexistuje.")
    player = next(
        (p for p in game.players if p.player_id == account["player_id"]), None
    )
    if player is None:
        return await send_error(sid, "Nie ste hracom tejto hry.")

    old_sid = player.sid
    if old_sid and old_sid != sid:
        sessions.pop(old_sid, None)
    player.sid = sid
    player.connected = True
    sessions[sid] = {"gid": gid, "order": player.order}
    await sio.enter_room(sid, gid)
    await sio.emit(
        "register_player",
        {"player": {"order": player.order, "name": player.name}, "token": player.token},
        to=sid,
    )
    if game.started:
        await send_game_status(gid)
        await send_player_cards(gid, player.order, sid)
    await sio.emit("player_connection", {"order": player.order, "connected": True}, room=gid)
    await broadcast_lobby()
    _kick_bots(gid)


@sio.on("restore_game")
async def restore_game(sid, gid):
    """Obnov predcasne ukoncenu hru z historie spat do lobby. Smie ju vyvolat
    iba hrac danej hry; v lobby sa potom objavi ako rozohrata a clenovia sa
    pripoja cez `rejoin_game`."""
    account = accounts.get(sid)
    if account is None:
        return await send_error(sid, "Musíte byť prihlásený.")
    if gid in games:
        # Uz je v pamati (lobby) -- staci obnovit zoznam hier u klienta.
        await sio.emit("game_restored", {"gid": gid}, to=sid)
        return await sio.emit("get_games", {"games": public_games()}, to=sid)

    info = await history.reopen_game(gid, account["player_id"])
    if info is None:
        return await send_error(sid, "Hru sa nepodarilo obnovit.")
    _load_game_into_memory(info)
    await sio.emit("game_restored", {"gid": gid}, to=sid)
    await broadcast_lobby()


# --- in-game actions (seat derived from the connection, never the client) -

@sio.on("game_status")
async def game_status(sid, *args):
    resolved = _active_game(sid)
    if resolved is None:
        return await send_error(sid, "Nie ste v rozohratej hre.")
    game, _ = resolved
    await send_game_status(game.gid)


@sio.on("player_cards")
async def player_cards(sid, *args):
    resolved = _active_game(sid)
    if resolved is None:
        return await send_error(sid, "Nie ste v rozohratej hre.")
    game, sess = resolved
    await send_player_cards(game.gid, sess["order"], sid)


@sio.on("add_guess")
async def add_guess(sid, guess):
    resolved = _active_game(sid)
    if resolved is None:
        return await send_error(sid, "Nie ste v rozohratej hre.")
    game, sess = resolved
    try:
        value = int(guess)
    except (TypeError, ValueError):
        return await send_error(sid, "Neplatny tip.")
    try:
        game.bridzik_core.add_player_guess(sess["order"], value)
    except BridzikException as exc:
        return await send_error(sid, str(exc))
    await send_game_status(game.gid)
    _kick_bots(game.gid)


@sio.on("play_card")
async def play_card(sid, card_key):
    resolved = _active_game(sid)
    if resolved is None:
        return await send_error(sid, "Nie ste v rozohratej hre.")
    game, sess = resolved
    core = game.bridzik_core
    hand = core.get_player_cards(sess["order"])
    try:
        key = int(card_key)
    except (TypeError, ValueError):
        return await send_error(sid, "Neplatna karta.")
    if key not in hand:
        return await send_error(sid, "Neplatna karta.")
    try:
        core.play_card(sess["order"], hand[key])
    except BridzikException as exc:
        return await send_error(sid, str(exc))
    # Persist completed rounds first so the DB-backed standings in game_status are
    # up to date (idempotent; also marks the game ended).
    await history.record_completed_rounds(game.gid, core)
    await send_game_status(game.gid)
    for player in game.players:
        if player.sid:  # None (bot/offline) by broadcastoval karty vsetkym
            await send_player_cards(game.gid, player.order, player.sid)
    # A naturally-finished game (all 4 series played out) has nothing left to
    # offer the lobby -- refresh the list so it drops out immediately instead
    # of lingering as "started"/resumable until someone happens to leave it.
    if core.is_completed():
        await broadcast_lobby()
    _kick_bots(game.gid)


# --- history (read-only) --------------------------------------------------

@sio.on("get_player_history")
async def get_player_history(sid, *args):
    account = accounts.get(sid)
    if account is None:
        return await send_error(sid, "Musíte byť prihlásený.")
    rows = await history.get_player_history(account["player_id"])
    await sio.emit("get_player_history", {"games": rows}, to=sid)


@sio.on("get_game_detail")
async def get_game_detail(sid, gid):
    account = accounts.get(sid)
    if account is None:
        return await send_error(sid, "Musíte byť prihlásený.")
    detail = await history.get_game_detail(gid)
    if detail is None:
        return await send_error(sid, "Hra neexistuje.")
    await sio.emit("get_game_detail", detail, to=sid)
