import base64
import datetime
import hashlib
import hmac
import logging
import re
import struct
import time

from . import packers, settings

__all__ = ["create_token", "detect_token", "parse_token"]

logger = logging.getLogger("sesame")

TIMESTAMP_OFFSET = 1577836800  # 2020-01-01T00:00:00Z


def pack_timestamp():
    """
    When SESAME_MAX_AGE is enabled, encode the time in seconds since the epoch.

    Return bytes.

    """
    if settings.MAX_AGE is None:
        return b""
    timestamp = int(time.time()) - TIMESTAMP_OFFSET
    return struct.pack("!i", timestamp)


def unpack_timestamp(data):
    """
    When SESAME_MAX_AGE is enabled, extract the timestamp and calculate the age.

    Return an age in seconds or None and the remaining bytes.

    """
    if settings.MAX_AGE is None:
        return None, data
    # If data contains less than 4 bytes, this raises struct.error.
    (timestamp,), data = struct.unpack("!i", data[:4]), data[4:]
    return int(time.time()) - TIMESTAMP_OFFSET - timestamp, data


HASH_SIZES = {
    "pbkdf2_sha256": 44,
    "pbkdf2_sha1": 28,
    "argon2": 22,  # in Argon2 v1.3; previously 86
    "bcrypt_sha256": 31,  # salt (22) + hash (31)
    "bcrypt": 31,  # salt (22) + hash (31)
    "sha1": 40,  # hex, not base64
    "md5": 32,  # hex, not base64
    "crypt": 11,  # salt (2) + hash (11)
}


def get_revocation_key(user):
    """
    When the value returned by this method changes, this revocates tokens.

    It is derived from the hashed password so that changing the password
    revokes tokens.

    For one-time tokens, it also contains the last login datetime so that
    logging in revokes existing tokens.

    """
    data = ""

    # Tokens generated by django-sesame are more likely to leak than hashed
    # passwords. To minimize the information tokens might be revealing, we'd
    # like to use only hashes, excluding salts, as suggested in issue #40.

    # Since we're hashing the result again with a cryptographic hash function,
    # this isn't supposed to make a difference in practice. But it alleviates
    # concerns about sending data derived from hashed passwords into the wild.

    # Hashed passwords may be in various formats:
    # 1. "[<algorithm>$]?[<parameters>$]*[<salt>$?]?<hash>", if set_password()
    #    was called with a built-in hasher. Unfortunatly, the bcrypt (and
    #    crypt) hashers don't include a "$" between the salt and the hash, so
    #    we can't split on this marker. Instead we hardcode hash lengths.
    # 2. "!<40 random characters>", if set_unusable_password() was called.
    # 3. Anything else, if set_password() was called with a custom hasher or
    #    if a custom authentication backend is used.

    # An alternative would be to rely on user.get_session_auth_hash(), which
    # has the advantage of being a public API. It's a HMAC-SHA256 of the whole
    # password hash. However, it's designed for a slightly different purpose,
    # so I'm not comfortable reusing it. Also, for clarity, I don't want to
    # chain more cryptographic operations than needed.

    if settings.INVALIDATE_ON_PASSWORD_CHANGE and user.password is not None:
        algorithm = user.password.partition("$")[0]
        try:
            hash_size = HASH_SIZES[algorithm]
        except KeyError:
            data += user.password
        else:
            data += user.password[-hash_size:]

    if settings.ONE_TIME and user.last_login is not None:
        data += user.last_login.isoformat()

    return data.encode()


def sign(data, key, size):
    """
    Create a MAC with keyed hashing.

    """
    return hashlib.blake2b(
        data,
        digest_size=size,
        key=key,
        person=b"sesame.tokens_v2",
    ).digest()


def create_token(user, scope=""):
    """
    Create a v2 signed token for a user.

    """
    primary_key = packers.packer.pack_pk(getattr(user, settings.PRIMARY_KEY_FIELD))
    timestamp = pack_timestamp()
    revocation_key = get_revocation_key(user)

    signature = sign(
        primary_key + timestamp + revocation_key + scope.encode(),
        settings.SIGNING_KEY,
        settings.SIGNATURE_SIZE,
    )

    # If the revocation key changes, the signature becomes invalid, so we
    # don't need to include a hash of the revocation key in the token.
    data = primary_key + timestamp + signature
    token = base64.urlsafe_b64encode(data).rstrip(b"=")
    return token.decode()


def parse_token(token, get_user, scope="", max_age=None):
    """
    Obtain a user from a v2 signed token.

    """
    token = token.encode()

    # Below, error messages should give a hint to developers debugging apps
    # but remain sufficiently generic for the common situation where tokens
    # get truncated by accident.

    try:
        data = base64.urlsafe_b64decode(token + b"=" * (-len(token) % 4))
    except Exception:
        logger.debug("Bad token: cannot decode token")
        return None

    # Extract user primary key, token age, and signature from token.

    try:
        user_pk, timestamp_and_signature = packers.packer.unpack_pk(data)
    except Exception:
        logger.debug("Bad token: cannot extract primary key")
        return None

    try:
        age, signature = unpack_timestamp(timestamp_and_signature)
    except Exception:
        logger.debug("Bad token: cannot extract timestamp")
        return None

    if len(signature) != settings.SIGNATURE_SIZE:
        logger.debug("Bad token: cannot extract signature")
        return None

    # Since we don't include the revocation key in the token, we need to fetch
    # the user in the database before we can verify the signature. Usually,
    # it's best to verify the signature before doing anything with a message.

    # An attacker could craft tokens to fetch arbitrary users by primary key,
    # like they can fetch arbitrary users by username on a login form.
    # Determining whether there's a user with a given primary key via a timing
    # attack is acceptable within django-sesame's threat model.

    # Check if token is expired. Perform this check first, because it's fast.

    if max_age is None:
        max_age = settings.MAX_AGE
    elif settings.MAX_AGE is None:
        logger.warning(
            "Ignoring max_age argument; "
            "it isn't supported when SESAME_MAX_AGE = None"
        )
    elif isinstance(max_age, datetime.timedelta):
        max_age = max_age.total_seconds()
    if age is not None and age >= max_age:
        logger.debug("Expired token: age = %d seconds", age)
        return None

    # Check if user exists and can log in.

    user = get_user(user_pk)
    if user is None:
        logger.debug(
            "Unknown or inactive user: %s = %r",
            settings.PRIMARY_KEY_FIELD,
            user_pk,
        )
        return None

    # Check if signature is valid

    primary_key_and_timestamp = data[: -settings.SIGNATURE_SIZE]
    revocation_key = get_revocation_key(user)
    for verification_key in settings.VERIFICATION_KEYS:
        expected_signature = sign(
            primary_key_and_timestamp + revocation_key + scope.encode(),
            verification_key,
            settings.SIGNATURE_SIZE,
        )
        if hmac.compare_digest(signature, expected_signature):
            log_scope = "in default scope" if scope == "" else f"in scope {scope}"
            logger.debug("Valid token for user %s %s", user, log_scope)
            return user

    log_scope = "in default scope" if scope == "" else f"in scope {scope}"
    logger.debug("Invalid token for user %s %s", user, log_scope)
    return None


# Tokens are arbitrary Base64-encoded bytestrings. Their size depends on
# SESAME_PACKER, SESAME_MAX_AGE, and SESAME_SIGNATURE_SIZE. Defaults are:
# - without SESAME_MAX_AGE: 4 + 10 = 14 bytes = 19 Base64 characters.
# - with SESAME_MAX_AGE: 4 + 4 + 10 = 18 bytes = 24 Base64 characters.
# Minimum "sensible" size is 1 + 0 + 2 = 3 bytes = 4 Base64 characters.
token_re = re.compile(r"[A-Za-z0-9-_]{4,}")


def detect_token(token):
    """
    Tell whether token may be a v2 signed token.

    """
    return token_re.fullmatch(token) is not None
