"""
Reserva y liberación del cupo diario de inspección.

Éste es el módulo del principio II: la cuota es del usuario y el sistema no
puede gastarla sin haberla contado antes. Por eso se reserva **antes** de llamar
a Google y se libera si la llamada no llega a ocurrir, y no al revés. Contar
después de llamar deja una ventana en la que dos trabajadores concurrentes
gastan más de lo que hay, y ese exceso no se puede devolver: Google ya lo
descontó.

La fila del día se bloquea mientras se decide. Un contador en Redis sería más
rápido, pero Redis puede perder su estado y el consumo de cuota no es una caché:
es el dato que explica por qué un lote terminó parcial.
"""

from dataclasses import dataclass
from datetime import timedelta

from django.core.cache import cache
from django.db import transaction
from django.db.models import F
from django.utils import timezone

from apps.core.dates import display_timezone, today
from apps.jobs.models import QuotaBudget


class Origin:
    """
    Contra qué bolsillo se cobra la reserva.

    Los dos primeros existen porque el trabajo automático corre todos los días y
    llenaría el cupo antes de que nadie se despierte. La reserva manual existe
    para que pedir una inspección a mano siga siendo posible a las seis de la
    tarde.

    El tercero es de otra naturaleza: comprobar una credencial ocurre antes de
    que exista ningún dominio, así que no hay presupuesto de propiedad contra el
    cual cobrarlo. Tampoco consume el cupo de 2.000 diarias, que es por propiedad
    y sólo lo gasta la inspección de URLs. Aun así lleva reserva, porque la regla
    de que toda llamada a Google pase por una es lo que hace que el control no
    dependa de que alguien se acuerde.
    """

    AUTOMATIC = 'AUTOMATIC'
    MANUAL = 'MANUAL'
    VERIFICATION = 'VERIFICATION'


#: Cuántas comprobaciones de credencial admite una cuenta por día. No protege
#: una cuota de Google —esa llamada no gasta la del dominio— sino de un botón
#: que alguien pulsa veinte veces seguidas mientras espera que algo cambie.
VERIFICATION_DAILY_LIMIT = 60


@dataclass(frozen=True)
class Reservation:
    """
    Cupo concedido. Es el único permiso válido para llamar a Google.

    `granted` puede ser menor que lo pedido: cuando quedan cien y se piden mil,
    se conceden cien y el lote termina parcial. Conceder todo o nada dejaría el
    resto del cupo sin usar por el resto del día.
    """

    domain_id: str
    origin: str
    granted: int
    requested: int
    date: str

    @property
    def is_partial(self) -> bool:
        return self.granted < self.requested

    def __bool__(self) -> bool:
        return self.granted > 0


def budget_for(domain, *, date=None) -> QuotaBudget:
    """
    Devuelve el presupuesto del día, creándolo con los límites vigentes del dominio.

    Los límites se copian al crear la fila: si mañana se sube el cupo, el día de
    ayer tiene que seguir contando lo que contaba.
    """
    budget, _ = QuotaBudget.objects.get_or_create(
        domain=domain,
        date=date or today(),
        defaults={
            'limit_total': domain.daily_inspection_budget,
            'manual_reserve': domain.manual_reserve,
        },
    )
    return budget


@transaction.atomic
def reserve(domain, amount: int, *, origin: str = Origin.AUTOMATIC, date=None) -> Reservation:
    """
    Reserva hasta `amount` llamadas y devuelve cuántas se concedieron.

    El bloqueo de fila es lo que hace correcta la operación con varios
    trabajadores a la vez: sin él, dos procesos leen el mismo saldo, los dos
    concluyen que hay lugar y los dos gastan.
    """
    day = date or today()
    budget_for(domain, date=day)

    budget = QuotaBudget.objects.select_for_update().get(domain=domain, date=day)

    available = (
        budget.automatic_remaining if origin == Origin.AUTOMATIC else budget.manual_remaining
    )
    granted = max(min(amount, available), 0)

    if granted:
        column = 'used_automatic' if origin == Origin.AUTOMATIC else 'used_manual'
        QuotaBudget.objects.filter(pk=budget.pk).update(**{column: F(column) + granted})

    return Reservation(
        domain_id=str(domain.id),
        origin=origin,
        granted=granted,
        requested=amount,
        date=day.isoformat(),
    )


@transaction.atomic
def release(reservation: Reservation, amount: int) -> None:
    """
    Devuelve al cupo lo reservado que no se llegó a gastar.

    Sin esto, un lote que reserva mil y falla en la llamada número tres deja
    novecientas noventa y siete consultas perdidas hasta el día siguiente, sin
    que Google haya descontado ninguna.
    """
    if amount <= 0 or reservation.origin == Origin.VERIFICATION:
        # La comprobación de credencial no descuenta cuota de ninguna propiedad,
        # así que no hay nada que devolver: su contador sólo mide el ritmo.
        return

    column = 'used_automatic' if reservation.origin == Origin.AUTOMATIC else 'used_manual'
    budget = QuotaBudget.objects.select_for_update().get(
        domain_id=reservation.domain_id, date=reservation.date
    )
    returned = min(amount, getattr(budget, column))
    QuotaBudget.objects.filter(pk=budget.pk).update(**{column: F(column) - returned})


def reserve_verification(account, amount: int = 1) -> Reservation:
    """
    Reserva para una llamada que no pertenece a ningún dominio.

    El contador vive en la caché y no en una tabla porque lo que limita es el
    ritmo de una acción interactiva, no el consumo de un recurso del usuario:
    perderlo al reiniciar Redis no cambia ninguna cifra que haya que explicar
    después. El consumo de cuota, en cambio, nunca vive en la caché.
    """
    day = today()
    key = f'verificaciones:{account.id}:{day.isoformat()}'

    cache.add(key, 0, timeout=_seconds_until_tomorrow())
    try:
        used = cache.incr(key, amount)
    except ValueError:
        # La entrada venció entre el add y el incr.
        cache.set(key, amount, timeout=_seconds_until_tomorrow())
        used = amount

    granted = amount if used <= VERIFICATION_DAILY_LIMIT else 0

    return Reservation(
        domain_id='',
        origin=Origin.VERIFICATION,
        granted=granted,
        requested=amount,
        date=day.isoformat(),
    )


def _seconds_until_tomorrow() -> int:
    from datetime import datetime
    from datetime import time as time_of_day

    now = timezone.now().astimezone(display_timezone())
    tomorrow = datetime.combine(now.date() + timedelta(days=1), time_of_day.min, now.tzinfo)
    return int((tomorrow - now).total_seconds())


def remaining(domain, *, date=None) -> dict:
    """Saldo del día, tal como se muestra en la ficha del dominio."""
    budget = budget_for(domain, date=date)
    return {
        'date': budget.date.isoformat(),
        'limit_total': budget.limit_total,
        'manual_reserve': budget.manual_reserve,
        'used_automatic': budget.used_automatic,
        'used_manual': budget.used_manual,
        'automatic_remaining': budget.automatic_remaining,
        'manual_remaining': budget.manual_remaining,
    }
