"""
PKCE helpers for the desktop authorization-code flow.

The desktop app never collects a password. It opens the system browser, the
user signs in there, and the browser hands the app back a one-time code over a
`lifey://` deep link which the app exchanges for tokens.

NO TOKEN EVER TRAVELS IN THE DEEP LINK. Any application on the machine can
register a URL scheme, and deep links are routinely written to shell histories
and OS logs. What crosses that boundary is a code that is worthless without the
verifier, which never leaves the app's main process. That is the entire reason
for PKCE, and it is not negotiable for a desktop client — unlike a web server,
it has nowhere to keep a client secret.
"""

import base64
import hashlib
import secrets

# The code is typed by hand in the paste-the-code fallback (§3.4), so the
# alphabet excludes the pairs that are indistinguishable in most UI fonts:
# 0/O, 1/I/L. 8 characters over 32 symbols is 40 bits, which is far more than
# a 60-second single-use credential needs.
CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'
CODE_LENGTH = 8

# Sixty seconds. The browser redirects straight into the app, so the only
# thing this has to cover is the OS handing the URL over — or a person reading
# eight characters off a page and typing them.
CODE_TTL_SECONDS = 60

# Rows older than this are deleted whenever a new code is minted. There is no
# Redis on this box and no cron, so cleanup-on-write is the honest answer.
CODE_SWEEP_SECONDS = 600


# The website's Google/Microsoft handoff code. Never typed by a human, so it
# is three times longer than the desktop one — 24 characters over the same
# alphabet is ~119 bits. It rides in a URL fragment for the length of one
# redirect, and the page trades it for tokens immediately.
HANDOFF_CODE_LENGTH = 24
HANDOFF_TTL_SECONDS = 60


# Email link tokens: confirm an address, reset a password. They travel in a
# URL a mail client renders, so they are URL-safe base64 rather than the
# typable alphabet above, and they are NOT uppercased when hashed.
#
# Two different lifetimes for two different risks. A confirmation link sits in
# an inbox and is worth a day. A reset link is a way into the account, so it
# is worth an hour and no more.
VERIFY_TTL_SECONDS = 24 * 3600
RESET_TTL_SECONDS = 3600


def generate_code():
    """Return a fresh authorization code. The plaintext is returned ONCE."""
    return ''.join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LENGTH))


def generate_link_token():
    """Return a fresh email link token. The plaintext is returned ONCE."""
    return secrets.token_urlsafe(32)


def hash_token(token):
    """
    SHA-256 of a link token, hex.

    Separate from `hash_code` because that one upper-cases before hashing —
    right for an eight-character code somebody types, wrong for a base64url
    token where `a` and `A` are different characters.
    """
    return hashlib.sha256(token.strip().encode()).hexdigest()


def generate_handoff_code():
    """Return a fresh OAuth handoff code. The plaintext is returned ONCE."""
    return ''.join(secrets.choice(CODE_ALPHABET) for _ in range(HANDOFF_CODE_LENGTH))


def hash_code(code):
    """
    SHA-256 of the code, hex.

    The code is a bearer credential for sixty seconds, so it is stored hashed:
    a leaked database read must not be a login. No salt and no KDF — the code
    is high-entropy random rather than a password, so there is nothing to
    dictionary-attack and a slow hash would only cost the exchange latency.
    """
    return hashlib.sha256(code.strip().upper().encode()).hexdigest()


def verify_challenge(code_verifier, code_challenge):
    """
    Check `base64url(sha256(verifier)) == challenge`, per RFC 7636 S256.

    `secrets.compare_digest` rather than `==`: both sides are attacker-visible
    in the failure case, and a timing oracle on a 60-second credential is
    cheap to remove.
    """
    if not code_verifier or not code_challenge:
        return False

    digest = hashlib.sha256(code_verifier.encode('ascii', 'ignore')).digest()
    expected = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
    return secrets.compare_digest(expected, code_challenge)
