"""
Lectura de un sitemap: descarga y análisis del XML.

Esto sale a internet pero **no habla con Google**: descarga el sitio del propio
usuario. Por eso no pasa por el cliente de Search Console ni consume presupuesto
de cuota; el límite de Google es sobre sus APIs, no sobre nuestro tráfico hacia
un sitio ajeno a él.

Lo que sí lleva es un tope de tamaño y un tiempo máximo. Un sitemap es un
archivo del usuario y nada garantiza que sea razonable: sin tope, uno de un
gigabyte llenaría la memoria del trabajador y voltearía la cola entera.
"""

import gzip
import hashlib
from dataclasses import dataclass, field
from datetime import UTC, datetime
from xml.etree import ElementTree

import httpx

from apps.sitemaps.models import SitemapError

#: Espacio de nombres del protocolo de sitemaps. Algunos sitios lo omiten, así
#: que las etiquetas se buscan por nombre local y no por nombre calificado.
TIMEOUT_SECONDS = 20.0

#: Tope de descarga. El límite del protocolo es 50 MB sin comprimir; se toma ese
#: mismo número porque más que eso ya no es un sitemap válido.
MAX_BYTES = 50 * 1024 * 1024

MAX_URLS_PER_SITEMAP = 50_000

USER_AGENT = 'tools-patiospools/1.0 (lector de sitemaps)'


class SitemapUnreadable(Exception):
    """
    No se pudo leer el sitemap, con el motivo ya redactado.

    El motivo importa: «no responde», «devolvió un 404» y «no es XML válido» se
    arreglan en lugares distintos, y ninguno de los tres es culpa de la
    plataforma.

    Además del texto viaja el código, porque quien lo muestra tiene que separar
    «revisá la dirección» de «revisá el archivo» sin leer la oración: el mensaje
    trae el dato concreto, el código trae la clase de problema.
    """

    def __init__(self, message: str, *, code: str = SitemapError.UNREACHABLE):
        super().__init__(message)
        self.code = code


@dataclass
class SitemapContent:
    """Lo que un sitemap declara, ya interpretado."""

    is_index: bool
    locations: list[str] = field(default_factory=list)
    content_hash: str = ''
    #: Cuándo dice el sitio que cambió cada dirección, por dirección.
    #:
    #: Va como diccionario aparte y no como lista paralela a `locations`: una
    #: segunda lista que hay que mantener del mismo largo se desalinea en cuanto
    #: alguien filtra una de las dos, y el resultado sería una fecha atribuida a
    #: la URL equivocada. Sólo entran las que traen `<lastmod>` legible.
    last_modified: dict[str, datetime] = field(default_factory=dict)

    @property
    def count(self) -> int:
        return len(self.locations)


def fetch(
    location: str, *, client: httpx.Client | None = None, timeout: float = TIMEOUT_SECONDS
) -> bytes:
    """
    Descarga el sitemap, descomprimiéndolo si vino comprimido.

    `timeout` se puede acortar porque no todos los que leen esperan igual: el
    trabajador que sincroniza puede esperar veinte segundos, y quien acaba de
    pegar una dirección en un formulario, no.
    """
    owns_client = client is None
    client = client or httpx.Client(
        timeout=timeout, follow_redirects=True, headers={'User-Agent': USER_AGENT}
    )

    try:
        response = client.get(location)
    except httpx.TimeoutException as exc:
        raise SitemapUnreadable(
            f'El sitio no respondió en {timeout:.0f} segundos al pedir {location}.'
        ) from exc
    except httpx.HTTPError as exc:
        raise SitemapUnreadable(f'No se pudo conectar con {location}: {exc}.') from exc
    finally:
        if owns_client:
            client.close()

    if response.status_code >= 400:
        raise SitemapUnreadable(
            f'No pudimos descargar {location}: el servidor respondió {response.status_code}. '
            f'Revisá la dirección o que el archivo esté publicado sin pedir inicio de sesión.'
        )

    content = response.content
    if len(content) > MAX_BYTES:
        raise SitemapUnreadable(
            f'El archivo pesa más de {MAX_BYTES // (1024 * 1024)} MB, que es el máximo que '
            f'admite el protocolo de sitemaps.'
        )

    if location.endswith('.gz') or content[:2] == b'\x1f\x8b':
        try:
            content = gzip.decompress(content)
        except (OSError, EOFError) as exc:
            raise SitemapUnreadable(
                f'{location} parece comprimido pero no se pudo descomprimir.',
                code=SitemapError.MALFORMED,
            ) from exc

    return content


def parse(content: bytes) -> SitemapContent:
    """
    Interpreta el XML y devuelve las ubicaciones que declara.

    Las etiquetas se buscan por nombre local. El espacio de nombres del protocolo
    es obligatorio en teoría, pero en la práctica hay sitios que lo declaran mal
    o no lo declaran, y rechazar sus sitemaps no le sirve a nadie: el archivo se
    entiende igual.
    """
    try:
        root = ElementTree.fromstring(content)  # noqa: S314
    except ElementTree.ParseError as exc:
        raise SitemapUnreadable(
            f'El archivo se descargó pero no es XML válido: {exc}. Puede que la dirección '
            f'devuelva una página de error en vez del sitemap.',
            code=SitemapError.MALFORMED,
        ) from exc

    root_tag = _local_name(root.tag)
    if root_tag not in ('sitemapindex', 'urlset'):
        raise SitemapUnreadable(
            f'El archivo se descargó pero no es un sitemap válido: el XML no tiene la etiqueta '
            f'<urlset> ni <sitemapindex>, sino <{root_tag}>. Puede que la dirección no '
            f'apunte a un sitemap.',
            code=SitemapError.MALFORMED,
        )

    is_index = root_tag == 'sitemapindex'
    locations = []
    last_modified: dict[str, datetime] = {}

    for child in root:
        location = ''
        modified = None

        # Se recorren los hijos enteros y ya no se corta en el primer `<loc>`:
        # `<lastmod>` puede venir antes o después, y el orden no está fijado por
        # el protocolo.
        for grandchild in child:
            name = _local_name(grandchild.tag)
            if name == 'loc' and grandchild.text and not location:
                location = grandchild.text.strip()
            elif name == 'lastmod' and grandchild.text:
                modified = _parse_lastmod(grandchild.text.strip())

        if not location:
            continue

        locations.append(location)
        if modified is not None:
            last_modified[location] = modified

        if len(locations) >= MAX_URLS_PER_SITEMAP:
            break

    return SitemapContent(
        is_index=is_index,
        locations=locations,
        content_hash=hashlib.sha256(content).hexdigest(),
        last_modified=last_modified,
    )


def _parse_lastmod(text: str) -> datetime | None:
    """
    La fecha que declara el sitio, o nada si no se entiende.

    El protocolo admite desde `2026-08-25` hasta la marca con huso horario, y en
    la práctica aparece de las dos formas y de algunas más. Una fecha que no se
    entiende **se descarta en silencio**: no es un error del sitemap —el archivo
    se leyó bien y sus direcciones sirven— y tratarla como tal dejaría un sitio
    entero sin registrar por un campo que es opcional.

    Una fecha sin huso se toma como UTC. Es una suposición y hay que conocerla:
    puede correr el dato unas horas, y por eso este campo nunca se usa para
    afirmar nada por sí solo, sino para comparar contra `last_crawl_time`.
    """
    try:
        parsed = datetime.fromisoformat(text)
    except ValueError:
        return None

    if parsed.tzinfo is None:
        return parsed.replace(tzinfo=UTC)
    return parsed


def read(
    location: str, *, client: httpx.Client | None = None, timeout: float = TIMEOUT_SECONDS
) -> SitemapContent:
    """Descarga e interpreta en un paso."""
    return parse(fetch(location, client=client, timeout=timeout))


def _local_name(tag: str) -> str:
    return tag.rsplit('}', 1)[-1].lower()
