"""
Clés de licence et jetons d'habilitation : le format, et lui seul.

**Deux artefacts distincts, et c'est toute la conception de ce module.**

    inscription  ->  MESH-4K7P-9QR2-XW3N     la CLÉ : courte, lisible, révocable
                            |
                            v  une fois, /licence/activer
                     v1.<charge>.<signature>  le JETON : signé, à durée courte
                            |
                            v  à chaque jointure
                     serveur de signalisation vérifie la SIGNATURE, rien d'autre

La clé est ce qu'un humain reçoit, note sur un bout de papier et retape. Elle doit
donc être courte et lisible, et elle vit en base côté service de licence — c'est ce
qui permet de la révoquer, de l'étendre ou d'en changer le palier.

Le jeton est ce que la machine présente ensuite. Le serveur de signalisation n'a
besoin que de la **clé publique** pour le vérifier : aucune requête en base, aucun
couplage. Trois conséquences qui justifient à elles seules cette séparation :

  - le service de licence peut redémarrer, planter ou être mis à jour sans
    déconnecter personne — la panne d'un composant de vente ne doit pas couper le
    réseau de quelqu'un qui joue ;
  - la révocation reste effective, avec au pire la durée du jeton de latence ;
  - c'est ce qui rendra un fournisseur d'identité tiers (Steam) enfichable sans
    rien changer ici : son ticket entrerait par le même `/licence/activer` et
    ressortirait sous la forme du même jeton.

**Ce module ne fait AUCUNE entrée-sortie.** Ni fichier, ni base, ni réseau, ni
horloge : `maintenant` est toujours un paramètre. C'est ce qui rend le gel du
format testable exhaustivement, et le format est précisément la seule décision de
cette phase qu'on ne pourra plus reprendre — les gens auront leur clé notée.
"""

from __future__ import annotations

import base64
import hashlib
import json
import re
import secrets
from dataclasses import dataclass
from enum import Enum

from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey,
    Ed25519PublicKey,
)

# --------------------------------------------------------------------------- #
# La clé
# --------------------------------------------------------------------------- #

# Alphabet sans caractères confondables (ni i/l/1, ni o/0).
#
# **Recopié volontairement de client/network_defaults.py**, où le même raisonnement
# est déjà écrit pour les mots de passe de réseau : la valeur est lue à l'écran et
# parfois dictée, et un `l` pris pour un `1` produirait un refus incompréhensible.
# Les deux arbres ne peuvent pas partager de module (imports à plat, `config.py`
# homonymes), d'où la duplication — verrouillée par un test qui lit l'autre fichier,
# même idiome que test_version.py. Sans lui les deux alphabets divergeraient en
# silence, et la tolérance à la faute de lecture disparaîtrait d'un côté seulement.
_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"

# Affichée en MAJUSCULES : une clé de licence se lit comme un numéro de série, et
# l'alphabet ci-dessus exclut déjà i/l/o, donc la casse haute n'introduit aucune
# nouvelle confusion. La saisie, elle, est acceptée dans n'importe quelle casse.
_ALPHABET_AFFICHE = _ALPHABET.upper()

# Préfixe littéral, pour que la clé soit reconnaissable quand elle traîne dans une
# conversation ou un courriel — même motif que le `MESHA1-` des codes d'invitation.
# Il est aussi ce qui rend la normalisation NON AMBIGUË : l'alphabet contient M, E,
# S et H, donc on ne peut pas retirer le préfixe caractère par caractère. On retire
# tout ce qui n'est pas alphanumérique, puis on retranche exactement ces 4 lettres.
_PREFIXE = "MESH"

# 3 groupes de 4 sur 31 symboles = ~59 bits. Très au-delà du nécessaire pour un
# parc de 100 clés ; le but n'est pas de résister à une attaque hors ligne mais
# qu'une clé ne se devine pas, et que le porteur reste court à retaper.
_GROUPES = 3
_TAILLE_GROUPE = 4
_TAILLE_CORPS = _GROUPES * _TAILLE_GROUPE


def generer_cle() -> str:
    """Clé neuve sous sa forme canonique, ex. `MESH-4K7P-9QR2-XW3N`."""
    groupes = [
        "".join(secrets.choice(_ALPHABET_AFFICHE) for _ in range(_TAILLE_GROUPE))
        for _ in range(_GROUPES)
    ]
    return "-".join([_PREFIXE, *groupes])


def normaliser_cle(saisie: str) -> str | None:
    """
    Forme canonique d'une clé saisie à la main, ou `None` si elle est invalide.

    **Délibérément tolérante au collage humain**, comme le décodage des codes
    d'invitation (point 52) : une clé recopiée depuis une conversation arrive avec
    des espaces, des espaces insécables, une coupure de ligne, une casse
    quelconque, ou sans ses tirets. Refuser ces formes produirait un échec que la
    personne n'aurait aucun moyen de comprendre — elle voit bien que sa clé est
    « la bonne ».

    **Le préfixe est en revanche OBLIGATOIRE, et cette sévérité-là est un choix.**
    Accepter aussi un corps nu paraissait plus accueillant ; c'est un piège, et un
    test l'a montré avant la première émission. L'alphabet contient M, E, S et H,
    donc une clé amputée d'un groupe — `MESH-4K7P-9QR2` — fait exactement la taille
    d'un corps nu une fois nettoyée : elle était réinterprétée comme tel et
    produisait `MESH-MESH-4K7P-9QR2`, une clé DIFFÉRENTE et valide en apparence. La
    personne aurait lu « clé inconnue » sans jamais soupçonner la troncature.
    Exiger le préfixe rend la lecture non ambiguë et fait nommer la faute au bon
    endroit — une clé incomplète est refusée comme telle, jamais transformée en une
    autre. Le coût est nul : la clé est toujours affichée avec son préfixe.
    """
    if not saisie:
        return None

    # On retire tout ce qui n'appartient pas à l'alphabet AVANT de raisonner sur la
    # longueur : espaces, tirets mal placés, insécables et retours à la ligne
    # disparaissent d'un coup, sans avoir à les énumérer.
    brut = re.sub(r"[^A-Z0-9]", "", saisie.upper())

    if not brut.startswith(_PREFIXE) or len(brut) != len(_PREFIXE) + _TAILLE_CORPS:
        return None
    corps = brut[len(_PREFIXE) :]

    if any(caractere not in _ALPHABET_AFFICHE for caractere in corps):
        return None

    groupes = [
        corps[debut : debut + _TAILLE_GROUPE]
        for debut in range(0, _TAILLE_CORPS, _TAILLE_GROUPE)
    ]
    return "-".join([_PREFIXE, *groupes])


def empreinte_cle(cle_canonique: str) -> str:
    """
    Empreinte à stocker en base, à la place de la clé elle-même.

    Une clé est un porteur d'autorisation : si la base fuit, des clés en clair
    fuient avec elle. On stocke donc l'empreinte, et la clé n'est affichée qu'une
    fois, à l'inscription — perdue, elle se révoque et se réémet.

    **SHA-256 nu suffit ici, contrairement à un mot de passe humain**, et ce n'est
    pas un raccourci : la clé vient d'un générateur cryptographique et porte ~59
    bits, donc il n'existe ni dictionnaire ni motif à exploiter. Une fonction lente
    ne protégerait de rien et rendrait la vérification coûteuse.

    Attend une clé DÉJÀ canonique : hacher la saisie brute ferait dépendre
    l'empreinte de la casse et des espaces, donc la même clé ne se retrouverait pas.
    """
    return hashlib.sha256(cle_canonique.encode("ascii")).hexdigest()


# --------------------------------------------------------------------------- #
# Le jeton
# --------------------------------------------------------------------------- #

# Le préfixe de version tient lieu d'indication d'algorithme, et c'est DÉLIBÉRÉ :
# `v1` signifie Ed25519, point. Un champ « algorithme » transporté dans la charge —
# ce que fait JWT — laisserait l'émetteur du jeton influencer la façon dont il est
# vérifié, qui est la faille classique de ce format (`alg: none`). Ici le
# vérificateur choisit seul, et un jeton d'une autre version est refusé sans être
# examiné.
_VERSION_JETON = "v1"


def serialiser_paire(cle_privee: Ed25519PrivateKey) -> tuple[bytes, bytes]:
    """
    Paire de signature au format PEM : `(privée PKCS#8, publique SubjectPublicKeyInfo)`.

    Deux fichiers séparés, et c'est la condition du découplage : la privée reste en
    0600 chez le service de licence, la publique est lisible par le serveur de
    signalisation, qui n'a besoin que d'elle pour vérifier. Un `ProtectSystem=strict`
    ne restreint que l'ÉCRITURE, donc la lecture croisée entre les deux comptes de
    service ne demande aucun droit supplémentaire.

    PEM plutôt que 32 octets nus : le format se décrit lui-même, donc un fichier
    tronqué ou interverti échoue au chargement au lieu de produire une clé silencieuse.
    """
    return (
        cle_privee.private_bytes(
            encoding=serialization.Encoding.PEM,
            format=serialization.PrivateFormat.PKCS8,
            encryption_algorithm=serialization.NoEncryption(),
        ),
        cle_privee.public_key().public_bytes(
            encoding=serialization.Encoding.PEM,
            format=serialization.PublicFormat.SubjectPublicKeyInfo,
        ),
    )


def charger_cle_privee(pem: bytes) -> Ed25519PrivateKey:
    """Lève si le PEM n'est pas une clé privée Ed25519 — jamais de repli silencieux."""
    cle = serialization.load_pem_private_key(pem, password=None)
    if not isinstance(cle, Ed25519PrivateKey):
        raise ValueError("la clé de signature n'est pas une clé privée Ed25519")
    return cle


def charger_cle_publique(pem: bytes) -> Ed25519PublicKey:
    cle = serialization.load_pem_public_key(pem)
    if not isinstance(cle, Ed25519PublicKey):
        raise ValueError("la clé de vérification n'est pas une clé publique Ed25519")
    return cle


class MotifDeRefus(str, Enum):
    """
    Pourquoi un jeton est refusé — jamais un simple `None`.

    Le produit se distingue en nommant ses pannes ; un refus muet ici remonterait
    jusqu'à l'écran sous la forme « connexion impossible », et le point 62 a déjà
    montré ce que coûte un diagnostic qui accuse le réseau à la place de la vraie
    cause. Chaque motif appelle une conduite différente côté client : réactiver,
    ressaisir la clé, ou ne surtout pas insister.
    """

    FORME = "forme"          # ce n'est pas un jeton v1 — corruption, troncature
    CHARGE = "charge"        # enveloppe correcte, contenu illisible
    SIGNATURE = "signature"  # falsifié, ou émis par une autre autorité
    EXPIRE = "expire"        # authentique, mais périmé — il faut réactiver


@dataclass(frozen=True)
class Jeton:
    """Contenu vérifié d'un jeton. Ne porte JAMAIS la clé de licence elle-même."""

    licence_id: str
    palier: str
    emis_le: int
    expire_le: int


@dataclass(frozen=True)
class Verification:
    """Résultat d'une vérification : soit un jeton, soit un motif de refus."""

    jeton: Jeton | None
    motif: MotifDeRefus | None

    @property
    def valide(self) -> bool:
        # Les DEUX conditions, délibérément : un futur motif renvoyé avec un jeton
        # non nul — la tentation est réelle pour EXPIRE, qui « sait » de quelle
        # licence il parle — ferait silencieusement accepter un jeton refusé.
        return self.jeton is not None and self.motif is None


def _encoder(donnees: bytes) -> str:
    """Base64url sans remplissage — les `=` finaux sont rognés par les messageries."""
    return base64.urlsafe_b64encode(donnees).rstrip(b"=").decode("ascii")


def _decoder(texte: str) -> bytes:
    return base64.urlsafe_b64decode(texte + "=" * (-len(texte) % 4))


def emettre_jeton(
    cle_privee: Ed25519PrivateKey,
    *,
    licence_id: str,
    palier: str,
    maintenant: int,
    duree_secondes: int,
) -> str:
    """
    Jeton signé, de la forme `v1.<charge>.<signature>`.

    **Le jeton ne contient pas la clé de licence**, seulement son identifiant
    opaque : un jeton qui fuit ne doit pas livrer un porteur à longue durée de vie.

    `palier` est écrit dès maintenant alors que tout le monde est au même niveau —
    l'ajouter plus tard obligerait à réactiver tout le parc, alors qu'il ne coûte
    rien aujourd'hui. Même raisonnement pour `expire_le`.
    """
    charge = {
        "lic": licence_id,
        "palier": palier,
        "iat": int(maintenant),
        "exp": int(maintenant) + int(duree_secondes),
    }
    # `separators` et `sort_keys` rendent la sérialisation déterministe. Sans effet
    # sur la vérification — on signe la chaîne transmise, pas un JSON re-sérialisé,
    # précisément pour ne jamais dépendre d'une re-canonicalisation — mais ça rend
    # deux jetons de même contenu identiques, donc les tests lisibles.
    encodee = _encoder(
        json.dumps(charge, separators=(",", ":"), sort_keys=True).encode("utf-8")
    )
    signe = f"{_VERSION_JETON}.{encodee}"
    signature = cle_privee.sign(signe.encode("ascii"))
    return f"{signe}.{_encoder(signature)}"


def verifier_jeton(
    cle_publique: Ed25519PublicKey, jeton: str, *, maintenant: int
) -> Verification:
    """
    Vérifie signature puis échéance, dans cet ordre.

    **L'ordre n'est pas indifférent** : examiner l'échéance avant la signature
    reviendrait à faire confiance à une charge non authentifiée, donc à laisser
    n'importe qui obtenir un verdict `EXPIRE` — informatif — sur un jeton
    fabriqué. On ne lit le contenu qu'une fois qu'on sait d'où il vient.
    """
    morceaux = (jeton or "").strip().split(".")
    if len(morceaux) != 3 or morceaux[0] != _VERSION_JETON:
        return Verification(None, MotifDeRefus.FORME)

    _, encodee, signature = morceaux
    signe = f"{_VERSION_JETON}.{encodee}"
    try:
        cle_publique.verify(_decoder(signature), signe.encode("ascii"))
    except (InvalidSignature, ValueError, TypeError):
        # `ValueError` couvre un base64 illisible : il n'y a pas de signature à
        # examiner, donc c'est bien un refus de signature et non de forme.
        return Verification(None, MotifDeRefus.SIGNATURE)

    try:
        charge = json.loads(_decoder(encodee).decode("utf-8"))
        contenu = Jeton(
            licence_id=str(charge["lic"]),
            palier=str(charge["palier"]),
            emis_le=int(charge["iat"]),
            expire_le=int(charge["exp"]),
        )
    except (ValueError, TypeError, KeyError):
        return Verification(None, MotifDeRefus.CHARGE)

    if int(maintenant) >= contenu.expire_le:
        # Le contenu n'est PAS renvoyé, bien qu'il soit authentique : le client
        # connaît déjà sa propre licence puisqu'il détient la clé, il n'a donc rien
        # à y lire — et le renvoyer ouvrirait la porte au `if verif.jeton:` qui
        # accepterait un jeton périmé.
        return Verification(None, MotifDeRefus.EXPIRE)

    return Verification(contenu, None)
