"""
Service de licence : inscription, activation, état des places.

**Processus SÉPARÉ du serveur de signalisation.** Le point d'entrée d'inscription
est public et non authentifié ; le faire tourner dans le processus qui détient les
réseaux et les pairs mettrait une surface de vente au contact d'une base qui n'a
rien à voir. Publié par Caddy sous `/licence/*`, écoute sur la boucle locale
uniquement — même montage que la signalisation (point 42).

**Aucun point d'entrée d'administration n'est exposé**, et c'est un choix : révoquer
ou prolonger passe par `python -m licence_db`, sur le VPS en SSH. Une surface
d'administration demanderait une authentification, donc un mécanisme de plus à ne
pas rater pour un parc de 100 clés.
"""

from __future__ import annotations

import json
import logging
import os
import time

from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, Field

import client_ip
import licence_common
import licence_db
import version
from rate_limit import FailureLimiter

# **Indispensable, et l'oubli a été mesuré.** Sans cette ligne, le logger racine reste
# à WARNING : uvicorn configure SES loggers, jamais le nôtre, donc tous les
# `logger.info` ci-dessous sont jetés en silence. Constaté en exécutant réellement le
# service — le journal ne contenait que les lignes d'uvicorn, et la trace censée
# révéler une régénération de la clé de signature n'aurait jamais paru en production.
# Même famille que le point 74i (renouvellement UPnP en DEBUG, mécanisme invérifiable).
# Format aligné sur app.py, qui porte la même ligne pour la même raison.
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("meshamoto.licence")

app = FastAPI(title="Meshamoto — licences")

# Inscription : fenêtre glissante par adresse, sur TOUTES les tentatives et pas
# seulement les échecs — contrairement à l'usage de ce compteur côté signalisation.
# S'inscrire est un acte unique ; ce qu'on veut borner ici est le nombre de comptes
# créés depuis une même adresse, pas le nombre d'erreurs. 10 par heure laisse passer
# une famille entière derrière un seul NAT et arrête un script.
_limiteur_inscription = FailureLimiter(max_failures=10, window_seconds=3600.0)

# Activation : seuls les ÉCHECS comptent, sémantique d'origine du compteur. Deviner
# une clé de ~59 bits est hors de portée, mais un client mal configuré qui boucle
# n'a aucune raison de marteler le service.
_limiteur_activation = FailureLimiter(max_failures=20, window_seconds=300.0)

_cle_privee = None


@app.on_event("startup")
def _demarrage() -> None:
    licence_db.init_db()
    global _cle_privee
    _cle_privee, creee = licence_db.charger_ou_creer_paire()

    # Le chemin ABSOLU et l'état (chargée / créée) sont journalisés à chaque
    # démarrage, et ce n'est pas du bavardage. Si la variable d'environnement manque
    # dans l'unité systemd, le service retombe sur le dossier du module et génère
    # silencieusement une NOUVELLE paire : tous les jetons déjà émis deviennent
    # invalides sans qu'aucune erreur n'apparaisse. Même piège que la base SQLite au
    # point 45 et le canal de trafic du relais au point 86, tous deux payés une fois.
    logger.info(
        "Clé de signature %s : %s",
        "CRÉÉE" if creee else "chargée",
        os.path.abspath(licence_db.CLE_PRIVEE_PATH),
    )
    logger.info("Base des licences : %s", os.path.abspath(licence_db.DB_PATH))
    logger.info(
        "Plafond %d clés, %d place(s) restante(s)",
        licence_db.PLAFOND_CLES,
        licence_db.places_restantes(),
    )


class DemandeInscription(BaseModel):
    nom: str = Field(min_length=1, max_length=120)
    courriel: str = Field(min_length=5, max_length=254)
    # Réponses libres du formulaire (quels jeux, combien d'amis, déjà utilisé
    # Hamachi…). C'est l'instrument de mesure de la phase, pas une barrière : tout
    # est facultatif, et rien ici ne conditionne l'émission de la clé.
    reponses: dict[str, str] = Field(default_factory=dict)


class DemandeActivation(BaseModel):
    cle: str = Field(min_length=1, max_length=64)


def _refuser(source: str, limiteur: FailureLimiter) -> None:
    if limiteur.is_blocked(source):
        raise HTTPException(
            status_code=429,
            detail="Trop de tentatives. Réessayez plus tard.",
            headers={"Retry-After": str(limiteur.retry_after_seconds(source))},
        )


@app.get("/licence/etat")
def etat() -> dict:
    """Lu par la page publique pour afficher « il reste N places »."""
    return {
        "places_restantes": licence_db.places_restantes(),
        "plafond": licence_db.PLAFOND_CLES,
        "duree_jours": licence_db.DUREE_CLE_SECONDES // 86400,
        "version": version.version_affichee(),
    }


@app.post("/licence/inscription", status_code=201)
def inscription(req: DemandeInscription, request: Request) -> dict:
    """
    Crée une licence et renvoie sa clé — **la seule fois où elle existe en clair**.

    Elle n'est pas expédiée par courriel dans cette phase : un VPS qui envoie du mail
    sans réputation SPF/DKIM atterrit en indésirable, et la clé serait perdue sans
    que personne ne comprenne pourquoi. Elle est affichée, et c'est à l'appelant de
    la conserver.
    """
    source = client_ip.cle_source(request)
    _refuser(source, _limiteur_inscription)
    _limiteur_inscription.record_failure(source)  # compte TOUTES les tentatives

    reponses = json.dumps(req.reponses, ensure_ascii=False) if req.reponses else None
    resultat = licence_db.inscrire(req.nom, req.courriel, reponses)

    if resultat is licence_db.RefusInscription.COURRIEL:
        raise HTTPException(status_code=422, detail="Cette adresse ne semble pas valide.")
    if resultat is licence_db.RefusInscription.DEJA_INSCRIT:
        # On ne renvoie jamais la clé existante : l'adresse n'est pas vérifiée, donc
        # ce serait un moyen de récupérer la clé d'un tiers.
        raise HTTPException(
            status_code=409,
            detail="Une clé a déjà été émise pour cette adresse. "
            "Si vous l'avez perdue, écrivez-nous et nous vous en émettrons une autre.",
        )
    if resultat is licence_db.RefusInscription.PLAFOND:
        raise HTTPException(
            status_code=409,
            detail=f"Les {licence_db.PLAFOND_CLES} places de la phase d'essai sont "
            "prises. Meshamoto reste utilisable gratuitement en hébergeant "
            "vous-même votre serveur.",
        )

    # Le courriel n'est JAMAIS journalisé — même règle que le jeton DuckDNS (point 44),
    # où un fichier de configuration sourcé l'avait recraché dans le journal système.
    logger.info(
        "Licence %s émise, %d place(s) restante(s)",
        resultat.licence_id,
        licence_db.places_restantes(),
    )
    return {
        "cle": resultat.cle,
        "expire_le": resultat.expire_le,
        "places_restantes": licence_db.places_restantes(),
    }


@app.post("/licence/activer")
def activer(req: DemandeActivation, request: Request) -> dict:
    """
    Échange une clé contre un jeton signé, à durée courte.

    C'est le seul endroit où la clé circule après l'inscription. Le jeton qui en sort
    ne la contient pas et se périme vite : c'est ce qui borne la latence d'une
    révocation sans rendre le service de licence indispensable au quotidien.
    """
    source = client_ip.cle_source(request)
    _refuser(source, _limiteur_activation)

    resultat = licence_db.activer(req.cle)

    if isinstance(resultat, licence_db.RefusActivation):
        _limiteur_activation.record_failure(source)
        # Quatre motifs, quatre messages : « mal recopiée » et « inexistante » ne
        # doivent pas se confondre, sans quoi quelqu'un qui a oublié un groupe de
        # caractères réclamerait une réémission dont il n'a pas besoin.
        messages = {
            licence_db.RefusActivation.FORME: (
                422,
                "Cette clé est incomplète ou mal recopiée. Elle a la forme "
                "MESH-XXXX-XXXX-XXXX.",
            ),
            # 403 et non 404, alors que « cette clé n'existe pas » appellerait
            # naturellement un 404 : ce code doit rester RÉSERVÉ à « il n'y a pas de
            # service de licence à cette adresse ». Sans cette réservation, un client
            # branché sur un serveur auto-hébergé — donc sans service de licence —
            # reçoit le 404 de FastAPI et affiche « Not Found », indiscernable d'une
            # clé inconnue. Mesuré en exécutant la chaîne réelle. Le coût est nul :
            # tous ces refus sont définitifs pour le client, seul le MESSAGE change,
            # et il reste distinct.
            licence_db.RefusActivation.INCONNUE: (
                403,
                "Cette clé n'existe pas. Vérifiez la saisie, ou inscrivez-vous.",
            ),
            licence_db.RefusActivation.REVOQUEE: (
                403,
                "Cette clé a été révoquée. Écrivez-nous si c'est une erreur.",
            ),
            licence_db.RefusActivation.EXPIREE: (
                403,
                "Cette clé est arrivée à échéance. Écrivez-nous pour la prolonger — "
                "Meshamoto reste utilisable en hébergeant vous-même votre serveur.",
            ),
        }
        code, message = messages[resultat]
        raise HTTPException(status_code=code, detail=message)

    _limiteur_activation.record_success(source)
    maintenant = int(time.time())
    # Durée choisie par le PALIER — 30 jours pour une licence permanente, 7 sinon.
    # Le compromis diffère : sur une clé interne la latence de révocation n'a aucune
    # importance, alors que la friction en a (une machine éteinte deux semaines doit
    # repartir sans dépendre du service de licence).
    duree = licence_db.duree_jeton(resultat.palier)
    if resultat.expire_le is not None:
        # Le jeton ne dépasse jamais l'échéance de la clé : sans ce plafond, une clé
        # expirant demain donnerait un jeton valable une semaine, donc une habilitation
        # qui survit à la licence qui la fonde. Sans échéance, il n'y a rien à plafonner
        # — et `expire_le - maintenant` lèverait sur None.
        duree = min(duree, max(0, resultat.expire_le - maintenant))
    jeton = licence_common.emettre_jeton(
        _cle_privee,
        licence_id=resultat.licence_id,
        palier=resultat.palier,
        maintenant=maintenant,
        duree_secondes=duree,
    )
    return {
        "jeton": jeton,
        "palier": resultat.palier,
        "expire_le": maintenant + duree,
        "licence_expire_le": resultat.expire_le,
    }
