"""Trabajo programado de inspección de cobertura."""

import logging

from celery import shared_task

from apps.core.errors import ApiError
from apps.coverage.services import inspect_domain
from apps.domains.models import AccessState, Domain
from apps.jobs.models import BatchOrigin

logger = logging.getLogger(__name__)


@shared_task(name='apps.coverage.tasks.inspect_all_domains')
def inspect_all_domains() -> dict:
    # Los dados de baja quedan afuera. `access_state` describe lo que pasa en
    # Search Console —un sitio puede estar operativo ahí y dado de baja acá—,
    # así que filtrar por estado no alcanza para saber sobre qué hay que
    # trabajar.
    domains = Domain.objects.filter(
        access_state=AccessState.OPERATIONAL, deactivated_at__isnull=True
    ).values_list('id', flat=True)
    for domain_id in domains:
        inspect_one_domain.delay(str(domain_id))

    return {'queued': len(domains)}


@shared_task(name='apps.coverage.tasks.inspect_one_domain', bind=True)
def inspect_one_domain(
    self,
    domain_id: str,
    limit: int | None = None,
    origin: str = BatchOrigin.SCHEDULED,
    batch_id: str | None = None,
    url_ids: list[str] | None = None,
) -> dict:
    """
    Consulta las URLs pendientes de un dominio, hasta donde alcance el cupo.

    El tope por corrida sale del propio presupuesto del dominio y no de un
    número fijo: es el cupo lo que manda, y pedir más de lo que hay sólo
    produciría un lote parcial garantizado.

    `batch_id` llega cuando alguien pidió la inspección desde la pantalla o la
    API: el lote ya existe en `QUEUED` y esta tarea lo toma. Si no llega, lo crea
    el servicio, que es el camino del ciclo automático.

    `url_ids` llega cuando la selección salió de la tabla. Se resuelven **acá y
    no al encolar**: entre el clic y la ejecución una dirección puede haber
    salido del inventario, y un lote que arrastre filas que ya no existen
    gastaría cupo en direcciones que el sitio dejó de declarar.
    """
    domain = Domain.objects.filter(id=domain_id).select_related('account').first()
    if domain is None:
        return {'skipped': 'el dominio ya no existe'}

    batch = _queued_batch(batch_id)
    if batch_id and batch is None:
        # El lote se borró entre el encolado y la ejecución, o ya lo tomó otro
        # trabajador. Crear uno nuevo duplicaría el trabajo y el gasto de cupo.
        return {'skipped': 'el lote ya no está en cola'}

    try:
        batch = inspect_domain(
            domain,
            limit=limit or domain.automatic_budget,
            origin=origin,
            batch=batch,
            urls=_selected_urls(domain, url_ids),
        )
    except ApiError as exc:
        logger.warning('No se inspeccionó %s: %s', domain.hostname, exc.message)
        _fail(batch, exc)
        return {'skipped': exc.code, 'detail': exc.message}

    return {'batch_id': str(batch.id), 'state': batch.state, 'summary': batch.summary}


@shared_task(name='apps.coverage.tasks.build_export')
def build_export(export_id: str) -> dict:
    """
    Arma el archivo de una exportación encolada (FR-058).

    Se toma sólo si el lote sigue en cola, igual que la inspección: Celery
    reconoce las tareas al terminar, así que una que muera a mitad vuelve, y sin
    esa condición el segundo intento reescribiría un archivo que alguien ya
    puede estar bajando.
    """
    from apps.coverage.models import CoverageExport
    from apps.coverage.services import write_export

    export = (
        CoverageExport.objects.filter(id=export_id).select_related('batch', 'batch__domain').first()
    )
    if export is None:
        return {'skipped': 'la exportación ya no existe'}

    if not _queued_batch(str(export.batch_id)):
        return {'skipped': 'el lote ya no está en cola'}

    export = write_export(export)
    return {'batch_id': str(export.batch_id), 'state': export.batch.state, 'rows': export.row_count}


@shared_task(name='apps.coverage.tasks.purge_exports')
def purge_exports() -> dict:
    """Aplica la retención de los archivos de exportación."""
    from apps.coverage.services import purge_expired_exports

    return {'removed': purge_expired_exports()}


def _selected_urls(domain, url_ids: list[str] | None):
    """
    Las direcciones que alguien marcó, o `None` para que el servicio elija.

    `None` y lista vacía significan cosas distintas y no se pueden confundir:
    sin selección el lote recorre lo pendiente, y con una selección que ya no
    existe no recorre nada. Devolver `[]` en el primer caso convertiría un
    recorrido completo en uno vacío.

    **El dominio entra en el filtro.** Los identificadores llegan del navegador,
    así que sin esa condición alguien podría hacer que un lote de su dominio
    consulte —y muestre— direcciones de otro.
    """
    if url_ids is None:
        return None

    from apps.sitemaps.models import Url

    return list(Url.objects.filter(domain=domain, id__in=url_ids).order_by('loc'))


def _queued_batch(batch_id: str | None):
    """
    El lote encolado, sólo si sigue en cola.

    Exigir `QUEUED` es lo que hace inofensivo un reintento: Celery reconoce las
    tareas al terminar, así que una que muera a mitad vuelve a la cola, y sin
    esta condición el segundo intento reprocesaría un lote que ya corrió y
    gastaría el cupo dos veces.
    """
    if not batch_id:
        return None

    from apps.jobs.models import Batch, BatchState

    return Batch.objects.filter(id=batch_id, state=BatchState.QUEUED).first()


def _fail(batch, exc: ApiError) -> None:
    """
    Cierra en `FAILED` el lote que alguien está mirando.

    Sin esto, un dominio que perdió el acceso entre el clic y la ejecución
    dejaría el lote en «en curso» para siempre, y la pantalla consultando cada
    cinco segundos un estado que no va a cambiar nunca (RT-13).
    """
    if batch is None:
        return

    from django.utils import timezone

    from apps.jobs.models import BatchState

    batch.state = BatchState.FAILED
    batch.finished_at = timezone.now()
    batch.summary = {'errors': [exc.message]}
    batch.save(update_fields=['state', 'finished_at', 'summary', 'updated_at'])
