"""
Code d'invitation : tout ce qu'un ami doit savoir, en une chaîne à coller.

Aujourd'hui, faire entrer quelqu'un demande de lui transmettre **trois choses
séparées** — l'adresse du serveur, le nom du réseau, le mot de passe — par un
autre canal, à retaper sans faute. C'est le principal obstacle d'accueil, et il
n'a rien de technique.

**Pourquoi une chaîne à coller et pas un lien `meshamoto://`.** Le schéma d'URI
est en tension depuis le point 2 avec le `.exe` portable choisi pour Windows :
enregistrer un gestionnaire de schéma suppose une installation, or l'exécutable
doit pouvoir tourner sans. Une chaîne copiable contourne le problème au lieu de
l'arbitrer — elle se colle dans une conversation, un courriel ou un message
vocal transcrit, et fonctionne identiquement quel que soit le mode de
distribution.

**Ce que ce code n'est pas.** Il contient le mot de passe du réseau, donc il ne
protège rien : il n'a ni expiration ni révocation, et quiconque le lit peut
entrer. C'est **exactement** le niveau de sécurité actuel, où le mot de passe est
envoyé par message — le code ne fait que supprimer les fautes de frappe et les
allers-retours. À partager avec le même soin que le mot de passe lui-même, et à
ne pas présenter comme une invitation au sens de Tailscale ou Discord, qui elles
sont révocables.
"""

from __future__ import annotations

import base64
import binascii
import json
import logging
from dataclasses import dataclass
from typing import Optional

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

# Préfixe visible : permet à quelqu'un qui reçoit la chaîne de savoir de quoi il
# s'agit, et à l'interface de reconnaître un collage d'invitation dans un champ
# qui accepte aussi autre chose. Le chiffre est une version de format : un client
# plus ancien pourra dire « invitation trop récente » au lieu d'échouer bizarrement.
PREFIXE = "MESHA1-"


@dataclass(frozen=True)
class Invitation:
    """Contenu d'un code d'invitation, prêt à remplir le formulaire de connexion."""

    server_address: str      # tel qu'on le taperait, ex: "https://exemple.duckdns.org"
    network_name: str
    password: str
    # Adresse du serveur sur SON réseau local, quand l'hôte la connaît. Offerte
    # aux amis sous le même toit : elle leur évite le blocage du hairpin NAT
    # (point 51) sans qu'ils aient à la deviner ni à la saisir.
    server_lan_address: str = ""


def choisir_adresse_a_partager(
    adresse_saisie: str, adresse_locale: str, ip_locale: str, domaine_public: str
) -> tuple[str, str]:
    """
    Décide de l'adresse à écrire dans le code : `(adresse, adresse_lan)`.

    `adresse_locale` est complète (hôte, port, schéma) et sert de repli quand il
    n'y a pas de nom public. `ip_locale` est l'IPv4 **nue**, et c'est elle qui
    part dans `server_lan_address` : côté invité, cette valeur est donnée telle
    quelle à `lan_override`, qui **refuse tout ce qui n'est pas une IPv4
    littérale** — y joindre un port désactiverait silencieusement la substitution
    chez le destinataire, donc précisément chez celui qu'on cherche à aider.

    **L'adresse par laquelle l'hôte joint SON serveur n'est pas celle que ses
    invités doivent utiliser.** Vrai dans le cas simple, faux dès qu'il y a un
    reverse proxy — c'est-à-dire dans le montage que le projet recommande
    lui-même pour une exposition Internet. Constaté le 2026-07-31 : l'hôte se
    joignait en `127.0.0.1:8765`, le code produisait `192.168.0.142:8765`, et ce
    port n'est joignable depuis AUCUNE autre machine puisque le serveur est lié à
    la boucle locale derrière Caddy. L'hôte voyait un code d'apparence normale, et
    c'est son invité qui échouait.

    Ordre de préférence, et il compte :

    1. **Le nom public** (DuckDNS), quand il est configuré et qu'on héberge
       localement. C'est la seule adresse valable depuis l'extérieur, et elle
       vaut aussi depuis le réseau local grâce à `adresse_lan` ci-dessous. En
       `https://` sans port : le montage documenté met un reverse proxy en 443,
       et le port découle alors du schéma.
    2. **L'adresse locale**, quand il n'y a pas de nom public — cas d'un réseau
       entre machines d'un même logement, où le serveur écoute directement.
    3. **La saisie telle quelle**, quand l'hôte vise un serveur distant : c'est
       alors déjà l'adresse que ses invités utiliseront.

    `adresse_lan` n'est renseignée que si l'hôte héberge chez lui : la
    transmettre pour un serveur distant enverrait les invités vers une adresse
    qui n'existe pas chez eux (point 51).
    """
    heberge_ici = _est_boucle_locale(adresse_saisie)
    if not heberge_ici:
        return adresse_saisie, ""
    if domaine_public:
        return f"https://{domaine_public}", ip_locale
    return adresse_locale, ip_locale


def _est_boucle_locale(adresse: str) -> bool:
    """Vrai si l'adresse saisie désigne cette machine (avec ou sans schéma/port)."""
    valeur = adresse.strip().lower()
    for prefixe in ("https://", "http://", "wss://", "ws://"):
        if valeur.startswith(prefixe):
            valeur = valeur[len(prefixe):]
            break
    hote = valeur.split("/", 1)[0].rsplit(":", 1)[0] if ":" in valeur else valeur.split("/", 1)[0]
    return hote in ("127.0.0.1", "localhost", "::1", "[::1]")


def encoder(invitation: Invitation) -> str:
    """
    Produit la chaîne à partager.

    Base64 **URL-safe** : la chaîne survit à un collage dans une URL, un nom de
    fichier ou un message qui traite les `+` et les `/` de travers. Le
    remplissage `=` est retiré puis rétabli à la lecture, parce que certains
    outils de messagerie le rognent en fin de ligne.
    """
    donnees = {
        "s": invitation.server_address,
        "n": invitation.network_name,
        "p": invitation.password,
    }
    if invitation.server_lan_address:
        donnees["l"] = invitation.server_lan_address
    brut = json.dumps(donnees, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    return PREFIXE + base64.urlsafe_b64encode(brut).decode("ascii").rstrip("=")


def ressemble_a_une_invitation(texte: str) -> bool:
    """Vrai si ce texte a l'air d'être un code, sans garantir qu'il soit lisible.
    Sert à l'interface pour reconnaître un collage et basculer d'affichage."""
    return _nettoyer(texte).upper().startswith(PREFIXE)


def decoder(texte: str) -> Optional[Invitation]:
    """
    Analyse un code collé, ou None s'il est illisible.

    **Tolérant au collage humain** : espaces, retours à la ligne et sauts
    insérés par un client de messagerie sont retirés avant analyse. Sans ça, un
    code copié depuis un message sur deux lignes échouerait, et l'utilisateur
    n'aurait aucun moyen de comprendre pourquoi.

    Ne lève jamais : un code invalide est un cas d'usage ordinaire (mauvais
    copier-coller), pas une anomalie.
    """
    nettoye = _nettoyer(texte)
    if not nettoye.upper().startswith(PREFIXE):
        return None
    charge = nettoye[len(PREFIXE):]
    # Rétablit le remplissage retiré à l'encodage.
    charge += "=" * (-len(charge) % 4)
    try:
        donnees = json.loads(base64.urlsafe_b64decode(charge).decode("utf-8"))
    except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
        logger.info("Code d'invitation illisible : %s", exc)
        return None
    if not isinstance(donnees, dict):
        return None

    adresse = str(donnees.get("s", "")).strip()
    nom = str(donnees.get("n", "")).strip()
    mot_de_passe = str(donnees.get("p", ""))
    # Les trois champs obligatoires : sans l'un d'eux le code ne remplit pas sa
    # promesse, et le laisser passer produirait un formulaire à moitié rempli
    # dont l'utilisateur ne saurait pas quoi faire.
    if not adresse or not nom or not mot_de_passe:
        return None
    return Invitation(
        server_address=adresse,
        network_name=nom,
        password=mot_de_passe,
        server_lan_address=str(donnees.get("l", "")).strip(),
    )


def _nettoyer(texte: str) -> str:
    """Retire tout ce qu'un copier-coller peut ajouter : espaces, retours à la
    ligne, tabulations."""
    return "".join(texte.split())
