"""
Client STUN minimal (RFC 5389, "binding request") pour découvrir l'adresse publique
IP:port telle que vue depuis Internet (nécessaire pour le hole punching NAT).

On envoie la requête STUN depuis LE MÊME socket UDP que celui utilisé par WireGuard
(WIREGUARD_LISTEN_PORT), pour que l'IP:port découvert corresponde exactement au
mapping NAT que les autres pairs devront utiliser pour nous joindre en direct.

Limite connue : derrière un NAT symétrique, l'IP:port observé par STUN peut différer
de celui utilisé pour joindre un pair spécifique (mapping différent par destination).
Dans ce cas, le hole punching direct échoue et il faut basculer sur le relais UDP
(relay.py) — géré au niveau de network_client.py.

IMPORTANT : ce module doit être appelé AVANT que le tunnel WireGuard ne soit installé
(voir network_client.py::NetworkClient._discover_own_endpoint). Une fois le tunnel actif,
WIREGUARD_LISTEN_PORT est occupé exclusivement par l'interface WireGuard et le bind()
ci-dessous échoue systématiquement.
"""

from __future__ import annotations

import logging
import socket
import struct

from config import STUN_SERVERS

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

_STUN_MAGIC_COOKIE = 0x2112A442
_BINDING_REQUEST = 0x0001
_XOR_MAPPED_ADDRESS_ATTR = 0x0020
_MAPPED_ADDRESS_ATTR = 0x0001


def _build_binding_request(transaction_id: bytes) -> bytes:
    # En-tête STUN : type (2o) + longueur du corps (2o) + magic cookie (4o) + transaction id (12o)
    return struct.pack("!HHI12s", _BINDING_REQUEST, 0, _STUN_MAGIC_COOKIE, transaction_id)


def _parse_xor_mapped_address(body: bytes, transaction_id: bytes) -> tuple[str, int] | None:
    offset = 0
    while offset + 4 <= len(body):
        attr_type, attr_len = struct.unpack_from("!HH", body, offset)
        value = body[offset + 4: offset + 4 + attr_len]
        if attr_type == _XOR_MAPPED_ADDRESS_ATTR and len(value) >= 8:
            _reserved, _family, xport = struct.unpack_from("!BBH", value, 0)
            port = xport ^ (_STUN_MAGIC_COOKIE >> 16)
            xaddr = struct.unpack_from("!I", value, 4)[0]
            addr_int = xaddr ^ _STUN_MAGIC_COOKIE
            ip = socket.inet_ntoa(struct.pack("!I", addr_int))
            return ip, port
        if attr_type == _MAPPED_ADDRESS_ATTR and len(value) >= 8:
            _reserved, _family, port = struct.unpack_from("!BBH", value, 0)
            addr_int = struct.unpack_from("!I", value, 4)[0]
            ip = socket.inet_ntoa(struct.pack("!I", addr_int))
            return ip, port
        offset += 4 + attr_len
    return None


def discover_public_endpoint(local_port: int, timeout: float = 3.0) -> tuple[str, int] | None:
    """
    Interroge les serveurs STUN configurés jusqu'à obtenir une réponse.
    Retourne (ip_publique, port_public) ou None si tous les serveurs échouent
    (ex: pas de connectivité Internet, pare-feu Windows bloquant l'UDP sortant).
    """
    import os

    transaction_id = os.urandom(12)
    request = _build_binding_request(transaction_id)

    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.settimeout(timeout)
    try:
        sock.bind(("0.0.0.0", local_port))
    except OSError as exc:
        logger.error(
            "Impossible de lier le socket STUN sur le port %d (déjà utilisé ?) : %s",
            local_port, exc,
        )
        sock.close()
        return None

    try:
        for host, port in STUN_SERVERS:
            try:
                sock.sendto(request, (host, port))
                data, _addr = sock.recvfrom(2048)
                # On vérifie que la réponse correspond bien à notre requête avant de la traiter
                resp_type, _length, _cookie, resp_txn = struct.unpack("!HHI12s", data[:20])
                if resp_txn != transaction_id:
                    continue
                result = _parse_xor_mapped_address(data[20:], transaction_id)
                if result:
                    logger.info("Endpoint public découvert via %s: %s:%d", host, *result)
                    return result
            except (socket.timeout, OSError) as exc:
                logger.warning("Serveur STUN %s:%d injoignable : %s", host, port, exc)
                continue
    finally:
        sock.close()

    logger.error("Aucun serveur STUN n'a répondu — vérifiez la connectivité Internet / pare-feu Windows.")
    return None
