"""
Paginado, orden y filtrado del lado del servidor para las tablas largas (RT-09).

Seis de las doce vistas listan tablas que en un sitio real tienen decenas de
miles de filas. Traerlas enteras al navegador y ordenarlas ahí no es una opción:
la página tardaría en aparecer y el orden mentiría —sería el orden de lo que se
alcanzó a mandar, no el del conjunto—. Así que el servidor pagina, ordena y
filtra, y la dirección de la vista es la que dice qué se está mirando.

De acá sale la otra mitad de RT-09: como el estado vive en la querystring y no
en el componente, la vista es enlazable, el botón Atrás del navegador funciona y
recargar no pierde el filtro.

El punto delicado es el orden. `order_by` recibe un nombre de campo y lo lleva
tal cual a la base, así que aceptar el que venga del navegador es dejar que
cualquiera recorra el modelo —relaciones incluidas— eligiendo por dónde ordenar.
Por eso cada tabla declara su lista blanca acá: lo que llega por la querystring
es una **clave pública** que se traduce a campos, nunca un campo.
"""

import re
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from typing import Any

from django.core.paginator import Paginator
from django.db.models import QuerySet
from django.http import HttpRequest

#: Filas por página, iguales en todas las tablas del producto (RT-09).
#:
#: Es una constante y no un parámetro de la querystring a propósito: si el
#: tamaño de página fuera elegible, el `count` dejaría de ser comparable entre
#: pantallas y bastaría con pedir `per_page=100000` para volver al problema que
#: este módulo evita.
PER_PAGE = 50


@dataclass(frozen=True)
class Filter:
    """
    Un filtro que la tabla acepta desde la querystring.

    `param` es el nombre público —el que viaja en la dirección y el que el
    frontend usa como id de columna— y `lookup` la expresión que va al ORM. El
    navegador elige entre los filtros declarados; nunca nombra un campo.

    `choices` vacío significa texto libre: sirve para una búsqueda, donde el
    valor no se puede enumerar pero el campo sobre el que se busca sí está
    fijado por `lookup`. `cast` traduce el texto de la dirección al valor que
    espera la base; si falla, el filtro se descarta en vez de romper la vista.

    **Los valores enumerados viajan en minúscula y con guiones.** En la base son
    `TextChoices` de Django, que por convención se escriben `BATCH_FINISHED`; en
    la dirección eso se lee como una variable escapada de un archivo de código.
    La traducción vive acá y no en los modelos: cambiarlos obligaría a migrar
    diez tablas para arreglar un detalle de presentación.
    """

    param: str
    lookup: str
    choices: tuple[str, ...] = ()
    cast: Callable[[str], Any] = str
    multiple: bool = False

    def public_values(self) -> dict[str, str]:
        """Qué valor de base le corresponde a cada forma pública."""
        return {slug(choice): choice for choice in self.choices}


def slug(value: str) -> str:
    """La forma pública de un valor enumerado: `BATCH_FINISHED` → `batch-finished`."""
    return value.lower().replace('_', '-')


def from_slug(value: str, choices: Iterable[str]) -> str:
    """
    El valor de base que corresponde a una forma pública, o vacío si no hay.

    Para los pocos lugares que leen un filtro sin pasar por `TableSpec` —la API
    pública lo hace— y que aun así tienen que entender la misma dirección que la
    pantalla. Un valor inventado devuelve vacío, que es «sin filtrar»: el mismo
    trato que le da la tabla.
    """
    return {slug(choice): choice for choice in choices}.get(value, '')


def filter_options(choices: Iterable[tuple[str, str]]) -> list[dict[str, str]]:
    """
    Las opciones de un eje, con el valor ya en su forma pública.

    Las arma acá y no cada vista para que el valor que se dibuja en el control y
    el que el servidor sabe leer sean el mismo por construcción. Una vista que
    publique el valor crudo produce enlaces que el filtro descarta en silencio:
    la lista vuelve completa y nada falla.
    """
    return [{'value': slug(value), 'label': label} for value, label in choices]


@dataclass(frozen=True)
class TableSpec:
    """
    Lo que una tabla concreta declara: por qué se puede ordenar y por qué filtrar.

    `sortable` va de clave pública a los campos del ORM **en orden ascendente**.
    Son varios campos y no uno porque un orden útil suele necesitar un
    desempate con sentido —el estado de acceso primero y el nombre después—, y
    porque el descendente se obtiene invirtiendo todos, no sólo el primero.

    Sin `default_sort` las filas salen por clave primaria. Eso es aburrido pero
    es estable, que es lo que una tabla paginada necesita.
    """

    sortable: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
    default_sort: str = ''
    filters: tuple[Filter, ...] = ()
    per_page: int = PER_PAGE


def _split(value: str) -> tuple[str, bool]:
    """Separa la clave de su signo. El prefijo `-` es descendente, como en Django."""
    descending = value.startswith('-')
    return value.removeprefix('-'), descending


def _invert(fields: tuple[str, ...]) -> tuple[str, ...]:
    """Da vuelta un orden completo, campo por campo."""
    return tuple(f.removeprefix('-') if f.startswith('-') else f'-{f}' for f in fields)


def _sort(spec: TableSpec, raw: str) -> tuple[str, tuple[str, ...]]:
    """
    Resuelve el orden vigente contra la lista blanca.

    Devuelve la clave pública tal como debe volver a la dirección y los campos
    que van al ORM. Una clave que no está declarada no es un error del usuario
    —casi siempre es un enlace de otra versión de la vista—, así que se descarta
    en silencio y manda el orden por omisión. Lo que nunca pasa es que llegue a
    `order_by`.
    """
    for candidate in (raw.strip(), spec.default_sort):
        key, descending = _split(candidate)
        fields = spec.sortable.get(key)
        if fields:
            return (f'-{key}' if descending else key), (_invert(fields) if descending else fields)

    return '', ()


def _accepted(spec: TableSpec, params: Mapping[str, str]) -> list[tuple[Filter, str, Any]]:
    """Filtros que sobreviven la validación, con su valor público y el que va al ORM."""
    accepted: list[tuple[Filter, str, Any]] = []

    for table_filter in spec.filters:
        public = (params.get(table_filter.param) or '').strip()
        if not public:
            continue

        # Enumerado: la dirección trae la forma pública y la base espera la suya.
        # Lo que no está en el mapa se descarta, así que el juego sigue siendo
        # cerrado y nadie nombra un valor que el modelo no declara.
        #
        # El `cast` se aplica igual, y sobre el valor ya traducido: hay filtros
        # que lo usan de verdad —`group` convierte una clave en la lista de
        # estados que agrupa, `sitemap` convierte «yes» en un booleano— y
        # saltearlo les pasaría al ORM la clave en vez del valor.
        if table_filter.multiple:
            if not table_filter.choices:
                continue

            public_values = table_filter.public_values()
            accepted_public = []
            accepted_values = []
            for candidate in dict.fromkeys(part.strip() for part in public.split(',')):
                raw = public_values.get(candidate)
                if raw is None:
                    continue
                try:
                    value = table_filter.cast(raw)
                except (TypeError, ValueError, KeyError):
                    continue
                accepted_public.append(candidate)
                accepted_values.append(value)

            if accepted_values:
                accepted.append((table_filter, ','.join(accepted_public), accepted_values))
            continue

        if table_filter.choices:
            raw = table_filter.public_values().get(public)
            if raw is None:
                continue
        else:
            raw = public

        try:
            value = table_filter.cast(raw)
        except (TypeError, ValueError, KeyError):
            continue
        accepted.append((table_filter, public, value))

    return accepted


def apply_filters(queryset: QuerySet, spec: TableSpec, params: Mapping[str, str]) -> QuerySet:
    """
    Aplica a un queryset los filtros vigentes, sin paginar ni ordenar.

    Existe para lo que necesita el mismo recorte que la tabla pero no la tabla:
    la exportación se tiene que llevar **exactamente** lo que está en pantalla
    (FR-058), y un archivo con otro filtro que el que se estaba mirando es peor
    que no exportar nada, porque nadie lo vuelve a revisar. Que salga de la misma
    declaración que usa `table_props()` es lo que garantiza que sean el mismo.
    """
    for table_filter, _public, value in _accepted(spec, params):
        lookup = f'{table_filter.lookup}__in' if table_filter.multiple else table_filter.lookup
        queryset = queryset.filter(**{lookup: value})
    return queryset


def applied_filters(spec: TableSpec, params: Mapping[str, str]) -> dict[str, str]:
    """
    Los filtros vigentes con su valor público, ya validados.

    Sólo los que tienen valor. Es lo que se guarda junto a un trabajo diferido
    para poder decir después qué recorte produjo su resultado, y ahí las claves
    vacías serían ruido.
    """
    return {table_filter.param: public for table_filter, public, _value in _accepted(spec, params)}


#: Cómo se llama en la dirección la posición de una tabla, con su prefijo o sin él.
_POSITION = re.compile(r'^(?P<prefix>\w*?)(?P<name>page|sort)$')

#: Una clave de orden posible. No se comprueba contra la lista blanca de nadie:
#: acá sólo se decide si el valor puede volver a una dirección, y de validarlo
#: contra su tabla se encarga esa tabla.
_SORT_KEY = re.compile(r'^-?[a-z][a-z0-9_]{0,39}$')


def _siblings(params: Mapping[str, str], prefix: str) -> dict[str, str]:
    """
    La posición de las **otras** listas de la página.

    Una página con dos listas tiene dos posiciones en la misma querystring, y
    cada tabla arma sus enlaces sobre la suya. Sin esto, pasar a la página 2 de
    una manda a la otra de vuelta al principio: no se pisan la clave, pero se
    pisan igual, borrándosela.

    La regla que viene con el prefijo es que **si hay dos listas, las dos lo
    llevan**. Un `page` sin prefijo en una página con dos tablas no se puede
    atribuir a ninguna, así que no se arrastra: arrastrarlo sería devolver la
    ambigüedad que el prefijo saca.

    Lo que se arrastra se comprueba antes. Es valor ajeno que vuelve a una
    dirección, y este módulo no reemite nada que no haya mirado.
    """
    if not prefix:
        return {}

    kept: dict[str, str] = {}
    for key, value in params.items():
        found = _POSITION.match(key)
        if not found or not found['prefix'] or found['prefix'] == prefix:
            continue
        if found['name'] == 'page' and not value.isdigit():
            continue
        if found['name'] == 'sort' and not _SORT_KEY.match(value):
            continue
        kept[key] = value

    return kept


def table_props(
    request: HttpRequest,
    queryset: QuerySet,
    spec: TableSpec,
    *,
    serialize: Callable[[Any], dict],
    extra_query: dict[str, str] | None = None,
    prefix: str = '',
) -> dict:
    """
    Arma las props que consume `C-11 DataTable` a partir de la querystring.

    `serialize` es obligatorio porque lo que viaja a Inertia son datos, no
    instancias del ORM: pedirlo acá evita que una vista mande objetos y se
    entere en el navegador.

    `extra_query` es para el recorte que una vista aplica por su cuenta y que no
    es un filtro declarado —«sólo los sin leer», por ejemplo—. Sin esto, ese
    recorte no sobrevive a un clic: la página dos del recorte devolvería la
    página dos de todo, en silencio y sin que nada falle.

    `prefix` es para las páginas con **dos listas**. Sin él las dos leen `page`
    y `sort` de la misma querystring, así que pasar a la página 2 de una manda a
    la página 2 de la otra y ordenar una desordena las dos. Con
    `prefix='revoked_'` esa tabla lee y escribe `revoked_page` y `revoked_sort`,
    y tiene que recibir el mismo prefijo en el `paramPrefix` de `DataTable`. La
    posición de la otra lista viaja sola en los enlaces de ésta (`_siblings`),
    que es la otra mitad de convivir.

    Los filtros no se prefijan: cada tabla los declara con el nombre que quiere
    en su `TableSpec`, así que ya son distintos por construcción.
    """
    accepted = _accepted(spec, request.GET)
    for table_filter, _public, value in accepted:
        lookup = f'{table_filter.lookup}__in' if table_filter.multiple else table_filter.lookup
        queryset = queryset.filter(**{lookup: value})

    sort, fields = _sort(spec, request.GET.get(f'{prefix}sort', ''))

    # El desempate por clave primaria cierra el orden. Sin él, dos filas iguales
    # en el campo elegido pueden alternarse entre una consulta y la siguiente, y
    # entonces una fila aparece dos veces en la página 3 y ninguna en la 4.
    queryset = queryset.order_by(*fields, 'pk')

    paginator = Paginator(queryset, spec.per_page)
    # `get_page` es lo que evita el error: una página fuera de rango devuelve la
    # última y algo que no es número devuelve la primera. Pedir la página 900 de
    # una tabla que tiene 25 casi nunca es un ataque: es un enlace guardado, o un
    # filtro que acaba de achicar el total mientras alguien miraba el final.
    page = paginator.get_page(request.GET.get(f'{prefix}page'))

    # Todos los filtros declarados salen, con su valor o vacíos. La forma es
    # siempre la misma, así que la vista puede leer `filters['state']` sin
    # preguntarse antes si la clave existe.
    filters = {f.param: '' for f in spec.filters}
    filters.update({f.param: public for f, public, _value in accepted})

    # La querystring ya saneada. El frontend arma cada enlace —otra página, otro
    # orden, otro filtro— pisando una clave sobre esta base, así que lo que no
    # esté acá no puede sobrevivir a un clic.
    query: dict[str, str | int] = {param: value for param, value in filters.items() if value}
    query.update(extra_query or {})
    query.update(_siblings(request.GET, prefix))
    # El orden por omisión no viaja: `/domains` tiene que poder enlazarse tal
    # cual, y una dirección que fija el orden que nadie eligió lo congela —el día
    # que cambie el criterio, los enlaces guardados seguirían con el viejo—.
    if sort and sort != spec.default_sort:
        query[f'{prefix}sort'] = sort
    if page.number > 1:
        query[f'{prefix}page'] = page.number

    return {
        'rows': [serialize(obj) for obj in page.object_list],
        'count': paginator.count,
        'page': page.number,
        'pages': paginator.num_pages,
        'per_page': spec.per_page,
        'sort': sort,
        # Las claves ordenables viajan porque el frontend no puede deducirlas:
        # si una columna se declarara ordenable en React sin estar declarada acá,
        # el encabezado ofrecería un orden que el servidor descarta en silencio.
        'sortable': sorted(spec.sortable),
        'filters': filters,
        'path': request.path,
        'query': query,
    }
