"""Passwords: argon2id hashing and verification, the rules, the generated-password alphabet.

Sources of truth: this module, `tests/test_accounts_passwords.py`, the design
`.logs/planning/accounts-2026-09.md` §4.4. Parameters sit above the OWASP floor
(`time_cost=3, memory_cost=32 MiB, parallelism=1`: 50 to 90 ms on one vCPU; `accounts bench`
prints the cost on the actual host). A `Semaphore(2)` around hash and verify makes a login
flood queue instead of starving page serving in the 1 GB cgroup.

Every non-verifiable case (unknown username, an invited account with no hash, a disabled or
locked account) verifies against a fixed dummy hash so the timing is uniform: the response
sequence must never tell a username that exists from one that does not.
"""

from __future__ import annotations

import secrets
import threading
import unicodedata

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

TIME_COST = 3
MEMORY_COST = 32 * 1024  # KiB: 32 MiB
PARALLELISM = 1

MIN_LENGTH = 12
MAX_LENGTH = 256

# The kit's unambiguous alphabet (no 0/O, no 1/l/I): safe to read off a screen and retype.
GENERATED_ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
GENERATED_LENGTH = 16

_hasher = PasswordHasher(time_cost=TIME_COST, memory_cost=MEMORY_COST, parallelism=PARALLELISM)
_gate = threading.Semaphore(2)
# Computed once at import from a random value nobody knows: verifying against it always
# fails, at the same cost as a real verify.
_DUMMY_HASH = _hasher.hash(secrets.token_urlsafe(24))


class PasswordRuleError(ValueError):
    """The password breaks a rule; `.code` is stable for the API and the CLI."""

    def __init__(self, code: str, message: str) -> None:
        super().__init__(message)
        self.code = code


def normalise(password: str) -> str:
    return unicodedata.normalize("NFC", password or "")


def check_rules(password: str, *, username: str = "", email: str = "") -> str:
    """Apply the rules and return the normalised password, or raise PasswordRuleError.
    12 to 256 characters, no composition rules, refused when it contains the username or
    the email's local part (case-insensitively)."""
    value = normalise(password)
    if len(value) < MIN_LENGTH:
        raise PasswordRuleError("PASSWORD_TOO_SHORT", f"Use at least {MIN_LENGTH} characters.")
    if len(value) > MAX_LENGTH:
        raise PasswordRuleError("PASSWORD_TOO_LONG", f"Use at most {MAX_LENGTH} characters.")
    lowered = value.lower()
    parts = [(username or "").strip().lower(), (email or "").split("@", 1)[0].strip().lower()]
    for part in parts:
        if len(part) >= 3 and part in lowered:
            raise PasswordRuleError(
                "PASSWORD_CONTAINS_IDENTITY", "Choose a password that is not your username or email."
            )
    return value


def hash_password(password: str) -> str:
    with _gate:
        return _hasher.hash(normalise(password))


def verify_password(stored_hash: str | None, offered: str) -> bool:
    """True only for a real hash that matches. A missing hash verifies against the dummy so
    the branch costs the same; a malformed stored hash is a mismatch, never an exception."""
    target = stored_hash or _DUMMY_HASH
    with _gate:
        try:
            ok = _hasher.verify(target, normalise(offered))
        except (VerifyMismatchError, InvalidHashError):
            return False
        except Exception:  # noqa: BLE001 - any other argon2 failure is a refusal
            return False
    return bool(ok) and stored_hash is not None


def dummy_verify(offered: str) -> None:
    """Spend the cost of a verify without a credential: the uniform-timing branch."""
    with _gate:
        try:
            _hasher.verify(_DUMMY_HASH, normalise(offered))
        except Exception:  # noqa: BLE001
            pass


def needs_rehash(stored_hash: str) -> bool:
    try:
        return _hasher.check_needs_rehash(stored_hash)
    except Exception:  # noqa: BLE001
        return False


def generate_password() -> str:
    """16 characters of the unambiguous alphabet, grouped xxxx-xxxx-xxxx-xxxx (about 92 bits)."""
    chars = [secrets.choice(GENERATED_ALPHABET) for _ in range(GENERATED_LENGTH)]
    return "-".join("".join(chars[i:i + 4]) for i in range(0, GENERATED_LENGTH, 4))


def bench(rounds: int = 3) -> float:
    """Milliseconds per verify on this host, for `accounts bench`."""
    import time

    sample = hash_password("bench-" + secrets.token_hex(4))
    start = time.perf_counter()
    for _ in range(rounds):
        verify_password(sample, "wrong-" + secrets.token_hex(4))
    return (time.perf_counter() - start) / rounds * 1000
