"""
Tablero de cobertura de un dominio (V7).

Es la vista donde más fácil se miente, así que el orden de lo que arma esta
función es el orden en que hay que leerlo: primero cuántas URLs tienen dato
sobre el total, después el reparto por estado con su denominador, y recién
después la tabla. Un porcentaje de indexación calculado sobre una muestra
parcial es la mentira más barata de cometer y la más cara de sostener.

Los filtros, la página, el orden y la URL abierta en el panel de historial
viajan en la dirección y no en el estado del componente: así la vista es
enlazable, el botón Atrás funciona —y cierra el panel— y recargar no pierde el
filtro (RT-09).
"""

from urllib.parse import urlencode

from django.contrib.auth.decorators import login_required
from django.db.models import OuterRef, Subquery
from django.http import FileResponse, Http404
from django.shortcuts import redirect
from django.urls import reverse
from django.views.decorators.http import require_http_methods
from inertia import render

from apps.core.errors import ApiError, as_error, stash_errors
from apps.core.requests import field, field_list, payload
from apps.core.tables import applied_filters
from apps.coverage.models import CoverageExport
from apps.coverage.services import (
    COVERAGE_TABLE,
    EXPORT_THRESHOLD,
    STATE_GROUPS,
    coverage_queryset,
    coverage_summary,
    coverage_window,
    cycle_estimate,
    export_path,
    export_props,
    queue_export,
    queue_inspection,
    retired_count,
    state_counts,
    url_history,
    with_latest_record,
)
from apps.domains.models import Domain
from apps.domains.views import shared_domain_or_404
from apps.jobs.models import (
    TERMINAL_BATCH_STATES,
    Batch,
    BatchKind,
    BatchOrigin,
    BatchState,
)
from apps.sitemaps.models import CoverageState

#: Cuántas filas de la tabla viajan a la pantalla.
#:
#: La tabla ordena, filtra y pagina **en el navegador**, así que las filas ya no
#: viajan de a una página: viajan todas juntas. Un dominio no tiene tope de URLs
#: —salen de los sitemaps— y un sitio grande convierte cada visita a esta
#: pantalla en varios megabytes de JSON que además hay que montar en React.
#:
#: El tope existe por eso, y **lo que no entra se dice en pantalla**: un recorte
#: silencioso se lee como «esto es todo lo que hay», que es justo la afirmación
#: falsa que esta vista no puede cometer. `truncated` es lo que la habilita.
TABLE_LIMIT = 2000


@login_required
def index(request, domain_id):
    domain = shared_domain_or_404(request, domain_id)

    counts = state_counts(domain)

    # La dirección abierta en el panel es un parámetro más de la querystring, no
    # un estado del navegador: el panel se puede enlazar y el botón Atrás lo
    # cierra sin tocar el filtro que hay detrás (FR-060).
    opened = (request.GET.get('url') or '').strip()

    return render(
        request,
        'Coverage/Index',
        props={
            'domain': _domain_props(domain),
            'summary': coverage_summary(domain),
            'window': coverage_window(domain),
            'cycle': cycle_estimate(domain),
            'urls': _table(domain),
            'options': _options(counts),
            # La tabla cuenta más URLs que el resumen, y la diferencia son
            # exactamente éstas. Viaja como número propio para que la pantalla
            # pueda decirlo en una frase en vez de dejar dos totales que no
            # cierran a la vista, sin explicación posible.
            'retired': retired_count(domain),
            # Nulo cuando nadie pidió un historial; con la dirección adentro y
            # `found` en falso cuando la dirección no es de este dominio, para
            # que el panel pueda decirlo en vez de abrirse vacío.
            'history': _history(domain, opened),
            'export': {
                'path': f'/api/v1/domains/{domain.id}/coverage/export',
                'threshold': EXPORT_THRESHOLD,
                # La última exportación grande de este dominio, con su lote. Es
                # lo que hace que el pedido quede **escrito** en la pantalla y no
                # sólo en un mensaje que se va: media hora después, el único
                # modo de saber qué se pidió y si ya está es que siga acá (RT-11).
                'latest': _export_props(_latest_export(domain)),
            },
            'running_batch': _batch_props(_running_batch(domain)),
            'last_batch': _batch_props(_last_batch(domain)),
            # El eje de indexación, en su propia clave y nunca mezclado con el
            # de cobertura: «le pedimos que la mire» y «Google dice que está
            # indexada» son dos afirmaciones distintas, y compartir prop las
            # encimaría en la pantalla igual que las encimaría en la base.
            'indexing': _indexing(domain),
        },
    )


#: Cuántas direcciones del lote viajan a la pestaña.
#:
#: La pestaña muestra **lo que está pasando**, no un archivo: alcanza con ver
#: avanzar las primeras y, si algo falló, cuáles. El detalle completo de un lote
#: viejo está en su ficha, que es adonde lleva el historial.
INDEXING_ROWS = 200


def _indexing(domain) -> dict:
    """
    La indexación **de ahora**, no su historial.

    La pestaña contesta una sola pregunta —«¿qué está pasando con lo que le
    pedimos a Google?»— y por eso manda un lote y no una lista: el que está
    corriendo si hay uno, y si no el último que terminó. El historial vive en la
    tabla de lotes, donde la indexación es una clase más y se puede filtrar,
    ordenar y abrir como cualquier otra.

    Una lista acá competía con esa tabla y perdía: mostraba menos, no se podía
    filtrar, y dejaba la pregunta del día —«¿ya salió?»— mezclada entre veinte
    renglones de cosas terminadas hace semanas.
    """
    from apps.indexing.services import batch_progress

    batches = Batch.objects.filter(domain=domain, kind=BatchKind.URL_INDEXING)

    # El que está corriendo manda sobre el último, aunque el último sea más
    # nuevo: lo que está pasando ahora es más importante que lo que terminó, y
    # entre los dos es el único momento en que pueden convivir.
    batch = (
        batches.exclude(state__in=list(TERMINAL_BATCH_STATES)).order_by('-created_at').first()
        or batches.order_by('-created_at').first()
    )

    if batch is None:
        return {'batch': None, 'progress': None, 'requests': [], 'truncated': False}

    total = batch.indexing_requests.count()
    rows = (
        batch.indexing_requests.order_by('position')
        .values('id', 'loc', 'position', 'state', 'sent_at', 'response_status', 'error_code')[
            :INDEXING_ROWS
        ]
    )

    return {
        'batch': {
            'id': str(batch.id),
            'state': batch.state,
            'origin': batch.origin,
            'total_items': batch.total_items,
            'created_at': batch.created_at.isoformat(),
            'finished_at': batch.finished_at.isoformat() if batch.finished_at else None,
            'is_draft': batch.state == BatchState.DRAFT,
            # `QUEUED` cuenta como corriendo para la pantalla: encolado significa
            # que va a salir sola, y decir «no está corriendo» de algo que va a
            # gastar cupo en cinco segundos es la clase de precisión que
            # confunde.
            'is_running': batch.state in (BatchState.QUEUED, BatchState.RUNNING),
            'is_terminal': batch.is_terminal,
            #: Por qué se detuvo, en código. Vacío cuando no se detuvo.
            'stopped_by': (batch.summary or {}).get('stopped_by') or '',
        },
        'progress': batch_progress(batch),
        'requests': [
            {
                'id': str(row['id']),
                'loc': row['loc'],
                'position': row['position'],
                'state': row['state'],
                'sent_at': row['sent_at'].isoformat() if row['sent_at'] else None,
                'response_status': row['response_status'],
                'error_code': row['error_code'],
            }
            for row in rows
        ],
        # El recorte se dice, como en la tabla de arriba: una lista cortada en
        # silencio se lee como «esto es todo lo que hay».
        'truncated': total > INDEXING_ROWS,
    }


@login_required
@require_http_methods(['POST'])
def export(request, domain_id):
    """
    Encola la exportación grande y vuelve a la tabla con el lote a la vista.

    Sólo el camino grande pasa por acá. El archivo chico se baja del endpoint de
    la API con un enlace común, que es lo que un navegador sabe hacer con una
    descarga; encolarlo también obligaría a esperar un lote para bajar cuarenta
    filas.

    **Exporta el dominio entero.** Con la tabla filtrando en el navegador, el
    recorte que alguien esté mirando no existe del lado del servidor: no viaja
    en la dirección ni en el cuerpo. `applied_filters()` se conserva porque el
    endpoint sigue aceptando filtros —la API v1 los manda— y porque el día que
    el recorte vuelva a la dirección esto funciona sin tocarse; desde la
    pantalla llega vacío y exporta todo.

    Que llegue vacío tiene que ser **porque no se mandó nada**, y no porque no
    se sepa leer. Esto leía `request.POST`, que con un cuerpo JSON —el que manda
    Inertia— está vacío siempre: el resultado de hoy era el correcto por
    accidente, y el día que alguien mandara un filtro desde acá habría
    desaparecido sin una señal.

    El diálogo de confirmación es el que dice cuántas filas son, y por eso tiene
    que decir el total del dominio y no el de la tabla que se está viendo.
    """
    domain = shared_domain_or_404(request, domain_id)
    filters = applied_filters(COVERAGE_TABLE, payload(request))

    try:
        queue_export(domain, filters, origin=BatchOrigin.MANUAL)
    except ApiError as exc:
        # El código viaja adentro del error y ya no como clave aparte: la
        # pantalla ramifica por él (RT-08) y tenerlo en dos lugares distintos
        # según el formulario era una convención que había que recordar.
        stash_errors(request, {'export': as_error(exc)})

    destination = reverse('coverage', kwargs={'domain_id': domain.id})
    query = urlencode(filters)
    return redirect(f'{destination}?{query}' if query else destination)


@login_required
def download(request, export_id):
    """
    Entrega el archivo de una exportación, si es de quien lo pide.

    No se sirve desde una dirección pública: el CSV lleva las URLs del sitio de
    una cuenta. Y el archivo vencido se contesta distinto del inexistente,
    porque son dos cosas distintas: uno se puede volver a pedir sabiendo por qué
    ya no está.
    """
    export = (
        CoverageExport.objects.filter(id=export_id, batch__domain__deactivated_at__isnull=True)
        .select_related('batch', 'batch__domain')
        .first()
    )

    if export is None:
        raise Http404('Esa exportación no existe en tu cuenta.')

    path = export_path(export)
    if export.is_expired or not path.exists():
        raise Http404('El archivo de esa exportación ya no está disponible.')

    hostname = export.batch.domain.hostname
    return FileResponse(
        path.open('rb'),
        as_attachment=True,
        # El nombre del archivo no se traduce, por lo mismo que la cabecera: es
        # lo que queda escrito en la carpeta de descargas y en cualquier script
        # que lo levante, y un nombre que cambia con el idioma de quien exportó
        # deja dos archivos distintos para la misma cosa.
        filename=f'coverage-{hostname}.csv',
        content_type='text/csv; charset=utf-8',
    )


@login_required
@require_http_methods(['POST'])
def inspect(request, domain_id):
    """
    Dispara una tanda de inspecciones a mano, contra la reserva manual.

    Con `urls` en el cuerpo la tanda se limita a esas direcciones. Es lo que
    permite volver a preguntar por las cuatro que quedaron sin indexar sin gastar
    el cupo del día en las doscientas treinta y ocho que ya están bien.
    """
    domain = shared_domain_or_404(request, domain_id)
    selected = field_list(request, 'urls')

    try:
        queue_inspection(
            domain,
            limit=domain.manual_reserve,
            origin=BatchOrigin.MANUAL,
            url_ids=selected or None,
        )
    except ApiError as exc:
        # El código viaja junto al mensaje porque la pantalla tiene un mapa
        # cerrado de códigos (RT-08): sin él, «se agotó el cupo» y «el dominio no
        # tiene acceso confirmado» llegarían como dos oraciones que la vista no
        # puede distinguir, y la acción que ofrece tendría que ser la misma.
        stash_errors(request, {'inspect': as_error(exc)})

    # Se vuelve a donde se disparó la acción. Quien la pide desde la ficha está
    # mirando el cupo que acaba de gastar, y mandarlo a la tabla de cobertura le
    # saca de la vista justo el número que quería ver moverse.
    if field(request, 'from') == 'show':
        return redirect('domain.show', domain_id=domain.id)

    return redirect('coverage', domain_id=domain.id)


def _domain_props(domain: Domain) -> dict:
    return {
        'id': str(domain.id),
        'hostname': domain.hostname,
        'property_uri': domain.property_uri,
        'access_state': domain.access_state,
        # Desde cuándo dejamos de poder actualizar: sin esta fecha, «los datos
        # están congelados» no le dice a nadie qué tan viejos son (R-F).
        'access_checked_at': (
            domain.access_checked_at.isoformat() if domain.access_checked_at else None
        ),
        'is_operational': domain.is_operational,
    }


def _row(url) -> dict:
    """
    Una fila de la tabla.

    El estado viaja siempre junto a su fecha de obtención. Es la regla que hace
    que un estado sea una afirmación verificable y no una impresión: sin la
    fecha, «indexada» podría ser de hoy o de hace tres meses (R-A, RT-02).

    Los cuatro campos crudos vienen anotados desde el último registro del
    historial. Se mandan tal como los escribió Google —sin traducir— porque son
    la prueba de lo que dijo, y porque el estado traducido ya viaja al lado.
    """
    return {
        'id': str(url.id),
        'loc': url.loc,
        'state': url.coverage_state,
        'family': _family(url.coverage_state),
        'fetched_at': url.last_checked_at.isoformat() if url.last_checked_at else None,
        'raw_coverage_state': url.raw_coverage_state or '',
        'google_canonical': url.google_canonical or '',
        'user_canonical': url.user_canonical or '',
        'last_crawl_time': url.last_crawl_time.isoformat() if url.last_crawl_time else None,
        'in_sitemap': url.in_sitemap,
        # El eje de indexación, en su propia clave. Vacío cuando nunca se pidió
        # nada por esta dirección, que es lo normal: la columna llega escondida
        # y quien la enciende sabe que está mirando otra cosa.
        'indexing_state': getattr(url, 'indexing_state', None) or '',
    }


#: Las tres familias de RT-03, y **son excluyentes entre sí**.
#:
#: `STATE_GROUPS` no lo es: `with_data` contiene a `not_indexed`, porque una URL
#: no indexada es una URL con dato. Eso funciona para un filtro de servidor —dos
#: consultas distintas— y no funciona para un filtro de tabla, que compara el
#: valor de **una** columna: una fila no puede estar en dos casillas del mismo
#: eje. Por eso la tabla filtra por familia y no por grupo.
#:
#: La partición es la misma que ya rige el resto de la vista: lo que Google
#: confirmó indexado, lo que Google contestó que no, y lo que todavía no
#: preguntamos. Que sea excluyente es lo que impide que «no indexadas» se coma
#: las sin consultar (R-B).
FAMILY_INDEXED = 'indexed'
FAMILY_NOT_INDEXED = 'not_indexed'
FAMILY_WITHOUT_DATA = 'without_data'

FAMILIES: tuple[str, ...] = (FAMILY_INDEXED, FAMILY_NOT_INDEXED, FAMILY_WITHOUT_DATA)


def _family(state: str) -> str:
    """A cuál de las tres familias pertenece un estado. Siempre a una sola."""
    if state == CoverageState.UNKNOWN:
        return FAMILY_WITHOUT_DATA
    if state == CoverageState.INDEXED:
        return FAMILY_INDEXED
    return FAMILY_NOT_INDEXED


def _table(domain) -> dict:
    """
    Las filas de la tabla, con su total real y el aviso de recorte.

    Manda **filas sueltas** y no una página: la tabla ordena y filtra en el
    navegador. `total` es cuántas hay de verdad y `rows` cuántas viajaron; que
    no coincidan es información de la pantalla, no un detalle interno, porque un
    total que no cierra con lo que se ve hace dudar del resto del tablero.

    El orden lo pone SQL —por dirección, que es el mismo orden por omisión que
    tenía la tabla de servidor— para que el recorte sea siempre el mismo
    conjunto y no lo que la base haya devuelto primero esta vez.
    """
    urls = _with_latest_indexing(with_latest_record(coverage_queryset(domain))).order_by('loc')
    total = urls.count()

    return {
        'rows': [_row(url) for url in urls[:TABLE_LIMIT]],
        'total': total,
        'limit': TABLE_LIMIT,
        'truncated': total > TABLE_LIMIT,
    }


def _with_latest_indexing(queryset):
    """
    Agrega a cada URL el estado del último pedido de indexación que se le hizo.

    Va por subconsulta y no por `join` por lo mismo que el historial de
    cobertura: un `join` devolvería una fila por pedido y habría que agrupar.

    **Es una columna aparte y nunca se mezcla con `coverage_state`.** «Le
    pedimos que la mire» y «Google dice que está indexada» son dos afirmaciones
    distintas, y encimarlas destruiría la evidencia en el mismo movimiento en
    que la genera. Por eso también llega escondida: quien viene a mirar la
    cobertura no tiene por qué encontrarse un segundo eje sin haberlo pedido.
    """
    from apps.indexing.models import IndexingRequest, IndexingState

    return queryset.annotate(
        indexing_state=Subquery(
            IndexingRequest.objects.filter(url=OuterRef('pk'))
            .exclude(state=IndexingState.REMOVED)
            .order_by('-created_at')
            .values('state')[:1]
        )
    )


def _options(counts: dict[str, int]) -> dict:
    """
    Los filtros con su cantidad, para que cada uno diga de antemano qué muestra.

    Los grupos se cuentan sumando estados y no con otra consulta: así «no
    indexadas» es, por construcción, la suma de los ocho estados negativos y no
    puede incluir las sin consultar por descuido (R-B).

    Las etiquetas no viajan: las diez viven en `CoverageStateBadge`, que es su
    único dueño. Dos listas separadas terminan divergiendo, y el día que
    divergen la misma URL dice una cosa en la tabla y otra en el filtro.
    """
    total = sum(counts.values())

    return {
        'total': total,
        # El código crudo y no su forma pública: la tabla compara este valor con
        # el `state` de cada fila y las dos puntas están del mismo lado del
        # navegador. La forma pública (D12) es para lo que se lee en la barra de
        # direcciones, y el recorte de esta tabla ya no vive ahí.
        'states': [
            {'value': state, 'code': state, 'count': counts.get(state, 0)}
            for state in CoverageState.values
        ],
        # Las tres familias excluyentes, que es lo que la tabla puede filtrar.
        # Se cuentan sumando los mismos `counts` que las diez, así que las tres
        # cifras cierran contra el total por construcción y no por coincidencia.
        'families': [
            {
                'value': family,
                'count': sum(count for state, count in counts.items() if _family(state) == family),
            }
            for family in FAMILIES
        ],
        # Los grupos que entiende el servidor, para la exportación y la API.
        # **Se solapan** —`with_data` contiene a `not_indexed`— y por eso no son
        # lo que filtra la tabla; ver `FAMILIES`.
        'groups': [
            {'value': key, 'count': sum(counts.get(state, 0) for state in states)}
            for key, states in STATE_GROUPS.items()
        ],
    }


def _history(domain, loc: str) -> dict | None:
    if not loc:
        return None

    data = url_history(domain, loc)
    if data is None:
        # No es un 404 de la vista: la tabla de atrás sigue siendo válida y lo
        # único que falló es la dirección que alguien pegó a mano o que quedó en
        # un enlace viejo.
        return {'url': loc, 'found': False, 'in_sitemap': False, 'current': None, 'entries': []}

    return data | {'found': True}


def _running_batch(domain) -> Batch | None:
    """
    El lote no terminal más reciente, si lo hay.

    La pantalla lo usa para saber si tiene que seguir consultando. Cuando no hay
    ninguno devuelve nada, y la consulta periódica ni siquiera arranca (RT-13).
    """
    return (
        Batch.objects.filter(domain=domain, kind=BatchKind.URL_INSPECTION)
        .exclude(state__in=['COMPLETED', 'PARTIAL', 'FAILED'])
        .first()
    )


def _last_batch(domain) -> Batch | None:
    """
    El lote terminado que dejó los datos más recientes.

    Se exige `processed_items` mayor que cero porque un lote que no llegó a
    consultar nada no produjo ningún dato, y presentarlo como el origen de lo que
    está en pantalla haría buscar la explicación en el lugar equivocado.
    """
    return (
        Batch.objects.filter(
            domain=domain,
            kind=BatchKind.URL_INSPECTION,
            state__in=['COMPLETED', 'PARTIAL', 'FAILED'],
            processed_items__gt=0,
        )
        .order_by('-finished_at')
        .first()
    )


def _latest_export(domain) -> CoverageExport | None:
    """La exportación grande más reciente del dominio, esté lista o no."""
    return CoverageExport.objects.filter(batch__domain=domain).select_related('batch').first()


def _export_props(export: CoverageExport | None) -> dict | None:
    """La exportación con su lote: acá el lote no viaja aparte, como en su ficha."""
    if export is None:
        return None

    return export_props(export) | {'batch': _batch_props(export.batch)}


def _batch_props(batch: Batch | None) -> dict | None:
    if batch is None:
        return None

    return {
        'id': str(batch.id),
        'state': batch.state,
        'origin': batch.origin,
        'total_items': batch.total_items,
        'processed_items': batch.processed_items,
        'failed_items': batch.failed_items,
        'finished_at': batch.finished_at.isoformat() if batch.finished_at else None,
    }
