"""
Limitation des tentatives d'authentification échouées, par adresse source.

Nécessaire dès que le serveur est joignable depuis Internet — que ce soit un VPS ou une
machine personnelle derrière une redirection de port. Le mot de passe d'un réseau est
stocké haché (PBKDF2-HMAC-SHA256, 200 000 itérations), ce qui protège la base en cas de
fuite, mais **rien n'empêchait de marteler `/networks/join` pour deviner un mot de
passe** — le hachage ralentit l'attaquant hors ligne, pas en ligne.

Fenêtre glissante en mémoire, sans dépendance ni stockage : un redémarrage du serveur
remet les compteurs à zéro, ce qui est acceptable pour l'usage visé (quelques amis) et
évite d'introduire Redis ou une table de plus pour ça.

Seuls les échecs comptent. Un utilisateur légitime qui rejoint dix fois de suite son
réseau n'est jamais gêné ; seule une succession d'échecs déclenche le blocage.

Logique pure, testable sans serveur ni réseau.
"""

from __future__ import annotations

import threading
import time
from collections import deque

# Dix échecs en cinq minutes : large pour une faute de frappe répétée, dérisoire pour
# une attaque par force brute (un mot de passe de huit caractères demanderait des
# siècles à ce rythme).
DEFAULT_MAX_FAILURES = 10
DEFAULT_WINDOW_SECONDS = 300.0

# Au-delà, les compteurs des adresses inactives sont purgés : sans ça, un attaquant
# qui varie ses adresses sources ferait grossir le dictionnaire indéfiniment.
_CLEANUP_EVERY = 256


class FailureLimiter:
    """Compte les échecs récents par clé (adresse IP) sur une fenêtre glissante."""

    def __init__(
        self,
        max_failures: int = DEFAULT_MAX_FAILURES,
        window_seconds: float = DEFAULT_WINDOW_SECONDS,
    ) -> None:
        self.max_failures = max_failures
        self.window_seconds = window_seconds
        self._failures: dict[str, deque] = {}
        self._lock = threading.Lock()
        self._since_cleanup = 0

    def _prune(self, key: str, now: float) -> deque:
        entries = self._failures.setdefault(key, deque())
        cutoff = now - self.window_seconds
        while entries and entries[0] < cutoff:
            entries.popleft()
        return entries

    def is_blocked(self, key: str, now: float | None = None) -> bool:
        """Vrai si cette adresse a dépassé le quota d'échecs sur la fenêtre."""
        now = time.monotonic() if now is None else now
        with self._lock:
            return len(self._prune(key, now)) >= self.max_failures

    def record_failure(self, key: str, now: float | None = None) -> None:
        now = time.monotonic() if now is None else now
        with self._lock:
            self._prune(key, now).append(now)
            self._since_cleanup += 1
            if self._since_cleanup >= _CLEANUP_EVERY:
                self._cleanup(now)
                self._since_cleanup = 0

    def record_success(self, key: str) -> None:
        """Une authentification réussie efface l'ardoise : un utilisateur qui finit par
        retrouver son mot de passe ne doit pas rester pénalisé."""
        with self._lock:
            self._failures.pop(key, None)

    def retry_after_seconds(self, key: str, now: float | None = None) -> int:
        """Secondes avant que cette adresse retrouve le droit d'essayer."""
        now = time.monotonic() if now is None else now
        with self._lock:
            entries = self._prune(key, now)
            if len(entries) < self.max_failures:
                return 0
            return max(1, int(entries[0] + self.window_seconds - now) + 1)

    def _cleanup(self, now: float) -> None:
        """Purge les clés dont tous les échecs sont sortis de la fenêtre. Appelé sous
        le verrou, depuis record_failure."""
        cutoff = now - self.window_seconds
        vides = [k for k, v in self._failures.items() if not v or v[-1] < cutoff]
        for key in vides:
            del self._failures[key]
