"""
Exécute en parallèle les sondes du lobby (pings, requêtes A2S).

**Pourquoi c'est nécessaire et pas cosmétique.** Ces sondes sont bornées par des
TIMEOUTS, pas par du calcul : un pair injoignable coûte une seconde pleine, un
serveur de jeu muet une seconde et demie. En séquentiel, huit pairs injoignables
demandent **8 secondes mesurées**, alors que le cycle du lobby est de 5 — les
demandes s'empilent donc plus vite qu'elles ne se vident, et l'interface paraît
figée précisément quand quelque chose ne va pas.

**Des threads et non `asyncio`.** Les appels sous-jacents sont bloquants par
nature (`subprocess.run` vers le `ping` du système, `socket.recvfrom` pour A2S)
et relâchent le GIL pendant l'attente : un pool de threads les recouvre
parfaitement. Passer en `asyncio` supposerait de réécrire les deux clients et
d'introduire une boucle d'événements dans un programme qui en a déjà une (Qt) —
beaucoup de complexité pour le même résultat.

Le langage n'est pour rien dans le gain : ce qu'on récupère, c'est du temps
d'attente recouvert, pas du temps de calcul.
"""

from __future__ import annotations

import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, Iterable, Sequence, TypeVar

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

C = TypeVar("C")  # type d'une cible (une IP, ou un couple IP/port)
R = TypeVar("R")  # type d'un résultat

# Plafond de threads simultanés. La plage virtuelle autorise 254 pairs ; ouvrir
# autant de threads d'un coup pour lancer autant de processus `ping` chargerait
# la machine bien plus que le temps qu'on cherche à économiser. Seize suffit :
# au-delà de 16 pairs injoignables, deux vagues d'une seconde restent sous le
# cycle de 5 s, et le plafond réel du projet est de toute façon plus bas.
MAX_THREADS = 16


def sonder(
    cibles: Sequence[C],
    sonde: Callable[[C], R],
    cle: Callable[[C], object] | None = None,
) -> dict:
    """
    Applique `sonde` à toutes les cibles en parallèle -> {clé: résultat}.

    `cle` extrait l'identifiant d'une cible (par défaut la cible elle-même) :
    les sondes A2S travaillent sur des couples `(ip, port)` alors que le tableau
    du lobby est indexé par IP.

    **Une sonde qui lève ne fait pas échouer les autres** : son résultat vaut
    None, comme une sonde qui n'aboutit pas. Le contraire viderait tout le
    tableau de ping à cause d'un seul pair, ce qui serait à la fois plus
    déroutant et plus grave que la panne d'origine.
    """
    if not cibles:
        return {}
    extraire = cle or (lambda cible: cible)

    with ThreadPoolExecutor(max_workers=min(len(cibles), MAX_THREADS)) as pool:
        # `map` préserve l'ordre des cibles, ce qui suffit ici : on reconstruit
        # un dictionnaire, et `as_completed` n'apporterait qu'un affichage au fil
        # de l'eau dont le lobby n'a pas besoin — il repeint d'un coup.
        resultats = list(pool.map(_sans_lever(sonde), cibles))
    return {extraire(cible): resultat for cible, resultat in zip(cibles, resultats)}


def _sans_lever(sonde: Callable[[C], R]) -> Callable[[C], R | None]:
    def enveloppe(cible: C) -> R | None:
        try:
            return sonde(cible)
        except Exception:  # noqa: BLE001 - une cible ne doit jamais casser le lot
            logger.exception("Sonde en échec pour %r", cible)
            return None

    return enveloppe
