"""
Interrogation A2S_INFO d'un serveur à moteur Source annoncé par un pair.

Sert à savoir si une partie annoncée est **réellement vivante**, et à afficher la
carte et le nombre de joueurs dans le lobby. Sans ça, un libellé de service reste
affiché indéfiniment alors que le serveur est éteint depuis des heures — c'est
précisément le défaut du partage de nom de réseau à la Radmin / Hamachi : on
apprend que la partie est finie en essayant de la rejoindre.

Protocole Valve, implémenté en UDP pur, **aucune dépendance** — cohérent avec le
refus d'ajouter des binaires externes ailleurs dans le projet (`wg.exe`, etc.).

**Cette requête passe par le tunnel**, en visant l'IP virtuelle du pair. C'est ce
qui la rend possible ici alors que la découverte LAN par diffusion est condamnée
(point 36) : un serveur Source **ne répond pas** aux requêtes diffusées, mais il
répond parfaitement à une requête **unicast** — ce qui a été vérifié en
conditions réelles puisque `connect 192.168.192.2:27015` fonctionne.
"""

from __future__ import annotations

import logging
import socket
import struct
from dataclasses import dataclass
from typing import Optional

logger = logging.getLogger("meshamoto-client.a2s")

# En-tête « pas de découpage » suivi de 'T' (A2S_INFO) et de la chaîne rituelle.
_REQUETE_INFO = b"\xff\xff\xff\xffTSource Engine Query\x00"

_ENTETE_SIMPLE = b"\xff\xff\xff\xff"
_REPONSE_INFO = 0x49  # 'I'
_DEMANDE_DE_DEFI = 0x41  # 'A' — le serveur exige un jeton avant de répondre

TIMEOUT_PAR_DEFAUT = 1.5


@dataclass(frozen=True)
class InfoServeur:
    """Ce qu'on affiche dans le lobby. Tous les champs sont facultatifs côté
    protocole, donc tous ont un défaut sûr."""

    nom: str = ""
    carte: str = ""
    joueurs: int = 0
    joueurs_max: int = 0

    def resume(self) -> str:
        """Formule courte pour la colonne Service : carte et remplissage, ce que
        le joueur regarde réellement avant de rejoindre."""
        morceaux = []
        if self.carte:
            morceaux.append(self.carte)
        if self.joueurs_max:
            morceaux.append(f"{self.joueurs}/{self.joueurs_max}")
        return " · ".join(morceaux)


class _Lecteur:
    """
    Curseur sur la réponse binaire.

    Une réponse tronquée ou d'un autre protocole ne doit jamais faire planter le
    lobby : chaque lecture au-delà de la fin lève `ValueError`, capturée une
    seule fois par l'appelant.
    """

    def __init__(self, donnees: bytes) -> None:
        self._donnees = donnees
        self._position = 0

    def octet(self) -> int:
        if self._position >= len(self._donnees):
            raise ValueError("réponse tronquée")
        valeur = self._donnees[self._position]
        self._position += 1
        return valeur

    def sauter(self, nombre: int) -> None:
        """
        Avance sans interpréter, en vérifiant qu'il reste bien de quoi avancer.

        Utilisé pour l'identifiant d'application Steam, qu'on n'affiche pas. Le
        champ ne fait que **2 octets** alors que les appid modernes dépassent
        largement 65535 (No More Room in Hell : 224260) : les serveurs y mettent
        la valeur tronquée, et l'appid complet n'arrive que dans une extension
        optionnelle en fin de paquet. Le lire n'aurait donc aucun sens ici.
        """
        if self._position + nombre > len(self._donnees):
            raise ValueError("réponse tronquée")
        self._position += nombre

    def chaine(self) -> str:
        fin = self._donnees.find(b"\x00", self._position)
        if fin == -1:
            raise ValueError("chaîne non terminée")
        # Les serveurs renvoient de l'UTF-8 en pratique, mais rien ne le garantit :
        # un nom de serveur exotique ne doit pas faire échouer toute la lecture.
        valeur = self._donnees[self._position:fin].decode("utf-8", errors="replace")
        self._position = fin + 1
        return valeur


def analyser_reponse_info(donnees: bytes) -> Optional[InfoServeur]:
    """
    Analyse une réponse A2S_INFO. Renvoie None si ce n'en est pas une.

    Logique pure : testable sans réseau, avec des captures réelles.
    """
    if len(donnees) < 5 or not donnees.startswith(_ENTETE_SIMPLE):
        return None
    lecteur = _Lecteur(donnees[4:])
    try:
        if lecteur.octet() != _REPONSE_INFO:
            return None
        lecteur.octet()          # version du protocole, sans intérêt ici
        nom = lecteur.chaine()
        carte = lecteur.chaine()
        lecteur.chaine()         # dossier du jeu
        lecteur.chaine()         # nom du jeu
        lecteur.sauter(2)        # identifiant Steam de l'application (tronqué)
        joueurs = lecteur.octet()
        joueurs_max = lecteur.octet()
    except ValueError as exc:
        logger.debug("Réponse A2S illisible : %s", exc)
        return None
    return InfoServeur(nom=nom, carte=carte, joueurs=joueurs, joueurs_max=joueurs_max)


def extraire_defi(donnees: bytes) -> Optional[bytes]:
    """
    Jeton d'un paquet de défi ('A'), ou None.

    Depuis la mise à jour anti-réflexion de Valve (2020), beaucoup de serveurs
    répondent d'abord par un défi qu'il faut renvoyer avec la requête. Ignorer
    ce cas ferait paraître muets des serveurs parfaitement vivants — et sur les
    versions les plus récentes, ce serait *tous* les serveurs.
    """
    if len(donnees) < 9 or not donnees.startswith(_ENTETE_SIMPLE):
        return None
    if donnees[4] != _DEMANDE_DE_DEFI:
        return None
    return donnees[5:9]


def interroger(
    hote: str, port: int, timeout: float = TIMEOUT_PAR_DEFAUT
) -> Optional[InfoServeur]:
    """
    Interroge un serveur et renvoie ses informations, ou None s'il ne répond pas.

    Ne lève jamais : un serveur éteint est le cas NORMAL ici, pas une anomalie.
    Appel bloquant (jusqu'à deux allers-retours), à exécuter dans un thread
    worker — voir `PingWorker`.
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
            sock.settimeout(timeout)
            sock.sendto(_REQUETE_INFO, (hote, port))
            donnees, _ = sock.recvfrom(4096)

            defi = extraire_defi(donnees)
            if defi is not None:
                sock.sendto(_REQUETE_INFO + defi, (hote, port))
                donnees, _ = sock.recvfrom(4096)

            return analyser_reponse_info(donnees)
    except (OSError, socket.timeout):
        return None
