"""
Relais UDP applicatif pour émuler la découverte de serveurs LAN à travers le tunnel
WireGuard. WireGuard (couche 3, IP pur) ne transporte pas nativement le broadcast
(`255.255.255.255`) que la plupart des jeux plus anciens utilisent pour se découvrir
sur le réseau local — ce module relaie ces paquets entre pairs sans nécessiter de
pont L2/pilote TAP supplémentaire (voir README.md, section "Gaming LAN virtuel").

Architecture à deux canaux :
- Canal Capture : socket lié sur ("", PORT_JEU) avec SO_REUSEADDR, coexiste avec le
  socket du jeu, capte son broadcast de découverte local, et sert aussi à réinjecter
  les paquets reçus des pairs (voir plus bas — c'est ce même socket qui a déjà les
  droits SO_BROADCAST).
- Canal Infrastructure : socket dédié sur PORT_JEU + LAN_RELAY_PORT_OFFSET, relaie en
  unicast vers les IP virtuelles des pairs à travers le tunnel WireGuard déjà établi.

Réinjection : émise en broadcast sur l'interface LOOPBACK (`127.255.255.255:PORT_JEU`),
pas en unicast vers `127.0.0.1` ni en broadcast LAN vers `255.255.255.255`. Les trois
comportements ont été mesurés (voir tests/test_lan_broadcast_relay_reinjection.py) :

    destination        Linux     Windows
    127.255.255.255    tous      tous      <- retenu
    255.255.255.255    AUCUN     tous
    127.0.0.1          un seul   un seul

- `127.0.0.1` (unicast) n'est délivré qu'à UN seul socket parmi ceux liés sur le même
  port, au choix du noyau — le jeu peut donc ne jamais voir le paquet ;
- `255.255.255.255` était l'implémentation d'origine. Windows en délivre bien une copie
  aux sockets locaux, mais **Linux n'en délivre aucune** : le paquet part sur le câble,
  point final. La réinjection — donc tout le relais, dont c'est le dernier maillon —
  était ainsi inopérante sur Linux seulement, ce qui explique qu'elle ait pu sembler
  correcte. Cas d'école de la règle de symétrie du projet ;
- `127.255.255.255` est délivré à TOUS les sockets liés à ce port sur les DEUX OS, y
  compris celui du jeu. C'est la propriété recherchée, obtenue sur l'interface loopback
  plutôt que sur celle du LAN.

Émettre sur loopback est aussi préférable en soi : le paquet réinjecté est destiné aux
processus locaux, le renvoyer sur le LAN le rediffuserait à des machines qui l'ont déjà
reçu de leur côté.

Un paquet réinjecté localement est aussi vu par le Canal Capture (qui co-écoute le
même port) : sans précaution, il serait re-relayé indéfiniment. Le filtrage par IP
source ne fonctionne pas ici (sendto() ne préserve pas l'IP du pair distant, et
usurper l'IP d'origine nécessiterait un raw socket + IP_HDRINCL, explicitement
bloqué par Windows). La solution retenue est une déduplication par empreinte de
payload : chaque paquet réinjecté est mémorisé brièvement (TTL) et ignoré par le
Canal Capture s'il le revoit.
"""

from __future__ import annotations

import hashlib
import logging
import socket
import threading
import time
from dataclasses import dataclass, field

from config import LAN_RELAY_PORT_OFFSET

logger = logging.getLogger(__name__)

DEDUP_TTL_SECONDS = 1.0
RECV_BUFFER_SIZE = 4096
SOCKET_TIMEOUT_SECONDS = 0.5
# Broadcast de l'interface loopback : seule adresse dont le noyau délivre une copie à
# TOUS les sockets locaux liés au port. Voir l'en-tête du module pour les mesures.
BROADCAST_ADDRESS = "127.255.255.255"


@dataclass
class _DedupCache:
    """Cache à expiration courte des empreintes de paquets qu'on vient de réinjecter."""

    ttl_seconds: float = DEDUP_TTL_SECONDS
    _expiry_by_hash: dict[str, float] = field(default_factory=dict)
    _lock: threading.Lock = field(default_factory=threading.Lock)

    @staticmethod
    def _hash(payload: bytes) -> str:
        return hashlib.sha256(payload).hexdigest()

    def remember(self, payload: bytes) -> None:
        digest = self._hash(payload)
        with self._lock:
            self._expiry_by_hash[digest] = time.monotonic() + self.ttl_seconds

    def seen_recently(self, payload: bytes) -> bool:
        digest = self._hash(payload)
        now = time.monotonic()
        with self._lock:
            expiry = self._expiry_by_hash.get(digest)
            if expiry is None:
                return False
            if expiry < now:
                del self._expiry_by_hash[digest]
                return False
            return True


class LanBroadcastRelay:
    """
    Relaie les paquets de découverte LAN d'un jeu à port de découverte fixe et connu
    (ex: 27015 pour les jeux moteur Source/GoldSrc) entre pairs Meshamoto.
    """

    def __init__(self, game_port: int, local_virtual_ip: str) -> None:
        self.game_port = game_port
        self.infra_port = game_port + LAN_RELAY_PORT_OFFSET
        self.local_virtual_ip = local_virtual_ip
        self._peer_virtual_ips: set[str] = set()
        self._peer_ips_lock = threading.Lock()
        self._dedup = _DedupCache()
        self._stop_event = threading.Event()
        self._capture_socket: socket.socket | None = None
        self._infra_socket: socket.socket | None = None
        self._capture_thread: threading.Thread | None = None
        self._infra_thread: threading.Thread | None = None

    def set_peers(self, virtual_ips: list[str]) -> None:
        """
        Met à jour la liste des IP virtuelles des pairs à qui relayer — à appeler à
        chaque mise à jour de la liste de pairs reçue du serveur de signalisation.
        """
        with self._peer_ips_lock:
            self._peer_virtual_ips = {ip for ip in virtual_ips if ip != self.local_virtual_ip}

    def start(self) -> None:
        self._capture_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self._capture_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self._capture_socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        self._capture_socket.bind(("", self.game_port))
        self._capture_socket.settimeout(SOCKET_TIMEOUT_SECONDS)

        self._infra_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self._infra_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self._infra_socket.bind((self.local_virtual_ip, self.infra_port))
        self._infra_socket.settimeout(SOCKET_TIMEOUT_SECONDS)

        self._stop_event.clear()
        self._capture_thread = threading.Thread(target=self._run_capture_loop, daemon=True)
        self._infra_thread = threading.Thread(target=self._run_infra_loop, daemon=True)
        self._capture_thread.start()
        self._infra_thread.start()
        logger.info(
            "Relais LAN démarré (port jeu=%d, port infra=%d, IP virtuelle=%s)",
            self.game_port, self.infra_port, self.local_virtual_ip,
        )

    def stop(self) -> None:
        self._stop_event.set()
        if self._capture_thread is not None:
            self._capture_thread.join(timeout=2)
        if self._infra_thread is not None:
            self._infra_thread.join(timeout=2)
        if self._capture_socket is not None:
            self._capture_socket.close()
        if self._infra_socket is not None:
            self._infra_socket.close()
        logger.info("Relais LAN arrêté (port jeu=%d)", self.game_port)

    def _run_capture_loop(self) -> None:
        assert self._capture_socket is not None
        while not self._stop_event.is_set():
            try:
                payload, _addr = self._capture_socket.recvfrom(RECV_BUFFER_SIZE)
            except socket.timeout:
                continue
            except OSError:
                break

            if self._dedup.seen_recently(payload):
                continue

            self._relay_to_peers(payload)

    def _relay_to_peers(self, payload: bytes) -> None:
        assert self._infra_socket is not None
        with self._peer_ips_lock:
            peer_ips = list(self._peer_virtual_ips)
        for peer_ip in peer_ips:
            try:
                self._infra_socket.sendto(payload, (peer_ip, self.infra_port))
            except OSError:
                logger.exception("Échec de relais du paquet LAN vers le pair %s", peer_ip)

    def _run_infra_loop(self) -> None:
        assert self._infra_socket is not None
        while not self._stop_event.is_set():
            try:
                payload, _addr = self._infra_socket.recvfrom(RECV_BUFFER_SIZE)
            except socket.timeout:
                continue
            except OSError:
                break

            self._dedup.remember(payload)
            self._reinject_locally(payload)

    def _reinject_locally(self, payload: bytes) -> None:
        # Émis depuis le Canal Capture (déjà SO_BROADCAST) vers le broadcast de la
        # loopback : seule adresse dont le noyau délivre une copie à TOUS les sockets
        # locaux liés sur PORT_JEU, dont celui du jeu. Un unicast vers 127.0.0.1
        # n'en atteindrait qu'un seul, et 255.255.255.255 aucun (voir l'en-tête).
        assert self._capture_socket is not None
        try:
            self._capture_socket.sendto(payload, (BROADCAST_ADDRESS, self.game_port))
        except OSError:
            logger.exception("Échec de réinjection locale du paquet LAN relayé")
