"""
Credencial de un cliente de la API.

La clave se guardaba en claro en la columna `key` y se buscaba con
`ApiKeys.objects.get(key=apikey)`. Eso significa que cualquier lectura de la
base de datos (una copia de seguridad, una inyeccion SQL, un volcado para
depurar, el propio panel de administracion) entregaba credenciales utilizables
tal cual. El `__str__` del modelo devolvia la clave entera, asi que ademas
aparecia en el listado del admin y en cualquier log que imprimiera el objeto.

Ahora solo se guarda su hash.

Formato de la clave, el mismo de antes:

    ApiKey_<prefijo>_<secreto>
            32 hex    32 hex

Mantener el formato no es casualidad: permite migrar las claves que ya estan
en circulacion sin pedir a nadie que cambie nada. La migracion calcula el
prefijo y el hash a partir de la clave guardada y la borra.

Sobre el uso de SHA-256 en lugar de un derivador lento tipo Argon2: los
derivadores lentos existen para proteger secretos de baja entropia elegidos
por personas. Aqui el secreto son 128 bits aleatorios, asi que la fuerza bruta
es inviable con independencia de la velocidad del hash, y un KDF lento solo
anadiria latencia a cada peticion.
"""

from __future__ import annotations

import hashlib
import hmac
import secrets

from django.db import models
from django.utils import timezone

from authentication.models.account import Account

KEY_LABEL = "ApiKey"
KEY_PREFIX_BYTES = 16  # -> 32 caracteres hexadecimales
KEY_SECRET_BYTES = 16  # -> 32 caracteres hexadecimales

# -- Alcances -----------------------------------------------------------------
#
# Una credencial valida daba acceso a todo: enviar por los tres canales y
# consultar cualquier envio. Con esto, una clave entregada a un sistema que
# solo manda SMS deja de servir para mandar correo firmado por el dominio de
# la empresa.

SCOPE_MAIL_SEND = "mail:send"
SCOPE_SMS_SEND = "sms:send"
SCOPE_WHATSAPP_SEND = "whatsapp:send"
SCOPE_MESSAGES_READ = "messages:read"
SCOPE_ALL = "*"

SCOPES = {
    SCOPE_MAIL_SEND: "Enviar correo",
    SCOPE_SMS_SEND: "Enviar SMS",
    SCOPE_WHATSAPP_SEND: "Enviar WhatsApp",
    SCOPE_MESSAGES_READ: "Consultar el estado de los envios",
    SCOPE_ALL: "Todo (comodin)",
}

#: Lo que recibe una clave nueva si no se dice otra cosa: los tres canales y
#: la consulta. Es el comportamiento util por defecto, y quien quiera algo mas
#: estrecho lo pide explicitamente.
DEFAULT_SCOPES = [
    SCOPE_MAIL_SEND,
    SCOPE_SMS_SEND,
    SCOPE_WHATSAPP_SEND,
    SCOPE_MESSAGES_READ,
]


def hash_secret(secret: str) -> str:
    """Devuelve el SHA-256 hexadecimal de la parte secreta de una clave."""
    return hashlib.sha256(secret.encode("utf-8")).hexdigest()


def split_key(raw_key: str) -> tuple[str, str] | None:
    """
    Separa una clave completa en (prefijo, secreto).

    Devuelve `None` si el formato no es el esperado, sin lanzar excepciones,
    para que quien llame no tenga que distinguir entre "clave mal formada" y
    "clave inexistente": ambas deben producir la misma respuesta.
    """
    if not raw_key:
        return None

    # `maxsplit=2` por si el secreto llegara a contener guiones bajos.
    partes = raw_key.strip().split("_", 2)
    if len(partes) != 3:
        return None

    etiqueta, prefijo, secreto = partes
    if etiqueta != KEY_LABEL or not prefijo or not secreto:
        return None
    return prefijo, secreto


class ApiKeysQuerySet(models.QuerySet):
    def usable(self) -> ApiKeysQuerySet:
        """Claves activas y no revocadas."""
        return self.filter(active=True, revoked_at__isnull=True)


class ApiKeys(models.Model):
    account = models.ForeignKey(
        Account, on_delete=models.CASCADE, verbose_name="Cuenta"
    )

    key_prefix = models.CharField(
        max_length=64,
        unique=True,
        editable=False,
        blank=True,
        default="",
        verbose_name="Prefijo",
        help_text="Parte publica de la clave. Permite identificarla sin conocerla.",
    )
    key_hash = models.CharField(
        max_length=64, editable=False, default="", verbose_name="Hash"
    )

    scopes = models.JSONField(
        default=list,
        blank=True,
        verbose_name="Alcances",
        help_text=(
            "Operaciones permitidas. Vacio no autoriza nada; "
            f"'{SCOPE_ALL}' autoriza todo. Valores: {', '.join(SCOPES)}"
        ),
    )
    allowed_senders = models.JSONField(
        default=list,
        blank=True,
        verbose_name="Remitentes autorizados",
        help_text=(
            "Direcciones o dominios desde los que este cliente puede enviar "
            "correo, por ejemplo 'avisos@ejemplo.com' o 'ejemplo.com'. Si esta "
            "vacio se aplica la politica global del servicio."
        ),
    )

    active = models.BooleanField(default=False, verbose_name="Activo")
    revoked_at = models.DateTimeField(null=True, blank=True, verbose_name="Revocada el")
    last_used_at = models.DateTimeField(
        null=True, blank=True, editable=False, verbose_name="Último uso"
    )
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de creación")

    objects = ApiKeysQuerySet.as_manager()

    class Meta:
        verbose_name = "Clave"
        verbose_name_plural = "Claves"
        ordering = ("-created_at",)

    def __str__(self) -> str:
        # Antes devolvia la clave completa, con lo que aparecia en el listado
        # del admin y en cualquier log que imprimiera el objeto.
        return f"{self.account.name} ({self.key_prefix[:8]}…)"

    # -- Estado -------------------------------------------------------------

    @property
    def is_usable(self) -> bool:
        return self.active and self.revoked_at is None

    def has_scope(self, scope: str) -> bool:
        """
        Indica si esta clave puede realizar la operacion `scope`.

        Una lista vacia no autoriza nada. Es deliberado: si un dia se anade un
        alcance nuevo y una clave no lo tiene, debe fallar cerrado. Las claves
        que ya existian reciben los alcances por defecto en la migracion, no
        por esta funcion.
        """
        concedidos = self.scopes or []
        return SCOPE_ALL in concedidos or scope in concedidos

    def revoke(self) -> None:
        self.active = False
        self.revoked_at = timezone.now()
        self.save(update_fields=["active", "revoked_at"])
        self._invalidar_tokens()

    def touch(self) -> None:
        """
        Registra el uso de la clave.

        Se escribe solo si ha pasado mas de un minuto desde la ultima marca,
        para no generar un UPDATE por cada peticion.
        """
        ahora = timezone.now()
        if self.last_used_at and (ahora - self.last_used_at).total_seconds() < 60:
            return
        type(self).objects.filter(pk=self.pk).update(last_used_at=ahora)
        self.last_used_at = ahora

    def _invalidar_tokens(self) -> int:
        """
        Borra los tokens de acceso vivos de esta clave.

        `AccessToken.consume` ya comprueba que la clave siga siendo usable, asi
        que revocar surte efecto igualmente; esto es para no dejar en la tabla
        credenciales que ya no sirven.

        El import es local para no crear un ciclo: `access_token` importa este
        modulo.
        """
        from keys.models.access_token import AccessToken

        borrados, _ = AccessToken.objects.filter(api_key=self).delete()
        return borrados

    # -- Creacion y verificacion -------------------------------------------

    @classmethod
    def issue(
        cls,
        account: Account,
        *,
        active: bool = True,
        scopes: list[str] | None = None,
        allowed_senders: list[str] | None = None,
    ) -> tuple[ApiKeys, str]:
        """
        Crea una clave y devuelve `(registro, clave_en_claro)`.

        La clave en claro es lo unico que permite autenticarse y no se puede
        recuperar despues: quien llama debe mostrarla o entregarla en ese
        momento.
        """
        prefijo = secrets.token_hex(KEY_PREFIX_BYTES)
        secreto = secrets.token_hex(KEY_SECRET_BYTES)
        registro = cls.objects.create(
            account=account,
            key_prefix=prefijo,
            key_hash=hash_secret(secreto),
            active=active,
            scopes=list(DEFAULT_SCOPES) if scopes is None else list(scopes),
            allowed_senders=list(allowed_senders or []),
        )
        return registro, f"{KEY_LABEL}_{prefijo}_{secreto}"

    def rotate(self) -> str:
        """Genera un secreto nuevo para esta clave e invalida el anterior."""
        prefijo = secrets.token_hex(KEY_PREFIX_BYTES)
        secreto = secrets.token_hex(KEY_SECRET_BYTES)
        self.key_prefix = prefijo
        self.key_hash = hash_secret(secreto)
        self.save(update_fields=["key_prefix", "key_hash"])
        # Los tokens cuelgan de la fila, no del secreto, asi que sin esto
        # sobrevivirian a la rotacion: quien tuviera uno seguiria entrando con
        # la credencial que se acaba de retirar.
        self._invalidar_tokens()
        return f"{KEY_LABEL}_{prefijo}_{secreto}"

    @classmethod
    def authenticate(cls, raw_key: str) -> ApiKeys | None:
        """
        Devuelve la clave correspondiente a `raw_key`, o `None`.

        Todos los caminos de fallo (formato invalido, prefijo inexistente,
        secreto incorrecto, clave inactiva o revocada) devuelven `None` sin
        distinguirse entre si, para no dar pistas a quien pruebe claves.
        """
        parsed = split_key(raw_key)
        if parsed is None:
            return None
        prefijo, secreto = parsed

        registro = (
            cls.objects.select_related("account__user")
            .filter(key_prefix=prefijo)
            .first()
        )
        if registro is None:
            # Se calcula el hash igualmente para que el tiempo de respuesta no
            # revele si el prefijo existe en la base de datos.
            hash_secret(secreto)
            return None

        if not hmac.compare_digest(registro.key_hash, hash_secret(secreto)):
            return None
        if not registro.is_usable:
            return None
        # `Account.active` existia y no se comprobaba en ningun sitio:
        # desactivar una cuenta desde el panel no cortaba nada, sus claves
        # seguian enviando. La migracion 0008 pone a True las cuentas que ya
        # tenian claves usables, para que activar esto no deje fuera a nadie
        # que hoy funciona.
        if not registro.account.active:
            return None
        return registro
