"""
Types de service annoncés par un pair, et ce que le salon doit en faire.

Jusqu'ici un pair n'annonçait qu'UN service, via le couple `hosted_service_label` /
`hosted_service_port`, et ce port était **présumé être un port de jeu** par les trois
consommateurs de `gui.py` : la sonde A2S, les favoris Steam et le bouton « Rejoindre ».
Annoncer un service qui n'est pas un jeu — mesuré le 2026-08-08 avec Jellyfin sur 8096 —
produisait donc trois comportements faux, tous silencieux : une requête A2S vers un port
qui n'y répond jamais (1,5 s de délai par cycle, et « sans réponse » affiché alors que le
serveur va très bien), une entrée injectée dans les favoris Steam de l'utilisateur, et un
`steam://connect` vers un serveur de jeu inexistant.

Le type n'est donc pas un confort d'affichage : c'est ce qui rend l'annonce honnête.

**Le type est DÉCLARÉ, jamais déduit du port.** Deviner « 8096 donc web » serait une
heuristique, et le projet en a déjà payé une au point 62 — nommer une cause qu'on n'a pas
mesurée envoie chercher au mauvais endroit. Le défaut est `jeu`, c'est-à-dire exactement
le comportement actuel : rien ne change pour qui ne touche à rien.

**Asymétrie voulue entre l'écriture et la lecture**, et c'est le cœur du module :
- ce qu'on DÉCLARE est validé strictement — on le contrôle, autant refuser tout de suite ;
- ce qu'un pair ANNONCE est lu avec tolérance. Un type inconnu, venu d'un client plus
  récent, retombe sur un comportement générique au lieu d'être rejeté ou de faire planter
  le salon. Même règle que `os_family` (point 33) et `last_local_endpoints` (point 91) :
  la compatibilité doit tenir dans les DEUX sens.

Logique pure, sans Qt, sans réseau, sans accès disque : testable directement.
"""

from __future__ import annotations

import json
from dataclasses import dataclass

# --- Les types connus -------------------------------------------------------------
#
# Cette liste est DUPLIQUÉE dans `server/app.py`, qui doit valider ce qu'un client
# déclare. Les deux arbres ne peuvent pas s'importer : `client/config.py` et
# `server/config.py` portent le même nom de module et les suites de tests se lancent
# séparément (voir les conventions du projet). Un test anti-dérive compare les deux
# listes — sans lui elles divergeraient en silence, et un type accepté d'un côté serait
# rejeté de l'autre.
TYPE_JEU = "jeu"
TYPE_WEB = "web"
TYPE_FICHIER = "fichier"
TYPE_VOIX = "voix"

TYPES_CONNUS: tuple[str, ...] = (TYPE_JEU, TYPE_WEB, TYPE_FICHIER, TYPE_VOIX)

# Noms des clés sur le fil. Nommés plutôt qu'écrits en clair pour que le test
# anti-dérive puisse les confronter à ceux de `server/app.py`, qui les redéclare —
# les deux arbres ne peuvent pas s'importer (même nom de module que `server/config.py`,
# suites de tests séparées). Renommer une clé d'un seul côté ferait disparaître les
# services de tout le monde, en silence.
CLE_LABEL = "label"
CLE_PORT = "port"
CLE_TYPE = "type"

# `jeu` par défaut : c'est le comportement d'avant ce module. Un client qui met à jour
# sans rien reconfigurer doit retrouver exactement ce qu'il avait.
TYPE_PAR_DEFAUT = TYPE_JEU

# --- Sondes et actions ------------------------------------------------------------
SONDE_AUCUNE = "aucune"
SONDE_A2S = "a2s"
SONDE_HTTP = "http"

ACTION_COPIER = "copier"
ACTION_REJOINDRE = "rejoindre"
ACTION_NAVIGATEUR = "navigateur"
ACTION_EXPLORATEUR = "explorateur"


@dataclass(frozen=True)
class Comportement:
    """Ce que le salon fait d'un service : comment le sonder, et quel bouton offrir."""

    sonde: str
    action: str
    description: str


# `voix` n'a volontairement PAS de lanceur `mumble://`. Enregistrer un gestionnaire de
# schéma d'URI suppose une installation, ce qui est en tension avec l'`.exe` portable —
# tension identifiée au point 2 et jamais tranchée. Le jour où un lanceur sera écrit, il
# lui faudra le repli sur la copie d'adresse que `game_launcher` applique déjà à
# `steam://`. En attendant, copier l'adresse est l'action correcte, pas un pis-aller.
_REGISTRE: dict[str, Comportement] = {
    TYPE_JEU: Comportement(SONDE_A2S, ACTION_REJOINDRE, "Serveur de jeu"),
    TYPE_WEB: Comportement(SONDE_HTTP, ACTION_NAVIGATEUR, "Interface web"),
    TYPE_FICHIER: Comportement(SONDE_AUCUNE, ACTION_EXPLORATEUR, "Partage de fichiers"),
    TYPE_VOIX: Comportement(SONDE_AUCUNE, ACTION_COPIER, "Serveur vocal"),
}

# Un type qu'on ne connaît pas vient forcément d'un pair plus récent que nous. On
# l'affiche et on offre la copie d'adresse : c'est toujours utile, et ça ne prétend
# rien sur un protocole qu'on ne sait pas parler.
_COMPORTEMENT_INCONNU = Comportement(SONDE_AUCUNE, ACTION_COPIER, "Service")

# --- Bornes de validation ---------------------------------------------------------
#
# Le plafond borne deux choses à la fois : la lisibilité de la cellule Service du salon,
# et le BUDGET DE SONDE. Le cycle du salon dure 5 s et chaque sonde est bornée par un
# délai d'attente, pas par du calcul (point 53) — sans plafond, un pair pourrait à lui
# seul saturer le cycle de tout le monde.
SERVICES_MAX = 8

# Le libellé est affiché dans une cellule de tableau et recopié dans les favoris Steam.
LIBELLE_MAX = 64


@dataclass(frozen=True)
class Service:
    libelle: str
    port: int
    type_service: str

    def comportement(self) -> Comportement:
        return comportement_de(self.type_service)

    def adresse(self, virtual_ip: str) -> str:
        return f"{virtual_ip}:{self.port}"


def comportement_de(type_service: str) -> Comportement:
    """Comportement associé à un type, générique si le type est inconnu."""
    return _REGISTRE.get(type_service, _COMPORTEMENT_INCONNU)


def est_type_connu(type_service: str) -> bool:
    return type_service in _REGISTRE


def description_de(type_service: str) -> str:
    return comportement_de(type_service).description


def _libelle_acceptable(libelle: str) -> bool:
    """
    Un libellé ne doit contenir ni caractère de contrôle ni guillemet double.

    Ce n'est pas de la coquetterie : `steam_favorites.render_vdf` sérialise en
    `"clé"\\t\\t"valeur"` **sans échappement**, donc un saut de ligne ou un `"` dans un
    libellé corromprait le fichier de favoris Steam — un fichier que l'utilisateur a
    construit lui-même et dont le projet prend soin (point 41). Le libellé vient d'un
    pair, donc d'ailleurs que de nous.
    """
    if not libelle or len(libelle) > LIBELLE_MAX:
        return False
    if '"' in libelle:
        return False
    return all(caractere.isprintable() for caractere in libelle)


def valider(libelle: str, port: int, type_service: str) -> Service:
    """
    Valide un service que NOUS déclarons. Lève `ValueError` en cas de refus.

    Strict par choix : c'est notre propre saisie, la refuser tout de suite donne un
    message clair, alors que la laisser passer produirait une annonce que les autres
    pairs interpréteraient de travers.
    """
    libelle = (libelle or "").strip()
    if not _libelle_acceptable(libelle):
        raise ValueError(
            f"Libellé invalide : 1 à {LIBELLE_MAX} caractères, sans guillemet double "
            "ni caractère de contrôle."
        )
    if not isinstance(port, int) or isinstance(port, bool) or not (1 <= port <= 65535):
        raise ValueError("Port invalide : un entier entre 1 et 65535 est attendu.")
    if not est_type_connu(type_service):
        raise ValueError(
            f"Type de service inconnu : {type_service!r}. "
            f"Attendu l'un de {', '.join(TYPES_CONNUS)}."
        )
    return Service(libelle=libelle, port=port, type_service=type_service)


def en_charge_utile(services: list[Service]) -> list[dict]:
    """
    Forme transportée sur le fil, telle qu'attendue par le serveur.

    Les noms de clés ne sont écrits QU'ICI côté client : `serialiser` et
    `network_client.announce_services` passent tous deux par cette fonction. Les
    écrire à deux endroits ferait diverger le stockage et l'annonce le jour où l'un
    des deux changerait, et la panne serait muette — le serveur écarterait des
    entrées qu'il ne reconnaît plus.
    """
    return [
        {CLE_LABEL: s.libelle, CLE_PORT: s.port, CLE_TYPE: s.type_service}
        for s in services
    ]


def serialiser(services: list[Service]) -> str:
    """
    Rend la forme stockée en base et diffusée dans `peer_list`.

    JSON, et non une liste séparée par des virgules comme `last_local_endpoints`
    (point 91) : un libellé est du texte libre saisi par un humain, donc il peut
    contenir le séparateur. Choisir un séparateur imposerait de l'interdire dans les
    libellés, ce qui est une contrainte arbitraire là où JSON ne coûte rien.
    """
    return json.dumps(en_charge_utile(services), ensure_ascii=False)


def deserialiser(brut: str | None) -> list[Service]:
    """
    Lit ce qu'un pair annonce. **Ne lève jamais.**

    Tout ce qui arrive ici vient d'ailleurs — d'un pair plus récent, plus ancien, ou
    d'une base écrite par une autre version. Une entrée illisible est ignorée, pas
    propagée : faire planter le salon parce qu'un pair a annoncé quelque chose
    d'inattendu ferait dépendre l'affichage de tous du client le plus bancal.

    Un type inconnu est CONSERVÉ tel quel plutôt que remplacé par le défaut : le
    remplacer par `jeu` déclencherait la sonde A2S et les favoris Steam, c'est-à-dire
    exactement les trois comportements faux que ce module existe pour supprimer.
    """
    if not brut:
        return []
    try:
        charge = json.loads(brut)
    except (ValueError, TypeError):
        return []
    if not isinstance(charge, list):
        return []

    services: list[Service] = []
    ports_vus: set[int] = set()
    for entree in charge:
        if not isinstance(entree, dict):
            continue
        libelle = entree.get(CLE_LABEL)
        port = entree.get(CLE_PORT)
        type_service = entree.get(CLE_TYPE) or TYPE_PAR_DEFAUT
        if not isinstance(libelle, str) or not _libelle_acceptable(libelle.strip()):
            continue
        if not isinstance(port, int) or isinstance(port, bool) or not (1 <= port <= 65535):
            continue
        if not isinstance(type_service, str) or not type_service:
            continue
        # Deux services sur le même port, c'est le même service : on garde le premier
        # plutôt que d'afficher deux lignes qui pointent au même endroit.
        if port in ports_vus:
            continue
        ports_vus.add(port)
        services.append(Service(libelle.strip(), port, type_service))
        if len(services) >= SERVICES_MAX:
            break
    return services


def services_du_pair(peer: dict) -> list[Service]:
    """
    Services d'un pair tel que le serveur le diffuse, ancien format compris.

    Le serveur synthétise déjà `hosted_services` à partir de l'ancien couple pour les
    pairs qui n'annoncent que lui, précisément pour que le client n'ait jamais deux cas
    à gérer. Ce repli couvre le cas restant : un SERVEUR d'une version antérieure, qui
    ne connaît pas du tout le nouveau champ. Sans lui, mettre à jour le client avant le
    serveur ferait disparaître les services de tout le monde.
    """
    services = deserialiser(peer.get("hosted_services"))
    if services:
        return services

    libelle = peer.get("hosted_service_label")
    port = peer.get("hosted_service_port")
    if libelle and port:
        try:
            return [Service(str(libelle).strip(), int(port), TYPE_PAR_DEFAUT)]
        except (TypeError, ValueError):
            return []
    return []
