"""
Activation d'une clé de licence auprès du service de licence.

Le client échange sa clé durable contre un **jeton** à durée courte, qu'il présente
ensuite au serveur de signalisation. Il ne vérifie jamais la signature de ce jeton :
il n'a pas la clé publique, et il n'en a pas besoin — c'est le serveur qui vérifie.
Ici le jeton est une chaîne opaque, à l'exception de son échéance, qu'on lit pour
savoir quand le renouveler.

**Renouvellement PROACTIF, et c'est ce qui rend le mécanisme invisible.** On réactive
bien avant l'échéance ; le chemin « jeton expiré refusé par le serveur » n'est qu'un
filet, pas le fonctionnement normal. Sans cette marge, chaque expiration produirait
une coupure visible suivie d'une reconnexion, exactement le genre de hoquet
inexpliqué que le produit existe pour supprimer.
"""

from __future__ import annotations

import base64
import json
import logging
import time
from dataclasses import dataclass

import requests

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

# On réactive dès qu'il reste moins d'un jour. Le service émet des jetons de sept
# jours : la marge laisse donc six occasions de réussir avant que l'échéance ne
# devienne un problème, ce qui couvre largement une panne passagère du service de
# licence ou une machine restée éteinte le week-end.
MARGE_RENOUVELLEMENT_SECONDES = 86400

TIMEOUT_SECONDES = 10


class LicenceRefuseeError(Exception):
    """
    La clé a été refusée, et **le motif est destiné à l'utilisateur**.

    `definitif` distingue « ça ne sert à rien de réessayer » (clé inconnue, révoquée,
    échue) de « le service est momentanément injoignable ». Sans cette distinction, un
    service de licence en maintenance ferait afficher « votre clé est invalide » à
    quelqu'un dont la clé est parfaitement bonne — précisément le genre de diagnostic
    qui envoie chercher au mauvais endroit (point 62).
    """

    def __init__(self, message: str, definitif: bool = True) -> None:
        super().__init__(message)
        self.message = message
        self.definitif = definitif


@dataclass(frozen=True)
class Activation:
    jeton: str
    expire_le: int
    # `None` = licence permanente, sans échéance. Le champ arrive alors à `null` dans
    # la réponse JSON, et un `int(None)` lèverait — défaut trouvé en relisant, pas par
    # un test : rien n'émettait encore de licence permanente au moment de l'écrire.
    licence_expire_le: int | None
    palier: str


def echeance_du_jeton(jeton: str) -> int | None:
    """
    Échéance inscrite dans un jeton, sans en vérifier la signature.

    **Lire sans vérifier est correct ICI et nulle part ailleurs** : ce jeton vient de
    notre propre cache, et la seule décision qu'on en tire est « faut-il réactiver ? ».
    Se tromper coûte une réactivation inutile. C'est le serveur qui vérifie la
    signature, et lui seul — le client n'a pas la clé publique.
    """
    try:
        segments = jeton.split(".")
        if len(segments) != 3:
            return None
        charge = segments[1]
        brut = base64.urlsafe_b64decode(charge + "=" * (-len(charge) % 4))
        return int(json.loads(brut)["exp"])
    except (ValueError, TypeError, KeyError, json.JSONDecodeError):
        return None


def palier_du_jeton(jeton: str) -> str | None:
    """
    Palier inscrit dans un jeton (`demo`, `permanent`…), sans vérifier la signature.

    Même précaution que `echeance_du_jeton` : le jeton vient de NOTRE cache, et ce
    qu'on en tire est purement informatif — la ligne affichée à l'utilisateur. Se
    tromper n'accorde aucun droit ; c'est le serveur qui vérifie, et lui seul.

    Existe parce que la seule confirmation qu'une clé fonctionne était une ligne de
    journal (« Habilitation renouvelée »), dans un fichier que personne n'ouvre :
    signalé le 2026-08-07 par quelqu'un qui venait de coller une clé parfaitement
    valide et n'avait aucun moyen de le savoir.
    """
    try:
        segments = jeton.split(".")
        if len(segments) != 3:
            return None
        charge = segments[1]
        brut = base64.urlsafe_b64decode(charge + "=" * (-len(charge) % 4))
        palier = json.loads(brut)["palier"]
        return palier if isinstance(palier, str) and palier else None
    except (ValueError, TypeError, KeyError, json.JSONDecodeError):
        return None


def doit_reactiver(
    jeton: str,
    maintenant: int | None = None,
    marge: int = MARGE_RENOUVELLEMENT_SECONDES,
) -> bool:
    """
    Faut-il redemander un jeton ?

    Vrai si le jeton est absent, illisible, ou trop proche de son échéance. **Un jeton
    illisible vaut absent** plutôt qu'erreur : un cache corrompu doit se réparer tout
    seul à la prochaine activation, pas bloquer la connexion.
    """
    if not jeton:
        return True
    echeance = echeance_du_jeton(jeton)
    if echeance is None:
        return True
    return int(time.time() if maintenant is None else maintenant) + marge >= echeance


def activer(base_url: str, cle: str, session: requests.Session | None = None) -> Activation:
    """
    Échange la clé contre un jeton auprès du service de licence.

    `base_url` est l'adresse du SERVEUR, la même que celle de la signalisation : le
    service de licence est publié sous `/licence/` par le même reverse proxy, donc sur
    le même nom et le même certificat. Un hôte séparé obligerait l'utilisateur à
    saisir deux adresses, et ferait échouer la substitution de résolution locale du
    point 51 sur l'une des deux.
    """
    appelant = session or requests
    url = f"{base_url.rstrip('/')}/licence/activer"
    try:
        reponse = appelant.post(url, json={"cle": cle}, timeout=TIMEOUT_SECONDES)
    except requests.RequestException as erreur:
        # NON définitif : le service est injoignable, la clé n'est pas en cause. Un
        # jeton encore valable en cache doit continuer de servir.
        raise LicenceRefuseeError(
            "Le service de licence est injoignable pour le moment.", definitif=False
        ) from erreur

    if reponse.status_code >= 500:
        raise LicenceRefuseeError(
            "Le service de licence rencontre un problème. Réessayez plus tard.",
            definitif=False,
        )
    if reponse.status_code == 429:
        raise LicenceRefuseeError(
            "Trop de tentatives d'activation. Réessayez dans quelques minutes.",
            definitif=False,
        )
    if reponse.status_code == 404:
        # **404 est RÉSERVÉ à « il n'y a pas de service de licence ici »**, jamais à
        # une clé inconnue (le service répond 403 pour ça, voir licence_app.py). Le
        # cas se produit quand une clé est saisie alors qu'on vise un serveur
        # auto-hébergé : sans ce message, l'utilisateur lit le « Not Found » brut de
        # FastAPI et n'a aucun moyen de comprendre. Trouvé en exécutant la chaîne
        # réelle, pas par un test.
        raise LicenceRefuseeError(
            "Ce serveur n'a pas de service de licence — il est probablement "
            "auto-hébergé. Laissez le champ « Clé de licence » vide : Meshamoto est "
            "alors gratuit et complet.",
            definitif=True,
        )
    if reponse.status_code >= 400:
        # Le service formule déjà des messages destinés à l'utilisateur et distingue
        # « mal recopiée » de « inexistante » — les réécrire ici ferait diverger deux
        # rédactions du même refus.
        raise LicenceRefuseeError(_detail(reponse), definitif=True)

    corps = reponse.json()
    return Activation(
        jeton=corps["jeton"],
        expire_le=int(corps["expire_le"]),
        licence_expire_le=_echeance_facultative(corps),
        palier=str(corps.get("palier", "")),
    )


def _echeance_facultative(corps: dict) -> int | None:
    """
    Échéance de la LICENCE, distincte de celle du jeton.

    Trois cas, et ils se distinguent : absente de la réponse (service antérieur aux
    licences permanentes) — on retombe sur celle du jeton ; explicitement `null` —
    licence permanente, et on le conserve ; sinon une date.
    """
    if "licence_expire_le" not in corps:
        return int(corps["expire_le"])
    valeur = corps["licence_expire_le"]
    return None if valeur is None else int(valeur)


def _detail(reponse) -> str:
    try:
        detail = reponse.json().get("detail")
    except ValueError:
        detail = None
    return str(detail) if detail else f"Clé refusée par le serveur (HTTP {reponse.status_code})."
