import base64
import os
from typing import TypedDict

from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from django.core.management import CommandError

from documents.settings import EXPORTER_CRYPTO_ALGO_NAME
from documents.settings import EXPORTER_CRYPTO_KEY_ITERATIONS_NAME
from documents.settings import EXPORTER_CRYPTO_KEY_SIZE_NAME
from documents.settings import EXPORTER_CRYPTO_SALT_NAME
from documents.settings import EXPORTER_CRYPTO_SETTINGS_NAME


class CryptFields(TypedDict):
    exporter_key: str
    model_name: str
    fields: list[str]


class CryptMixin:
    """
    Fully based on:
    https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet

    To encrypt:
      1. Call setup_crypto providing the user provided passphrase
      2. Call encrypt_string with a value
      3. Store the returned hexadecimal representation of the value

    To decrypt:
      1. Load the required parameters:
        a. key iterations
        b. key size
        c. key algorithm
      2. Call setup_crypto providing the user provided passphrase and stored salt
      3. Call decrypt_string with a value
      4. Use the returned value

    """

    # This matches to Django's default for now
    # https://github.com/django/django/blob/adae61942/django/contrib/auth/hashers.py#L315

    # Set the defaults to be used during export
    # During import, these are overridden from the loaded values to ensure decryption is possible
    key_iterations = 1_000_000
    salt_size = 16
    key_size = 32
    kdf_algorithm = "pbkdf2_sha256"

    CRYPT_FIELDS: list[CryptFields] = [
        {
            "exporter_key": "mail_accounts",
            "model_name": "paperless_mail.mailaccount",
            "fields": [
                "password",
                "refresh_token",
            ],
        },
        {
            "exporter_key": "social_tokens",
            "model_name": "socialaccount.socialtoken",
            "fields": [
                "token",
                "token_secret",
            ],
        },
    ]
    # O(1) lookup for per-record encryption; derived from CRYPT_FIELDS at class definition time
    CRYPT_FIELDS_BY_MODEL: dict[str, list[str]] = {
        cfg["model_name"]: cfg["fields"] for cfg in CRYPT_FIELDS
    }

    def get_crypt_params(self) -> dict[str, dict[str, str | int]]:
        return {
            EXPORTER_CRYPTO_SETTINGS_NAME: {
                EXPORTER_CRYPTO_ALGO_NAME: self.kdf_algorithm,
                EXPORTER_CRYPTO_KEY_ITERATIONS_NAME: self.key_iterations,
                EXPORTER_CRYPTO_KEY_SIZE_NAME: self.key_size,
                EXPORTER_CRYPTO_SALT_NAME: self.salt,
            },
        }

    def load_crypt_params(self, metadata: dict) -> None:
        # Load up the values for setting up decryption
        self.kdf_algorithm: str = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
            EXPORTER_CRYPTO_ALGO_NAME
        ]
        self.key_iterations: int = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
            EXPORTER_CRYPTO_KEY_ITERATIONS_NAME
        ]
        self.key_size: int = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
            EXPORTER_CRYPTO_KEY_SIZE_NAME
        ]
        self.salt: str = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
            EXPORTER_CRYPTO_SALT_NAME
        ]

    def setup_crypto(self, *, passphrase: str, salt: str | None = None) -> None:
        """
        Constructs a class for encryption or decryption using the specified passphrase and salt

        Salt is assumed to be a hexadecimal representation of a cryptographically secure random byte string.
        If not provided, it will be derived from the system secure random
        """
        self.salt = salt or os.urandom(self.salt_size).hex()

        # Derive the KDF based on loaded settings
        if self.kdf_algorithm == "pbkdf2_sha256":
            kdf = PBKDF2HMAC(
                algorithm=hashes.SHA256(),
                length=self.key_size,
                salt=bytes.fromhex(self.salt),
                iterations=self.key_iterations,
            )
        else:  # pragma: no cover
            raise CommandError(
                f"{self.kdf_algorithm} is an unknown key derivation function",
            )

        key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))

        self.fernet = Fernet(key)

    def encrypt_string(self, *, value: str) -> str:
        """
        Given a string value, encrypts it and returns the hexadecimal representation of the encrypted token

        """
        return self.fernet.encrypt(value.encode("utf-8")).hex()

    def decrypt_string(self, *, value: str) -> str:
        """
        Given a string value, decrypts it and returns the original value of the field
        """
        return self.fernet.decrypt(bytes.fromhex(value)).decode("utf-8")
