"""Passwort-Hashing: Argon2id, mit Uebergangspfad von bcrypt.

Der Vorgaenger nutzte bcrypt (via Flask-Bcrypt). bcrypt ist nicht kaputt, aber
Argon2id ist der aktuelle Stand (speicherhart, damit deutlich teurer auf GPUs)
und die Empfehlung des OWASP-Passwort-Cheatsheets.

Bestehende bcrypt-Hashes werden weiter akzeptiert und beim naechsten
erfolgreichen Login transparent auf Argon2id migriert -- ein Import einer alten
Datenbank sperrt also niemanden aus.
"""

from __future__ import annotations

import hmac
import re
import secrets
import unicodedata
from dataclasses import dataclass

from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError

# Parameter nach OWASP-Empfehlung (m=64 MiB, t=3, p=2). Bei ~50 ms pro Hash auf
# der Ziel-VM bleibt der Login fluessig und Offline-Angriffe teuer.
_HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2, hash_len=32, salt_len=16)

_BCRYPT_RE = re.compile(r"^\$2[aby]\$\d{2}\$")

#: Passwoerter, die selbst mit 12 Zeichen nichts wert sind. Kurze, kuratierte
#: Liste -- eine vollstaendige Breach-Datenbank gehoert nicht in dieses Repo.
_FORBIDDEN = {
    "passwort1234",
    "password1234",
    "administrator",
    "qwertzuiop12",
    "123456789012",
    "tesmtesmtesm",
    "willkommen12",
    "geheim123456",
}


@dataclass(frozen=True, slots=True)
class PasswordPolicy:
    min_length: int = 12
    require_classes: int = 2
    """Anzahl verschiedener Zeichenklassen (klein/gross/Ziffer/Sonderzeichen)."""

    def check(self, password: str, *, username: str = "") -> list[str]:
        """Gibt eine Liste von Klartext-Problemen zurueck (leer = in Ordnung)."""
        problems: list[str] = []
        normalized = normalize(password)
        if len(normalized) < self.min_length:
            problems.append(f"Mindestens {self.min_length} Zeichen erforderlich.")
        classes = sum(
            bool(pattern.search(normalized))
            for pattern in (
                # Kleinbuchstaben inkl. Latin-1-Supplement (Umlaute, Akzente).
                re.compile("[a-zß-ÿ]"),
                re.compile("[A-ZÀ-Þ]"),
                re.compile(r"[0-9]"),
                re.compile("[^A-Za-z0-9À-ÿ]"),
            )
        )
        if classes < self.require_classes:
            problems.append(
                f"Mindestens {self.require_classes} verschiedene Zeichenarten "
                "(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
            )
        if normalized.lower() in _FORBIDDEN:
            problems.append("Dieses Passwort ist zu haeufig und damit unsicher.")
        if username and len(username) >= 3 and username.lower() in normalized.lower():
            problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
        if len(set(normalized)) <= 3 and normalized:
            problems.append("Das Passwort besteht aus zu wenigen verschiedenen Zeichen.")
        return problems


def normalize(password: str) -> str:
    """NFKC-Normalisierung.

    Ohne das schlaegt eine Anmeldung fehl, wenn ein Passwort mit Umlauten auf
    einem macOS-Client (NFD) gesetzt und auf Windows (NFC) eingegeben wird.
    """
    return unicodedata.normalize("NFKC", password or "")


def hash_password(password: str) -> str:
    return _HASHER.hash(normalize(password))


def is_legacy_hash(stored: str) -> bool:
    return bool(_BCRYPT_RE.match(stored or ""))


def verify_password(stored: str, password: str) -> bool:
    """Prueft ein Passwort gegen einen Argon2id- oder bcrypt-Hash.

    Laeuft bei leerem/kaputtem Hash bewusst in einen echten Argon2-Verify gegen
    einen Dummy-Hash, damit die Antwortzeit nicht verraet, ob ein Konto
    existiert (Timing-Orakel).
    """
    stored = stored or ""
    if is_legacy_hash(stored):
        return _verify_bcrypt(stored, password)
    try:
        return _HASHER.verify(stored, normalize(password))
    except (VerifyMismatchError, VerificationError, InvalidHashError, ValueError):
        return False


def _verify_bcrypt(stored: str, password: str) -> bool:
    try:
        import bcrypt  # lokaler Import: nur fuer Altbestaende noetig
    except ImportError:  # pragma: no cover
        return False
    try:
        return bcrypt.checkpw(normalize(password).encode("utf-8"), stored.encode("utf-8"))
    except (ValueError, TypeError):
        return False


def needs_rehash(stored: str) -> bool:
    """True, wenn der Hash veraltet ist (bcrypt oder zu schwache Argon2-Parameter)."""
    if is_legacy_hash(stored):
        return True
    try:
        return _HASHER.check_needs_rehash(stored)
    except (InvalidHashError, ValueError):
        return True


def dummy_verify() -> None:
    """Verbrennt dieselbe Rechenzeit wie ein echter Verify.

    Wird aufgerufen, wenn ein Benutzername nicht existiert -- so ist die
    Antwortzeit von "Konto existiert nicht" nicht von "falsches Passwort" zu
    unterscheiden.
    """
    global _DUMMY_HASH
    if not _DUMMY_HASH:
        _DUMMY_HASH = _HASHER.hash(secrets.token_urlsafe(24))
    try:
        _HASHER.verify(_DUMMY_HASH, "nicht-das-richtige-passwort")
    except (VerifyMismatchError, VerificationError, InvalidHashError):
        pass


_DUMMY_HASH = ""


def constant_time_equals(left: str, right: str) -> bool:
    return hmac.compare_digest((left or "").encode("utf-8"), (right or "").encode("utf-8"))
