"""Password hashing, quality rules and temporary-password issuance (design D13, D14).

This module holds the only credential the system can be brute-forced against. Everything
else an attacker faces is either a 128-bit token or a Google assertion; a password is a short
string a person had to remember, and the defence is Argon2id here plus the lockout in
`sign_in_with_password`.

Nothing in this module logs, returns or raises a password. `verify` takes one and gives back
a boolean; `generate_temporary` is the only function that produces one, and its caller shows
it to the issuing super admin exactly once and never stores it.
"""

import logging
import secrets
import string
import unicodedata
from datetime import UTC, datetime, timedelta

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

logger = logging.getLogger(__name__)

__all__ = [
    "PasswordRejectedError",
    "generate_temporary",
    "hash_password",
    "needs_rehash",
    "temporary_expiry",
    "validate_quality",
    "verify",
]

#: Library defaults, which track current guidance. Deliberately not tuned here: the right
#: cost is measured on the deployment host, and raising it later is free because
#: `needs_rehash` upgrades each account on its next successful sign-in.
_hasher = PasswordHasher()

#: Omits look-alikes for the same reason the guest short codes do: a temporary password is
#: read off a screen and typed somewhere else, often by a third person over a phone.
_TEMP_ALPHABET = "".join(c for c in string.ascii_letters + string.digits if c not in "0O1lI")
_TEMP_LENGTH = 16

#: Not a password list — a check for the handful of shapes that survive a length rule.
#: Real deny-list enforcement would need a corpus this project has no reason to ship.
_OBVIOUSLY_WEAK = frozenset(
    {
        "password",
        "passw0rd",
        "administrator",
        "letmein",
        "welcome",
        "changeme",
        "qwerty",
        "iloveyou",
    }
)


class PasswordRejectedError(ValueError):
    """A proposed password failed the quality rules. The message is shown to the user."""


def _normalise(raw: str) -> str:
    """NFKC, so a password typed on a phone keyboard matches the one typed on a laptop.

    Without it, visually identical strings composed differently hash differently and the
    user is told their correct password is wrong — with no way to tell why.
    """
    return unicodedata.normalize("NFKC", raw)


def validate_quality(raw: str, *, min_length: int) -> str:
    """Return the normalised password, or raise `PasswordRejectedError` saying what is wrong.

    Length is the rule that matters. Character-class requirements push people towards
    `P@ssw0rd1`, which satisfies every one of them and is on every wordlist, so there are
    none here beyond refusing the shapes that a length rule alone still lets through.
    """
    password = _normalise(raw)

    if len(password) < min_length:
        raise PasswordRejectedError(f"Password must be at least {min_length} characters.")
    if password != password.strip():
        raise PasswordRejectedError("Password must not begin or end with a space.")
    if len(set(password)) < 4:
        raise PasswordRejectedError("Password must not be a repetition of one or two characters.")

    lowered = password.lower()
    if lowered in _OBVIOUSLY_WEAK or any(w in lowered for w in _OBVIOUSLY_WEAK):
        raise PasswordRejectedError("Password is too easy to guess. Choose something else.")

    return password


def hash_password(raw: str) -> str:
    return _hasher.hash(_normalise(raw))


def verify(stored_hash: str | None, raw: str) -> bool:
    """Constant-time-ish verification that never raises on bad input.

    A null hash means a Google account, which has no password to be right. Returning False
    rather than raising keeps the sign-in path's timing and its response identical for
    "wrong password", "no password on this account", and "no such account" — the three
    things an attacker would most like to tell apart.
    """
    if not stored_hash:
        return False
    try:
        return _hasher.verify(stored_hash, _normalise(raw))
    except VerificationError:
        return False
    except InvalidHashError:
        # A corrupt or truncated hash. Refuse the sign-in rather than crash the endpoint,
        # and say so loudly — this is a data problem, not a user error.
        logger.error("stored password hash is unreadable; refusing sign-in")
        return False


def needs_rehash(stored_hash: str) -> bool:
    """True when the hash predates the current cost parameters."""
    try:
        return _hasher.check_needs_rehash(stored_hash)
    except InvalidHashError:
        return False


def generate_temporary() -> str:
    """A fresh temporary password. Shown to its issuer once and never persisted in clear."""
    return "".join(secrets.choice(_TEMP_ALPHABET) for _ in range(_TEMP_LENGTH))


def temporary_expiry(*, ttl_hours: int) -> datetime:
    return datetime.now(UTC) + timedelta(hours=ttl_hours)
