"""
Sincronización de sitemaps: leerlos, descubrir sus hijos y enviarlos a Google.

Dos reglas gobiernan el archivo. La primera: un sitemap sólo se envía si su
contenido cambió desde el último envío exitoso. Reenviar lo mismo todos los días
no le aporta nada a Google y gasta presupuesto del dominio, que después falta
para lo que sí importa.

La segunda: una URL que desaparece del sitemap **no se borra**, se marca. Su
historial de cobertura sigue siendo cierto, y borrarlo haría imposible responder
después por qué una página dejó de aparecer.
"""

import logging
from dataclasses import dataclass, field
from urllib.parse import urlparse

from django.db import transaction
from django.utils import timezone

from apps.billing import limits
from apps.core.errors import ValidationFailed
from apps.domains.models import PropertyType
from apps.domains.services import ensure_operational
from apps.jobs.budget import Origin, release, reserve
from apps.jobs.models import Batch, BatchKind, BatchOrigin, BatchState
from apps.sitemaps.models import (
    Sitemap,
    SitemapError,
    SitemapKind,
    SitemapSource,
    SubmitResult,
    Url,
)
from apps.sitemaps.reader import SitemapUnreadable, read

logger = logging.getLogger(__name__)

#: Cuántos niveles de índices se siguen. Un índice que apunta a otro índice es
#: legítimo; una cadena más larga que esto casi siempre es un ciclo, y seguirlo
#: dejaría al trabajador dando vueltas para siempre.
MAX_DEPTH = 3


@dataclass
class SyncSummary:
    """Lo que pasó en una corrida, en el vocabulario del usuario."""

    sitemaps_read: int = 0
    sitemaps_submitted: int = 0
    sitemaps_unchanged: int = 0
    new_urls: int = 0
    seen_urls: int = 0
    urls_out_of_sitemap: int = 0
    foreign_urls: int = 0
    errors: list[str] = field(default_factory=list)

    def as_dict(self) -> dict:
        # Las claves siguen al nombre del atributo. Esto es lo que se guarda en
        # `Batch.summary`, así que `jobs/migrations/0002_summary_keys_in_english`
        # reescribió los lotes que ya estaban con las claves viejas: no hay dos
        # vocabularios conviviendo.
        return {
            'sitemaps_read': self.sitemaps_read,
            'sitemaps_submitted': self.sitemaps_submitted,
            'sitemaps_unchanged': self.sitemaps_unchanged,
            'new_urls': self.new_urls,
            'seen_urls': self.seen_urls,
            'urls_out_of_sitemap': self.urls_out_of_sitemap,
            'foreign_urls': self.foreign_urls,
            'errors': self.errors,
        }


def register_sitemap(domain, location: str) -> Sitemap:
    """
    Registra un sitemap declarado a mano.

    No lo lee. Quién lo lee depende de por dónde se dé de alta: el alta directa
    llama antes a `check_sitemap()` para poder rechazar en el acto, y el
    recorrido guiado lo lee después con su propio tratamiento, porque ahí un
    archivo que todavía no está publicado es un paso pendiente y no un dato
    inválido.
    """
    sitemap, _ = Sitemap.objects.get_or_create(
        domain=domain,
        location=location.strip(),
        defaults={'source': SitemapSource.DECLARED},
    )
    return sitemap


#: Cuánto se espera al sitio al dar de alta un sitemap.
#:
#: Más corto que el de la sincronización, que corre en un trabajador y puede
#: esperar. Acá hay alguien frente a un formulario, y veinte segundos mirando un
#: botón en «Guardando…» se leen como que la pantalla se colgó.
REGISTER_TIMEOUT_SECONDS = 10.0


def check_sitemap(domain, location: str, *, http=None) -> None:
    """
    Comprueba que la dirección sea un sitemap legible de este dominio (T094).

    Es la diferencia entre enterarse al pegar la dirección y enterarse al día
    siguiente: sin esto, un `sitemap.xml` mal tipeado se guarda sin chistar y el
    error aparece recién en el resumen del lote de la madrugada.

    Rechaza el archivo que no se puede descargar y el que no es un sitemap. Con
    las URLs ajenas es más cuidadoso: que un sitemap declare algunas direcciones
    de otro sitio es común y no lo invalida —la sincronización las descarta con
    su aviso—, así que sólo se rechaza cuando **ninguna** pertenece al dominio,
    que es el caso en que se pegó el sitemap de otro sitio.

    Un índice no se juzga por sus URLs: lo que declara son otros sitemaps, y de
    cada uno de ellos se ocupa la sincronización.
    """
    try:
        content = read(location, client=http, timeout=REGISTER_TIMEOUT_SECONDS)
    except SitemapUnreadable as exc:
        # El código viaja en los detalles porque quien lo muestra tiene que
        # separar «revisá la dirección» de «revisá el archivo» sin leer la
        # oración (RT-08).
        raise ValidationFailed(str(exc), field='location', reason=exc.code) from exc

    if content.is_index or not content.locations:
        return

    if not any(belongs_to_domain(domain, loc) for loc in content.locations):
        raise ValidationFailed(
            f'Ninguna de las {len(content.locations)} URLs que declara este sitemap pertenece a '
            f'«{domain.property_uri}». Revisá que sea el sitemap de este dominio.',
            field='location',
            reason=SitemapError.FOREIGN_URLS,
        )


def queue_sync(domain, *, origin: str = BatchOrigin.SCHEDULED, idempotency_key: str = '') -> Batch:
    """
    Deja el lote en cola y encola el trabajo. No sincroniza nada todavía.

    Es lo que usan la API y la pantalla, que contestan «encolado» y necesitan
    devolver el lote en el acto. Antes las dos corrían la sincronización entera
    dentro de la petición y respondían `202` igual: un sitio con muchos sitemaps
    se pasaba del tiempo de espera, y la respuesta afirmaba algo que no había
    pasado.

    El lote se crea acá y no dentro de la tarea porque quien pide tiene que
    poder mirar su estado enseguida. Nace en `QUEUED`, que es la verdad: todavía
    no empezó.
    """
    ensure_operational(domain)

    batch = Batch.objects.create(
        domain=domain,
        kind=BatchKind.SITEMAP_SYNC,
        origin=origin,
        state=BatchState.QUEUED,
        idempotency_key=idempotency_key or None,
    )

    # Importación diferida: el módulo de tareas importa este servicio.
    from apps.sitemaps.tasks import sync_one_domain

    sync_one_domain.delay(str(domain.id), origin=origin, batch_id=str(batch.id))
    return batch


def sync_domain(
    domain, *, origin: str = BatchOrigin.SCHEDULED, client=None, http=None, batch=None
) -> Batch:
    """
    Lee todos los sitemaps del dominio, registra sus URLs y envía los que cambiaron.

    El lote se crea antes de empezar y se cierra pase lo que pase: sin eso, un
    fallo a mitad de camino dejaría el trabajo en «en curso» para siempre y la
    pantalla consultando un estado que nunca va a cambiar.

    `batch` llega cuando el trabajo se encoló antes: la petición ya creó el lote
    en `QUEUED` para poder devolverlo, y acá se lo toma en vez de crear un
    segundo. Sin ese parámetro, lo que la persona ve en pantalla y lo que el
    trabajador ejecuta serían dos lotes distintos.
    """
    ensure_operational(domain)

    if batch is None:
        batch = Batch.objects.create(
            domain=domain,
            kind=BatchKind.SITEMAP_SYNC,
            origin=origin,
            state=BatchState.RUNNING,
            started_at=timezone.now(),
        )
    else:
        batch.state = BatchState.RUNNING
        batch.started_at = timezone.now()
        batch.save(update_fields=['state', 'started_at', 'updated_at'])

    summary = SyncSummary()
    seen: set[str] = set()

    try:
        roots = list(Sitemap.objects.filter(domain=domain, source=SitemapSource.DECLARED))
        batch.total_items = len(roots)
        batch.save(update_fields=['total_items', 'updated_at'])

        for root in roots:
            _process(domain, root, summary, seen, batch, client=client, http=http, depth=0)

        summary.urls_out_of_sitemap = _mark_missing_from_sitemaps(domain, seen)

    except Exception as exc:
        logger.exception('Falló la sincronización de %s', domain.hostname)
        summary.errors.append(str(exc))

    return _close(batch, summary)


def _process(domain, sitemap, summary, seen, batch, *, client, http, depth) -> None:
    """Lee un sitemap, sigue sus hijos si es un índice y registra sus URLs."""
    if depth > MAX_DEPTH:
        summary.errors.append(
            f'Se dejó de seguir «{sitemap.location}»: los índices se encadenan más de '
            f'{MAX_DEPTH} niveles, que casi siempre significa un ciclo.'
        )
        return

    try:
        content = read(sitemap.location, client=http)
    except SitemapUnreadable as exc:
        # No se toca `last_submit_result`: lo que falló fue la lectura, y el
        # resultado del último envío sigue siendo el que fue. Marcarlo como
        # fallado acá pondría «Falló» al lado de la fecha de un envío que sí
        # salió bien, que es la pregunta que trae al usuario a esta pantalla.
        sitemap.last_error = str(exc)
        sitemap.last_error_code = exc.code
        sitemap.save(update_fields=['last_error', 'last_error_code', 'updated_at'])
        summary.errors.append(f'{sitemap.location}: {exc}')
        batch.failed_items += 1
        return

    sitemap.kind = SitemapKind.INDEX if content.is_index else SitemapKind.URLSET
    sitemap.content_hash = content.content_hash
    sitemap.url_count = content.count
    sitemap.last_read_at = timezone.now()
    sitemap.last_error = ''
    sitemap.last_error_code = ''
    # Se vuelve a cero antes de contar: si el sitio corrigió el archivo, el
    # aviso de la corrida anterior tiene que desaparecer solo. `_register_urls`
    # lo deja en el número de esta lectura.
    sitemap.foreign_url_count = 0
    sitemap.save(
        update_fields=[
            'kind',
            'content_hash',
            'url_count',
            'last_read_at',
            'last_error',
            'last_error_code',
            'foreign_url_count',
            'updated_at',
        ]
    )
    summary.sitemaps_read += 1
    batch.processed_items += 1

    if content.is_index:
        # Un índice se envía él mismo y se recorren sus hijos para leer URLs.
        # Enviar los hijos por separado sería decirle a Google lo mismo dos
        # veces y gastar el doble de cupo.
        _submit_if_changed(domain, sitemap, summary, batch, client=client)
        for location in content.locations:
            child, _ = Sitemap.objects.get_or_create(
                domain=domain,
                location=location,
                defaults={'parent': sitemap, 'source': SitemapSource.DISCOVERED},
            )
            _process(domain, child, summary, seen, batch, client=client, http=http, depth=depth + 1)
        return

    _register_urls(domain, sitemap, content.locations, summary, seen, content.last_modified)
    _submit_if_changed(domain, sitemap, summary, batch, client=client)


def belongs_to_domain(domain, loc: str) -> bool:
    """
    Si la URL cae dentro de la propiedad del dominio.

    Un sitemap puede declarar cualquier cosa, incluidas direcciones de otro
    sitio. Registrarlas sería peor que inútil: se gastaría el cupo del usuario
    consultando URLs sobre las que su propiedad de Search Console no tiene nada
    que decir, y Google devolvería un error por cada una.

    La propiedad de dominio cubre todos sus subdominios; la de prefijo de URL
    cubre exactamente lo que dice, ni un carácter más.
    """
    if domain.property_type == PropertyType.DOMAIN:
        host = urlparse(loc).hostname or ''
        return host == domain.hostname or host.endswith(f'.{domain.hostname}')

    return loc.startswith(domain.property_uri)


@transaction.atomic
def _register_urls(domain, sitemap, locations, summary, seen, last_modified=None) -> None:
    now = timezone.now()
    declared = last_modified or {}
    own = [loc for loc in locations if belongs_to_domain(domain, loc)]

    foreign = len(locations) - len(own)
    if foreign:
        summary.foreign_urls += foreign
        # Queda también en la fila y no sólo en el resumen del lote: el lote se
        # va a ver reemplazado por el de la próxima corrida, y entonces nadie
        # podría responder cuál de los sitemaps era el que declaraba URLs de
        # otro sitio.
        sitemap.foreign_url_count = foreign
        sitemap.save(update_fields=['foreign_url_count', 'updated_at'])
        # La dirección va adentro de la frase y no adelante: la ficha del lote
        # separa las fallas de las notas mirando si la anotación empieza con una
        # URL, y esto es una nota —el archivo se leyó bien y se envió— que no
        # incrementa `failed_items`. Escrita como «dirección: motivo» se leía
        # como un intento fallido y aparecía bajo «Los ítems que fallaron».
        summary.errors.append(
            f'Se descartaron {foreign} URLs declaradas en «{sitemap.location}» que no '
            f'pertenecen a «{domain.property_uri}».'
        )

    existing = {url.loc: url for url in Url.objects.filter(domain=domain, loc__in=own)}

    new = []
    for loc in own:
        seen.add(loc)
        summary.seen_urls += 1

        modified = declared.get(loc)

        url = existing.get(loc)
        if url is None:
            new.append(
                Url(
                    domain=domain,
                    sitemap=sitemap,
                    loc=loc,
                    in_sitemap=True,
                    last_seen_in_sitemap_at=now,
                    lastmod=modified,
                    # La primera vez que la vemos no es un cambio: es la fecha
                    # que el sitio ya declaraba. Anotarla como novedad haría que
                    # una sincronización inicial marcara el sitio entero como
                    # recién modificado.
                    lastmod_seen_at=None,
                )
            )
            continue

        fields = ['sitemap', 'in_sitemap', 'last_seen_in_sitemap_at', 'updated_at']
        url.sitemap = sitemap
        url.in_sitemap = True
        url.last_seen_in_sitemap_at = now

        # Se escribe **sólo cuando cambia**, y un `lastmod` que desaparece del
        # sitemap no borra el que teníamos: el sitio dejó de declararlo, que no
        # es lo mismo que decir que la página nunca cambió.
        if modified is not None and modified != url.lastmod:
            url.lastmod = modified
            url.lastmod_seen_at = now
            fields.extend(['lastmod', 'lastmod_seen_at'])

        url.save(update_fields=fields)

    if new:
        Url.objects.bulk_create(new, ignore_conflicts=True)
        summary.new_urls += len(new)
        limits.check(domain.account, limits.Action.MONITOR_URLS, len(new), domain=domain)


def _mark_missing_from_sitemaps(domain, seen: set[str]) -> int:
    """
    Marca como fuera del sitemap las que dejaron de aparecer, sin borrarlas.

    Se marcan sólo si hubo algo que ver: un sitemap que falló al descargarse
    dejaría el conjunto vacío, y marcar todas las URLs del sitio como
    desaparecidas por un error de red sería un desastre silencioso.
    """
    if not seen:
        return 0

    return (
        Url.objects.filter(domain=domain, in_sitemap=True)
        .exclude(loc__in=seen)
        .update(in_sitemap=False, updated_at=timezone.now())
    )


def _submit_if_changed(domain, sitemap, summary, batch, *, client) -> None:
    """Envía el sitemap a Google sólo si difiere del último enviado con éxito."""
    if not sitemap.needs_submit:
        sitemap.last_submit_result = SubmitResult.SKIPPED_UNCHANGED
        sitemap.save(update_fields=['last_submit_result', 'updated_at'])
        summary.sitemaps_unchanged += 1
        return

    from apps.gsc.client import SearchConsoleClient
    from apps.gsc.errors import GoogleCallError

    reservation = reserve(domain, 1, origin=Origin.AUTOMATIC)
    if not reservation:
        summary.errors.append(
            f'No quedaba cupo para enviar «{sitemap.location}». Se retoma en el próximo ciclo.'
        )
        return

    client = client or SearchConsoleClient(domain.account)

    try:
        client.submit_sitemap(
            property_uri=domain.property_uri, location=sitemap.location, reservation=reservation
        )
    except GoogleCallError as exc:
        release(reservation, 1)
        sitemap.last_submit_result = SubmitResult.FAILED
        sitemap.last_error = exc.message
        sitemap.last_error_code = SitemapError.SUBMIT_FAILED
        sitemap.save(
            update_fields=['last_submit_result', 'last_error', 'last_error_code', 'updated_at']
        )
        summary.errors.append(f'{sitemap.location}: {exc.message}')
        batch.failed_items += 1
        return

    sitemap.last_submitted_at = timezone.now()
    sitemap.submitted_content_hash = sitemap.content_hash
    sitemap.last_submit_result = SubmitResult.OK
    sitemap.last_error = ''
    sitemap.last_error_code = ''
    sitemap.save(
        update_fields=[
            'last_submitted_at',
            'submitted_content_hash',
            'last_submit_result',
            'last_error',
            'last_error_code',
            'updated_at',
        ]
    )
    summary.sitemaps_submitted += 1
    batch.quota_consumed += 1
    limits.check(domain.account, limits.Action.SUBMIT_SITEMAPS, 1, domain=domain)


def _close(batch: Batch, summary: SyncSummary) -> Batch:
    """
    Cierra el lote con el estado que corresponde.

    `PARTIAL` no es un adorno: si hubo errores, decir «terminado» convertiría un
    resultado incompleto en una afirmación falsa sobre el estado del sitio.
    """
    if summary.errors and summary.sitemaps_read == 0:
        batch.state = BatchState.FAILED
    elif summary.errors:
        batch.state = BatchState.PARTIAL
    else:
        batch.state = BatchState.COMPLETED

    batch.finished_at = timezone.now()
    batch.summary = summary.as_dict()
    batch.save(
        update_fields=[
            'state',
            'finished_at',
            'summary',
            'processed_items',
            'failed_items',
            'quota_consumed',
            'updated_at',
        ]
    )

    # Igual que en la inspección: el aviso sale de donde el lote se cierra, que
    # es el único punto por el que pasan las tres maneras de dispararlo.
    from apps.notifications import services as notifications

    notifications.batch_finished(batch)
    return batch
