"""Identidad: la cuenta titular de los datos, sus claves de API y sus sesiones."""

import hashlib
import secrets

from django.apps import apps
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.contrib.auth.models import PermissionsMixin
from django.db import models
from django.utils import timezone

from apps.core.models import BaseModel


class AccountRole(models.TextChoices):
    """Nivel administrativo de una cuenta; los permisos finos vendrán después."""

    SUPER_ADMIN = 'SUPER_ADMIN', 'Super admin'
    ADMIN = 'ADMIN', 'Admin'
    VIEWER = 'VIEWER', 'Viewer'


class AccountManager(BaseUserManager):
    """
    Crea cuentas identificadas por correo, sin nombre de usuario.

    Asigna el plan predeterminado en el alta. Con la facturación apagada el
    plan no restringe nada, pero la fila queda apuntada desde el principio: si
    se encendiera el cobro sobre cuentas sin plan, habría que inventarles uno
    a posteriori y adivinar cuál les correspondía.
    """

    use_in_migrations = True

    def _default_plan(self):
        # Importación diferida: billing referencia a esta aplicación y el
        # ciclo rompería la carga si se resolviera al importar el módulo.
        plan_model = apps.get_model('billing', 'Plan')
        return plan_model.objects.filter(is_default=True).first()

    def create_user(self, email, password=None, **extra_fields):
        if not email:
            raise ValueError('La cuenta necesita un correo.')
        extra_fields.setdefault('plan', self._default_plan())
        account = self.model(email=self.normalize_email(email), **extra_fields)
        account.set_password(password)
        account.save(using=self._db)
        return account

    def create_superuser(self, email, password=None, **extra_fields):
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)
        extra_fields.setdefault('role', AccountRole.SUPER_ADMIN)
        if extra_fields.get('is_staff') is not True:
            raise ValueError('Una cuenta de superusuario necesita is_staff.')
        if extra_fields.get('is_superuser') is not True:
            raise ValueError('Una cuenta de superusuario necesita is_superuser.')
        return self.create_user(email, password, **extra_fields)


class Language(models.TextChoices):
    """
    Idiomas en los que el producto se puede leer.

    Las etiquetas van en inglés porque el único lugar donde se leen es el panel
    interno: lo que ve el usuario sale del catálogo del cliente, no de acá.
    """

    EN = 'EN', 'English'
    ES_AR = 'ES_AR', 'Spanish (Argentina)'


class Theme(models.TextChoices):
    """
    Cómo se pinta la interfaz.

    `SYSTEM` es el valor por omisión y no es lo mismo que claro: significa
    «seguí lo que diga el sistema operativo», que es lo único que el servidor no
    puede resolver solo. Por eso existe el guion inline de `base.html`.
    """

    LIGHT = 'LIGHT', 'Light'
    DARK = 'DARK', 'Dark'
    SYSTEM = 'SYSTEM', 'System'


class Account(BaseModel, AbstractBaseUser, PermissionsMixin):
    """
    Titular de los datos. Todo lo demás cuelga de acá.

    El identificador es el correo: no hay nombre de usuario porque no aporta
    nada que el correo no resuelva, y obliga a recordar un dato más.
    """

    email = models.EmailField(unique=True)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    role = models.CharField(max_length=16, choices=AccountRole, default=AccountRole.VIEWER)
    # El idioma por omisión es el inglés, que es el idioma fuente del producto.
    # Las cuentas que ya existían no se migran a español aunque hoy vean
    # español: el default nuevo es el idioma nuevo, y quien quiera el anterior
    # lo elige en su perfil.
    language = models.CharField(max_length=8, choices=Language, default=Language.EN)
    theme = models.CharField(max_length=8, choices=Theme, default=Theme.SYSTEM)
    plan = models.ForeignKey(
        'billing.Plan',
        on_delete=models.PROTECT,
        null=True,
        blank=True,
        related_name='accounts',
    )

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    objects = AccountManager()

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=['role'],
                condition=models.Q(role=AccountRole.SUPER_ADMIN),
                name='only_one_super_admin',
            )
        ]

    def __str__(self) -> str:
        return self.email


class ApiKey(BaseModel):
    """
    Credencial de la cuenta contra nuestra propia API.

    Se guarda **hasheada**, no cifrada: a diferencia de la clave de Google, acá
    nunca hace falta recuperar el valor original —sólo comparar el que llega—,
    así que guardar algo reversible sería exponerse sin ganar nada.

    El hash es SHA-256 y no un derivador lento: la clave la generamos nosotros
    con entropía suficiente, no la elige una persona, así que no hay diccionario
    que probar. Un derivador lento acá sólo agregaría latencia a cada petición.
    """

    KEY_NAMESPACE = 'irk'
    PREFIX_LENGTH = 8

    account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name='api_keys')
    name = models.CharField(max_length=100)
    prefix = models.CharField(max_length=16, unique=True, db_index=True)
    hashed_key = models.CharField(max_length=64)
    last_used_at = models.DateTimeField(null=True, blank=True)
    revoked_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self) -> str:
        return f'{self.name} ({self.prefix}…)'

    @property
    def is_revoked(self) -> bool:
        return self.revoked_at is not None

    @staticmethod
    def hash_key(plaintext: str) -> str:
        return hashlib.sha256(plaintext.encode()).hexdigest()

    @classmethod
    def issue(cls, *, account: Account, name: str) -> tuple['ApiKey', str]:
        """
        Emite una clave y devuelve el objeto junto al valor en claro.

        El valor en claro se devuelve una única vez y no vuelve a existir en
        ningún lado: quien lo pierde emite otra clave, no lo recupera.
        """
        prefix = secrets.token_hex(cls.PREFIX_LENGTH // 2)
        plaintext = f'{cls.KEY_NAMESPACE}_{prefix}_{secrets.token_urlsafe(32)}'
        key = cls.objects.create(
            account=account, name=name, prefix=prefix, hashed_key=cls.hash_key(plaintext)
        )
        return key, plaintext

    @classmethod
    def split(cls, plaintext: str) -> str | None:
        """
        Extrae el prefijo de una clave recibida, o nada si no tiene la forma esperada.

        El corte es en los dos primeros guiones bajos y no en todos: el secreto
        se genera en base64 url-safe, que incluye el guion bajo entre sus
        caracteres. Partir por todos rechazaría —al azar, según qué salió en el
        sorteo— buena parte de las claves emitidas.
        """
        parts = plaintext.split('_', 2)
        if len(parts) != 3 or parts[0] != cls.KEY_NAMESPACE or not parts[2]:
            return None
        return parts[1] or None

    def matches(self, plaintext: str) -> bool:
        return secrets.compare_digest(self.hashed_key, self.hash_key(plaintext))

    def revoke(self) -> None:
        """Una clave revocada no se reactiva: la fila queda para poder auditarla."""
        if self.revoked_at is None:
            self.revoked_at = timezone.now()
            self.save(update_fields=['revoked_at', 'updated_at'])


class Session(BaseModel):
    """
    Sesión abierta de una cuenta, con lo necesario para reconocerla en pantalla.

    **Envuelve el almacén de sesiones de Django; no lo reemplaza.** Quién sigue
    adentro lo decide `django.contrib.sessions`: es lo que se consulta en cada
    petición y lo único que puede invalidar una cookie. Esta tabla sólo agrega
    lo que ese almacén no guarda —de quién es la sesión, desde dónde y con qué
    navegador— y la referencia por `session_key`. Reemplazarlo obligaría a
    reimplementar la caducidad y la firma de la cookie; duplicarlo dejaría dos
    respuestas posibles a «¿está abierta?», y la pantalla mostraría como viva
    una sesión que en el almacén ya venció. Por eso cerrar borra primero en el
    almacén y recién después marca acá, y el listado descarta toda fila cuya
    sesión ya no exista allí.

    **Sobre los datos personales.** El agente de usuario y la dirección de
    origen se guardan porque son lo único que permite decidir cuál sesión cerrar
    ante una sospecha: sin ellos las filas son indistinguibles entre sí
    (FR-059). No se usan para nada más —no se geolocalizan ni se cruzan con otra
    cosa— y la fila se borra a los `SESSION_RECORD_RETENTION_DAYS` de haberse
    cerrado.
    """

    #: Tope del agente de usuario que se guarda. La cabecera no tiene límite
    #: definido y hay clientes que mandan cientos de caracteres de relleno; lo
    #: que sirve para reconocer un navegador está siempre al principio.
    USER_AGENT_MAX_LENGTH = 512

    account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name='sessions')
    # Única: es la referencia al almacén, y dos filas para la misma sesión
    # serían dos verdades sobre el mismo objeto.
    session_key = models.CharField(max_length=40, unique=True)
    # Texto y no `GenericIPAddressField`: la dirección se muestra tal como
    # llegó y puede faltar. Un campo que valida el formato obligaría a inventar
    # un valor o a perder el dato cuando el origen no es una IP reconocible.
    ip_address = models.CharField(max_length=45, blank=True)
    user_agent = models.CharField(max_length=USER_AGENT_MAX_LENGTH, blank=True)
    last_activity_at = models.DateTimeField()
    revoked_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ['-last_activity_at']
        indexes = [models.Index(fields=['account', 'revoked_at'])]

    def __str__(self) -> str:
        return f'{self.account.email} · {self.ip_address or "sin dirección"}'

    @property
    def is_revoked(self) -> bool:
        return self.revoked_at is not None


class Invitation(BaseModel):
    """Invitación de un solo uso para que una cuenta nueva defina su contraseña."""

    account = models.OneToOneField(Account, on_delete=models.CASCADE, related_name='invitation')
    invited_by = models.ForeignKey(
        Account, on_delete=models.SET_NULL, null=True, related_name='sent_invitations'
    )
    token_hash = models.CharField(max_length=64, unique=True)
    expires_at = models.DateTimeField()
    accepted_at = models.DateTimeField(null=True, blank=True)
