"""
Token de acceso ligado a la clave que lo pidio.

El servicio usaba `rest_framework.authtoken`, cuyo token apunta a un `User`.
Eso rompia la cadena de identidad justo donde hace falta: la peticion llegaba
a la vista de envio sabiendo *que usuario* era, pero no *que credencial* se
habia presentado. Como consecuencia:

* `request.api_key_record` no existia en los envios, asi que la columna
  `NotificationMessage.api_key` quedaba siempre a NULL y no habia forma de
  saber de quien era cada mensaje;
* la politica de remitentes por cliente (`allowed_senders`) nunca se aplicaba,
  porque no habia cliente que consultar;
* dos claves de la misma cuenta eran indistinguibles: `get_or_create(user=...)`
  devolvia el mismo token para ambas, asi que revocar una no invalidaba las
  sesiones abiertas con ella.

Este modelo guarda la clave de origen, de modo que la identidad viaja intacta
desde `X-API-Key` hasta la fila de auditoria.

El token se guarda hasheado, por el mismo motivo que las claves: son 256 bits
aleatorios, asi que SHA-256 basta y un derivador lento solo anadiria latencia.
"""

from __future__ import annotations

import hashlib
import secrets

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

from keys.models.api_keys import ApiKeys

#: 32 bytes -> 64 caracteres hexadecimales.
TOKEN_BYTES = 32


def hash_token(raw_token: str) -> str:
    """Devuelve el SHA-256 hexadecimal de un token."""
    return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()


class AccessTokenQuerySet(models.QuerySet):
    def vigentes(self) -> AccessTokenQuerySet:
        return self.filter(expires_at__gt=timezone.now())


class AccessToken(models.Model):
    """
    Token de un solo uso emitido a cambio de una clave de API.

    Se mantiene la semantica anterior —el token se consume al autenticar— para
    no cambiar el contrato de los clientes, pero ahora ademas caduca: antes,
    un token emitido y nunca usado quedaba valido indefinidamente.
    """

    api_key = models.ForeignKey(
        ApiKeys,
        on_delete=models.CASCADE,
        related_name="tokens",
        verbose_name="Clave",
    )
    token_hash = models.CharField(
        max_length=64, unique=True, editable=False, verbose_name="Hash"
    )
    expires_at = models.DateTimeField(db_index=True, verbose_name="Caduca el")
    created_at = models.DateTimeField(auto_now_add=True, verbose_name="Emitido el")

    objects = AccessTokenQuerySet.as_manager()

    class Meta:
        verbose_name = "Token de acceso"
        verbose_name_plural = "Tokens de acceso"
        ordering = ("-created_at",)

    def __str__(self) -> str:
        return f"Token de {self.api_key.account.name} (caduca {self.expires_at:%Y-%m-%d %H:%M})"

    @property
    def is_expired(self) -> bool:
        return self.expires_at <= timezone.now()

    @classmethod
    def issue(cls, api_key: ApiKeys, *, ttl_seconds: int | None = None) -> tuple[AccessToken, str]:
        """
        Emite un token para `api_key` y devuelve `(registro, token_en_claro)`.

        El token en claro no se puede recuperar despues: se entrega en la
        respuesta y solo queda su hash.
        """
        if ttl_seconds is None:
            ttl_seconds = settings.ACCESS_TOKEN_TTL_SECONDS

        raw = secrets.token_hex(TOKEN_BYTES)
        registro = cls.objects.create(
            api_key=api_key,
            token_hash=hash_token(raw),
            expires_at=timezone.now() + timezone.timedelta(seconds=ttl_seconds),
        )
        return registro, raw

    @classmethod
    def consume(cls, raw_token: str) -> ApiKeys | None:
        """
        Canjea un token y devuelve la clave que lo pidio, o `None`.

        Todos los caminos de fallo (inexistente, caducado, clave revocada
        despues de emitirlo, cuenta desactivada) devuelven `None` sin
        distinguirse entre si.

        El token se borra aunque este caducado o la clave ya no sirva: no hay
        ningun motivo para conservarlo y asi la tabla no crece sola.
        """
        if not raw_token:
            return None

        registro = (
            cls.objects.select_related("api_key__account__user")
            .filter(token_hash=hash_token(raw_token))
            .first()
        )
        if registro is None:
            return None

        clave = registro.api_key
        caducado = registro.is_expired

        # El borrado es lo que hace efectivo el "un solo uso", asi que decide
        # quien gana: si dos peticiones llegan a la vez con el mismo token, el
        # DELETE solo afecta a una fila y la otra ve cero borrados. Sin esto,
        # ambas pasarian la comprobacion y el token valdria por dos.
        borrados, _ = cls.objects.filter(pk=registro.pk).delete()
        if not borrados:
            return None

        if caducado:
            return None
        # La clave pudo revocarse despues de emitir el token. Comprobarlo aqui
        # es lo que hace que revocar tenga efecto inmediato sobre las sesiones
        # ya abiertas, en lugar de esperar a que caduquen.
        if not clave.is_usable:
            return None
        if not clave.account.user.is_active:
            return None
        # El docstring prometia cubrir "cuenta desactivada" y no lo hacia.
        if not clave.account.active:
            return None
        return clave

    @classmethod
    def purge_expired(cls) -> int:
        """Borra los tokens caducados. Devuelve cuantos."""
        borrados, _ = cls.objects.filter(expires_at__lte=timezone.now()).delete()
        return borrados
