"""Dominios monitoreados y el estado de nuestro acceso a su propiedad."""

import secrets

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

from apps.core.models import BaseModel


class PropertyType(models.TextChoices):
    DOMAIN = 'DOMAIN', 'Domain property'
    URL_PREFIX = 'URL_PREFIX', 'URL prefix property'


class AccessState(models.TextChoices):
    """
    Estado de nuestro acceso a la propiedad en Search Console.

    Describe la propiedad, no la credencial: son dos cosas distintas y hay que
    mirarlas juntas. Un dominio puede estar en OPERATIONAL mientras la
    credencial de la cuenta está muerta, y en ese caso no está operativo aunque
    esta columna lo diga (FR-064).
    """

    AWAITING_ACCESS = 'AWAITING_ACCESS', 'Awaiting access'
    OPERATIONAL = 'OPERATIONAL', 'Operational'
    ACCESS_LOST = 'ACCESS_LOST', 'Access lost'
    ACCESS_REVOKED = 'ACCESS_REVOKED', 'Access revoked'
    SUSPENDED = 'SUSPENDED', 'Suspended'


# Transiciones admitidas. Fuera de esta tabla no hay cambio de estado válido:
# tenerla explícita evita que cada servicio invente su propio criterio y que un
# dominio suspendido vuelva a trabajar por un camino lateral.
ALLOWED_ACCESS_TRANSITIONS: dict[str, set[str]] = {
    AccessState.AWAITING_ACCESS: {
        AccessState.AWAITING_ACCESS,
        AccessState.OPERATIONAL,
        AccessState.ACCESS_REVOKED,
        AccessState.SUSPENDED,
    },
    AccessState.OPERATIONAL: {
        AccessState.OPERATIONAL,
        AccessState.ACCESS_LOST,
        AccessState.ACCESS_REVOKED,
        AccessState.SUSPENDED,
    },
    AccessState.ACCESS_LOST: {
        AccessState.ACCESS_LOST,
        AccessState.OPERATIONAL,
        AccessState.ACCESS_REVOKED,
        AccessState.SUSPENDED,
    },
    AccessState.ACCESS_REVOKED: {AccessState.AWAITING_ACCESS, AccessState.SUSPENDED},
    AccessState.SUSPENDED: {AccessState.AWAITING_ACCESS, AccessState.ACCESS_REVOKED},
}


class InvalidAccessTransition(Exception):
    """Se intentó un cambio de estado que la máquina no admite."""


def _new_txt_token() -> str:
    return secrets.token_hex(16)


class Domain(BaseModel):
    """
    Sitio monitoreado, atado a una propiedad concreta de Search Console.

    El cupo diario vive acá y no en la configuración global porque el límite de
    Google es por propiedad: dos dominios de la misma cuenta no comparten
    presupuesto, y tratarlos como si lo hicieran haría que uno se coma el del
    otro.
    """

    account = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='domains'
    )
    hostname = models.CharField(max_length=253)
    property_type = models.CharField(max_length=20, choices=PropertyType.choices)
    property_uri = models.CharField(max_length=300)
    access_state = models.CharField(
        max_length=20, choices=AccessState.choices, default=AccessState.AWAITING_ACCESS
    )
    access_checked_at = models.DateTimeField(null=True, blank=True)
    access_error = models.TextField(blank=True, default='')
    # El código se guarda junto al mensaje porque la ficha ramifica por código y
    # no por prosa: «no tenemos permiso» y «la propiedad no existe» se resuelven
    # en dos lugares distintos de Search Console, y deducir cuál es leyendo el
    # texto del error rompería la pantalla al primer cambio de redacción.
    access_error_code = models.CharField(max_length=40, blank=True, default='')
    daily_inspection_budget = models.PositiveIntegerField(default=2000)
    # Hace **dos** trabajos, y conviene saberlo antes de tocarlo:
    #
    # 1. Le pone techo al recorrido automático —que sólo puede gastar
    #    `daily_inspection_budget - manual_reserve`—, para garantizarle un piso
    #    a lo que dispara una persona. Al trabajo manual **no** lo limita: ése
    #    puede usar todo lo que el automático no haya gastado.
    # 2. Es **cuántas URLs entran en una tanda manual**: la pantalla y la API
    #    lo pasan como `limit` al encolar. Éste es el uso que se nota.
    #
    # Sube de 200 a 1000 por decisión del owner: con el recorrido automático
    # apagado, las 2.000 diarias de la propiedad están libres y una tanda de 200
    # obligaba a apretar el botón diez veces para dar una vuelta a un sitio
    # mediano.
    manual_reserve = models.PositiveIntegerField(default=1000)
    notifications_enabled = models.BooleanField(default=True)
    # Se genera siempre aunque la verificación por TXT esté apagada: emitirlo
    # recién al encenderla obligaría a repartir tokens nuevos a dominios que ya
    # existen, justo en el momento de mayor fricción (principio VI).
    txt_token = models.CharField(max_length=64, default=_new_txt_token, editable=False)
    txt_verified_at = models.DateTimeField(null=True, blank=True)
    # Fecha y no booleano: guarda **el hecho** —se dio de baja tal día— en vez de
    # un estado derivado, y eso permite que la pantalla diga desde cuándo. Un
    # `is_active = False` no sabe responder eso y no cuesta menos.
    #
    # Dar de baja no borra: la cobertura, los sitemaps y los lotes de este sitio
    # son historia real, y borrarlos en cascada perdería meses de datos por un
    # cambio de opinión. La restricción de unicidad de abajo tampoco distingue
    # activos de dados de baja, así que volver a dar de alta el mismo sitio
    # **reactiva esta fila** en vez de crear otra (`create_domain`).
    deactivated_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ['hostname']
        constraints = [
            models.UniqueConstraint(
                fields=['account', 'property_uri'], name='one_property_per_account'
            )
        ]

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

    @property
    def automatic_budget(self) -> int:
        """Cupo diario del trabajo automático, ya descontada la reserva manual."""
        return max(self.daily_inspection_budget - self.manual_reserve, 0)

    @property
    def is_operational(self) -> bool:
        return self.access_state == AccessState.OPERATIONAL

    def transition_access(self, new_state: str, *, error: str = '', error_code: str = '') -> None:
        """
        Cambia el estado de acceso validando la transición.

        No guarda: quien llama decide en qué transacción persistir, porque el
        cambio de estado casi siempre acompaña a otras escrituras que tienen que
        ser atómicas con él.

        El motivo se escribe siempre, aunque venga vacío: un cambio de estado
        que deja el error anterior en pie haría que la ficha explique el estado
        de hoy con la causa de la semana pasada.
        """
        allowed = ALLOWED_ACCESS_TRANSITIONS.get(self.access_state, set())
        if new_state not in allowed:
            raise InvalidAccessTransition(f'{self.access_state} no puede pasar a {new_state}.')
        self.access_state = new_state
        self.access_error = error
        self.access_error_code = error_code
