"""Vérifier la signature d'un webhook Tauraco (Standard Webhooks) et le recevoir.

Verify a Tauraco webhook signature (Standard Webhooks) and receive it.

Bibliothèque standard seule (Python 3.9+). Le contenu signé est
``"{webhook-id}.{webhook-timestamp}." + corps brut`` ; la signature vaut
``"v1," + base64(HMAC-SHA256(clé, contenu))``, la clé étant le secret ``whsec_…``
décodé de base64 (préfixe retiré). L'en-tête ``webhook-signature`` peut porter
plusieurs signatures séparées par des espaces. Un secret vide ou illisible est refusé :
une clé HMAC vide permettrait à n'importe qui de signer.

Vecteur de référence (identique dans la page « Guide des webhooks ») :
    secret            whsec_dGF1cmFjby1leGVtcGxlLWRlLXZlY3RldXItMjAyNiE=
    webhook-id        evt_exemple_0001
    webhook-timestamp 1793750400
    corps             {"id":"evt_exemple_0001","type":"webhook.test","data":{"message":"ok"}}
    signature         v1,z25573sJuRUxUk3JkjiZN3OW2KihQFtLvIGhZTHUnuM=

Lancer le récepteur de démonstration (port 8787) :
    TAURACO_WEBHOOK_SECRET='whsec_…' python3 verifier_webhook.py
"""
from __future__ import annotations

import base64
import binascii
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Mapping, Optional

TOLERANCE_SECONDS = 300


def secret_key(secret: str) -> Optional[bytes]:
    """Clé HMAC du secret ``whsec_…`` (base64, préfixe retiré) ; ``None`` si vide ou illisible."""
    if not isinstance(secret, str):
        return None
    try:
        key = base64.b64decode(secret.removeprefix("whsec_").encode("ascii"), validate=True)
    except (binascii.Error, UnicodeEncodeError, ValueError):
        return None
    return key or None  # clé vide : refusée


def verify_signature(secret: str, headers: Mapping[str, str], body: bytes, *,
                     tolerance: int = TOLERANCE_SECONDS, now: Optional[int] = None) -> bool:
    """Vrai si une signature ``v1`` de ``webhook-signature`` correspond et que l'horodatage est récent.

    ``body`` doit être le corps BRUT reçu (octets), jamais un JSON re-sérialisé.
    """
    lowered = {str(name).lower(): str(value) for name, value in headers.items()}
    msg_id = lowered.get("webhook-id")
    raw_timestamp = lowered.get("webhook-timestamp")
    raw_signatures = lowered.get("webhook-signature")
    if not msg_id or not raw_timestamp or not raw_signatures:
        return False
    try:
        timestamp = int(raw_timestamp.strip())
    except ValueError:
        return False
    current = int(time.time()) if now is None else now
    if abs(current - timestamp) > tolerance:
        return False  # rejeu d'un ancien message ou horloge déréglée
    key = secret_key(secret)
    if key is None:
        return False
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode("ascii")
    for candidate in raw_signatures.split():
        version, _, value = candidate.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return True
    return False


class AlreadySeen:
    """Dédoublonnage par ``webhook-id`` : la livraison est « au moins une fois ».

    En production, conserver les identifiants dans une base (contrainte d'unicité),
    pas en mémoire : un rejeu ou une reprise renvoie le même ``webhook-id``.
    """

    def __init__(self) -> None:
        self._ids: set[str] = set()

    def first_time(self, msg_id: str) -> bool:
        if msg_id in self._ids:
            return False
        self._ids.add(msg_id)
        return True


def handle_event(event: dict) -> None:
    """Traitement métier, idempotent (à remplacer)."""
    print(f"{event.get('type')} {event.get('id')} tenant={event.get('tenant_id')}")


def make_handler(secret: str, seen: AlreadySeen) -> type[BaseHTTPRequestHandler]:
    class Receiver(BaseHTTPRequestHandler):
        def do_POST(self) -> None:  # noqa: N802 (nom imposé par http.server)
            length = int(self.headers.get("Content-Length") or 0)
            body = self.rfile.read(length)
            headers = {name: value for name, value in self.headers.items()}
            if not verify_signature(secret, headers, body):
                self.send_response(401)
                self.end_headers()
                return
            # Répondre vite (2xx) ; traiter ensuite. Un doublon reçoit aussi 2xx.
            if seen.first_time(headers.get("webhook-id") or headers.get("Webhook-Id", "")):
                handle_event(json.loads(body))
            self.send_response(204)
            self.end_headers()

        def log_message(self, *_args) -> None:  # pas de corps ni d'en-tête dans les journaux
            return

    return Receiver


if __name__ == "__main__":
    webhook_secret = os.environ["TAURACO_WEBHOOK_SECRET"]  # aucune valeur par défaut
    if secret_key(webhook_secret) is None:
        raise SystemExit("TAURACO_WEBHOOK_SECRET vide ou illisible (whsec_ + base64 attendu)")
    HTTPServer(("127.0.0.1", 8787), make_handler(webhook_secret, AlreadySeen())).serve_forever()
