"""
Sonde de vivacité pour les services de type `web` annoncés par un pair.

Pendant de `a2s_client` pour les serveurs de jeu : le salon doit pouvoir distinguer
« Jellyfin (8096) » — déclaré, jamais vérifié — de « Jellyfin (8096) — répond ». C'est
précisément l'information qu'aucun concurrent ne donne (point 47), et elle vaut autant
pour une interface web que pour une partie.

**Toute réponse vaut « vivant », y compris 401, 403 ou 500.** On mesure la VIVACITÉ,
pas l'autorisation ni la bonne santé applicative : un service qui répond 401 est
manifestement debout, et prétendre le contraire enverrait chercher une panne réseau là
où il n'y a qu'une page de connexion. Mesuré le 2026-08-08 : Jellyfin répond 302 sur
`/` et 401 sur `/System/Info` — deux réponses parfaitement saines.

**Une poignée de main TCP réussie suffit**, même si l'échange HTTP échoue ensuite.
C'est ce qui rend la sonde utile face à un service en HTTPS, ou qui parle un protocole
qu'on ne connaît pas sur ce port. La méthode du témoin TCP a déjà tranché deux
diagnostics en conditions réelles (points 65 et 67bis) : elle sépare « le tunnel et le
pare-feu marchent » de « l'application ne répond pas comme prévu ».
"""

from __future__ import annotations

import logging
import socket
from dataclasses import dataclass

logger = logging.getLogger(__name__)

# Le cycle du salon dure 5 s et les sondes sont bornées par un délai d'attente, pas par
# du calcul (point 53). Une seconde suffit largement dans le tunnel — Jellyfin répond en
# 1,4 ms en local et le pair le plus lointain du parc est à 40 ms.
TIMEOUT_PAR_DEFAUT = 1.0

# On ne lit que la ligne de statut. Lire le corps ferait transiter une page entière
# toutes les 5 secondes, pour une information qui tient dans trois chiffres.
_OCTETS_LUS_MAX = 256


@dataclass(frozen=True)
class InfoService:
    """Ce qu'on a pu apprendre. `code` est None quand seul le TCP a abouti."""

    code: int | None

    def resume(self) -> str:
        if self.code is None:
            # Le port accepte les connexions mais n'a pas répondu en HTTP : très
            # probablement du HTTPS, ou un autre protocole. Dire « répond » serait
            # exact, mais nommer ce qu'on a réellement constaté aide au diagnostic.
            return "port ouvert"
        return "répond"


def sonder(
    ip: str, port: int, timeout: float = TIMEOUT_PAR_DEFAUT
) -> InfoService | None:
    """
    Interroge `ip:port`. Renvoie None si rien ne répond du tout.

    Ne lève jamais : une sonde qui lève ferait échouer tout le lot du salon, et un
    seul service en erreur viderait le tableau (même précaution que `parallel_probe`).
    """
    try:
        with socket.create_connection((ip, port), timeout=timeout) as connexion:
            connexion.settimeout(timeout)
            # `Connection: close` évite de laisser une connexion en attente chez le
            # pair à chaque cycle de sonde, soit toutes les 5 secondes et par service.
            requete = (
                f"GET / HTTP/1.1\r\n"
                f"Host: {ip}:{port}\r\n"
                f"User-Agent: Meshamoto\r\n"
                f"Connection: close\r\n\r\n"
            )
            try:
                connexion.sendall(requete.encode("ascii"))
                debut = connexion.recv(_OCTETS_LUS_MAX)
            except OSError:
                # Le TCP a abouti, l'échange HTTP non. Le service est debout.
                return InfoService(code=None)
            return InfoService(code=_code_de_statut(debut))
    except OSError:
        return None


def _code_de_statut(debut: bytes) -> int | None:
    """
    Extrait le code de la ligne de statut, ou None si elle est illisible.

    Analyse volontairement minimale : on n'a besoin que de savoir qu'il y a eu une
    réponse HTTP. Tout ce qui n'est pas reconnu retombe sur None, ce qui vaut « port
    ouvert » — un repli honnête plutôt qu'une affirmation sur un protocole qu'on ne
    reconnaît pas.
    """
    if not debut.startswith(b"HTTP/"):
        return None
    morceaux = debut.split(b" ", 2)
    if len(morceaux) < 2:
        return None
    try:
        code = int(morceaux[1])
    except ValueError:
        return None
    return code if 100 <= code <= 599 else None
