"""
Décide s'il faut retenter la WebSocket de signalisation, et après combien de temps.

Logique PURE, séparée du thread et du réseau : c'est la seule partie de la reconnexion
qui contienne des décisions, et elle doit être testable sans serveur ni horloge. Le
reste (`network_client._boucle_websocket`) n'est qu'une boucle qui applique ces
réponses.

**Ce qui a rendu ce module nécessaire (point 70).** Il n'existait aucune reconnexion :
`run_forever()` était appelé une fois, et à la première fermeture le thread se terminait
en journalisant `WebSocket fermée`. Un redémarrage du serveur, une coupure Wi-Fi ou une
expulsion laissaient donc le pair silencieusement dehors, sans le savoir ni pouvoir
revenir seul. Constaté en conditions réelles le 2026-08-01 sur une machine distante
qu'il a fallu aller relancer à la main.

**La distinction qui structure tout** : « le serveur ne répond pas » et « le serveur ne
veut plus de moi » demandent des réponses OPPOSÉES. Le premier doit réessayer, le
second doit s'arrêter net et le dire — réessayer indéfiniment contre un serveur qui
nous a expulsé remplit son journal, n'aboutira jamais, et laisse l'utilisateur devant
une fenêtre qui ne dit rien. D'où le code de fermeture applicatif que le serveur envoie
désormais (`app.WS_PEER_INCONNU`).
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional

import config

# Doit rester égal à `server.app.WS_PEER_INCONNU`. Recopié plutôt qu'importé : le client
# ne dépend pas du code du serveur, et il doit continuer de fonctionner avec un serveur
# d'une autre version. Un test verrouille l'égalité des deux constantes.
CODE_PEER_INCONNU = 4404

# Codes de fermeture RFC 6455 émis lors d'un arrêt volontaire de notre côté.
CODE_FERMETURE_NORMALE = 1000


@dataclass(frozen=True)
class Decision:
    """Que faire après une fermeture de WebSocket."""

    reessayer: bool
    delai: float = 0.0
    # Message destiné à l'UTILISATEUR, vide s'il n'y a rien à dire. Le but du chantier
    # n'est pas seulement de se reconnecter : c'est que personne n'ait à deviner.
    message: str = ""


def delai_avant_tentative(tentative: int) -> float:
    """
    Recul exponentiel plafonné, à partir de la tentative 1.

    Plafonné et non illimité : un pair déconnecté n'est pas un pair au repos, il est
    absent du réseau de tous les autres pendant ce temps. Une attente qui doublerait
    sans fin finirait par transformer une coupure de dix secondes en absence d'un
    quart d'heure.
    """
    if tentative < 1:
        tentative = 1
    delai = config.RECONNECT_INITIAL_DELAY_SECONDS * (
        config.RECONNECT_BACKOFF_FACTOR ** (tentative - 1)
    )
    return min(delai, config.RECONNECT_MAX_DELAY_SECONDS)


def decider(
    code_fermeture: Optional[int],
    tentative: int,
    arret_demande: bool = False,
) -> Decision:
    """
    Décide de la suite après une fermeture, à partir du code reçu.

    `code_fermeture` vaut None quand la connexion est tombée sans fermeture propre
    (câble arraché, serveur tué, Wi-Fi coupé) : c'est le cas le PLUS courant, et c'est
    précisément celui où il faut réessayer.
    """
    if arret_demande:
        # L'utilisateur ferme l'application ou change de réseau : se reconnecter serait
        # un bug, pas une robustesse.
        return Decision(reessayer=False)

    if code_fermeture == CODE_PEER_INCONNU:
        return Decision(
            reessayer=False,
            message=(
                "Ce pair ne fait plus partie du réseau : il en a été retiré, ou le "
                "réseau a été supprimé. Reconnectez-vous pour le rejoindre à nouveau."
            ),
        )

    return Decision(
        reessayer=True,
        delai=delai_avant_tentative(tentative),
        message="Connexion au serveur perdue, reconnexion automatique…",
    )
