"""
Los lotes de un dominio (V9) y la ficha de uno (V10).

La pregunta que trae a estas dos pantallas no es «¿qué corrió?» sino «¿qué quedó
sin hacer?». De ahí sale todo lo demás.

Un lote interrumpido por cuota es `PARTIAL` y nunca `COMPLETED` (R-C), y esa
distinción tiene que sobrevivir al viaje hasta el navegador: por eso el servidor
manda las cifras crudas —total, procesadas, fallidas— y no un porcentaje ya
resuelto, y manda el **motivo como código** en vez de como frase. La barra se
dibuja de las cifras y la frase la escribe la pantalla, que es donde vive el
resto del vocabulario del producto.

`PARTIAL` tampoco es una falla: es trabajo que sigue mañana. Nada de lo que sale
de acá lo trata como error, y ninguna de las dos vistas promete indexación:
inspeccionar una URL es preguntarle a Google qué sabe de ella.
"""

from collections import Counter, defaultdict

from django.contrib.auth.decorators import login_required
from django.db.models import Count, F, Max, Window
from django.db.models.functions import Coalesce, Lag
from django.http import Http404
from django.urls import reverse
from inertia import render

from apps.core.dates import display_timezone
from apps.coverage.models import CoverageExport, CoverageRecord
from apps.coverage.services import export_props, pending_urls
from apps.domains.models import Domain
from apps.domains.services import shared_domain
from apps.gsc.errors import MESSAGES, GoogleErrorCode
from apps.jobs.models import (
    TERMINAL_BATCH_STATES,
    Batch,
    BatchKind,
    BatchOrigin,
    BatchState,
    QuotaBudget,
)


class BatchReason:
    """
    Por qué un lote terminó como terminó, en claves cerradas.

    Va como código y no como frase porque el mismo motivo se dice distinto en la
    fila de una tabla y en el encabezado de una ficha, y porque las cifras que lo
    acompañan se formatean con la configuración regional del producto. Lo que sí
    decide el servidor es **cuál** de los motivos fue: eso se deduce del resumen
    del lote, que es dato suyo y no una lectura del texto de un error.
    """

    #: Se acabó el cupo diario del dominio: el corte fue nuestro y lo que quedó
    #: sigue en la cola. Es el caso que la restricción R-C protege.
    QUOTA_EXHAUSTED = 'QUOTA_EXHAUSTED'
    #: Google contestó que se alcanzó su límite de consultas.
    PROVIDER_LIMIT = 'PROVIDER_LIMIT'
    #: Google dejó de darnos permiso sobre la propiedad en mitad del trabajo.
    PROVIDER_DENIED = 'PROVIDER_DENIED'
    #: Fallaron ítems sueltos, cada uno con su motivo en la lista de fallidos.
    ITEM_ERRORS = 'ITEM_ERRORS'
    #: El lote no tenía nada para procesar.
    NOTHING_TO_DO = 'NOTHING_TO_DO'
    #: Se cortó antes de terminar y el resumen no dice por qué.
    INTERRUPTED = 'INTERRUPTED'


#: Cuándo se considera que el lote arrancó.
#:
#: Un lote en cola no tiene `started_at`, y ordenar por una columna nula deja al
#: trabajo recién encolado —lo que más se viene a mirar— en una punta distinta
#: según el motor de base de datos. `created_at` es el momento en que se encoló,
#: que es exactamente lo que hay que mostrar mientras no haya arrancado.
_STARTED = Coalesce('started_at', 'created_at')

@login_required
def index(request, domain_id):
    """Los lotes del dominio, con los que quedaron incompletos a la vista."""
    domain = _shared(domain_id)
    batches = (
        Batch.objects.filter(domain=domain)
        .annotate(started=_STARTED)
        .order_by('-started')
    )

    return render(
        request,
        'Batches/Index',
        props={
            'domain': {
                'id': str(domain.id),
                'hostname': domain.hostname,
                'is_operational': domain.is_operational,
                'access_state': domain.access_state,
            },
            'batches': [_row(batch) for batch in batches],
            # Los códigos y no las etiquetas: el vocabulario de los cinco estados
            # vive en `C-07`, y tenerlo también acá haría que «Parcial» se
            # escribiera en dos lugares y se corrigiera en uno.
            'options': {
                'state': list(BatchState.values),
                'kind': list(BatchKind.values),
                'origin': list(BatchOrigin.values),
            },
            'active_states': _active_states(domain),
            'pending_urls': pending_urls(domain).count(),
        },
    )


@login_required
def show(request, batch_id):
    """Qué hizo exactamente un lote, cuánto cupo gastó y por qué terminó así."""
    batch = (
        Batch.objects.filter(id=batch_id, domain__deactivated_at__isnull=True)
        .select_related('domain')
        .first()
    )
    if batch is None:
        raise Http404('Ese lote no existe en esta compañía.')

    domain = batch.domain

    return render(
        request,
        'Batches/Show',
        props={
            'domain': {
                'id': str(domain.id),
                'hostname': domain.hostname,
                'property_uri': domain.property_uri,
                'is_operational': domain.is_operational,
            },
            'batch': _detail(batch),
            'quota': _daily_quota(batch),
            'failures': _failures(batch),
            # El total aparte de la lista: recortar sin decirlo haría leer cien
            # motivos como si fueran todos, y quien busca el suyo lo daría por
            # ausente.
            'failures_total': len((batch.summary or {}).get('errors') or []),
            'inspection': _inspection(batch),
            # Nula salvo en un lote de indexación. Es lo que convierte esta
            # ficha en el lugar donde se reconstruye un pedido viejo: qué salió,
            # qué contestó Google y sobre qué dirección.
            'indexing': _indexing(batch),
            # Nula salvo en un lote de exportación. Es lo que convierte esta
            # ficha en el lugar donde se baja el archivo: el aviso lleva acá
            # justamente para no disparar una descarga desde la campana.
            'export': _export(batch),
            'neighbours': _neighbours(batch),
        },
    )


def _shared(domain_id) -> Domain:
    """
    El dominio activo del scope compartido de la compañía.

    La frontera por propietario sigue disponible como `active_owned_domain()`
    en servicios para la futura edición multitenant; esta interfaz usa de forma
    explícita el inventario común del MVP.
    """
    domain = shared_domain(domain_id)
    if domain is None:
        raise Http404('Ese dominio no existe en esta compañía.')
    return domain


def _active_states(domain) -> list[str]:
    """
    Los estados no terminales del dominio entero, no los de la página.

    El sondeo se decide con esto (RT-13). Mirar sólo las filas visibles dejaría
    la pantalla quieta cuando el lote en curso cayó en la página dos, que es
    justo cuando alguien la mira esperando que se mueva.
    """
    return list(
        Batch.objects.filter(domain=domain)
        .exclude(state__in=list(TERMINAL_BATCH_STATES))
        .values_list('state', flat=True)
    )


def _row(batch: Batch) -> dict:
    """
    Un renglón de la tabla.

    Van las cifras crudas y no un porcentaje: `C-17` dibuja el avance desde
    ellas, y un lote parcial tiene que verse parcial —la barra sin tocar el
    borde— aunque su estado diga que terminó (R-C).
    """
    return {
        'id': str(batch.id),
        'kind': batch.kind,
        'origin': batch.origin,
        'state': batch.state,
        'total_items': batch.total_items,
        'processed_items': batch.processed_items,
        'failed_items': batch.failed_items,
        'pending_items': _pending(batch),
        'quota_consumed': batch.quota_consumed,
        'reason': _reason(batch),
        'created_at': batch.created_at.isoformat(),
        'started_at': batch.started_at.isoformat() if batch.started_at else None,
        'finished_at': batch.finished_at.isoformat() if batch.finished_at else None,
        'duration_seconds': _duration(batch),
        'is_terminal': batch.is_terminal,
    }


def _detail(batch: Batch) -> dict:
    """La fila más el resumen entero: la ficha es el único lugar donde se abre."""
    return {**_row(batch), 'summary': batch.summary or {}}


def _pending(batch: Batch) -> int:
    """
    Lo que el lote no llegó a tocar.

    Se calcula de las mismas tres cifras con las que se dibuja la barra, y no del
    resumen, para que el número de la frase y el hueco del final de la barra no
    puedan contradecirse.
    """
    return max(batch.total_items - batch.processed_items - batch.failed_items, 0)


def _duration(batch: Batch) -> int | None:
    """Segundos entre arranque y cierre. Nulo mientras falte alguna de las dos puntas."""
    if not batch.started_at or not batch.finished_at:
        return None
    return max(int((batch.finished_at - batch.started_at).total_seconds()), 0)


def _reason(batch: Batch) -> str | None:
    """
    De qué se cortó el lote, si se cortó.

    El cupo propio se reconoce por la reserva y no por el texto de ningún error:
    si se concedió menos de lo pedido, el corte lo pusimos nosotros y lo que
    quedó entra en el ciclo siguiente. Los motivos que sí vienen de Google se
    reconocen contra los mensajes de `apps/gsc/errors.py`, que son constantes del
    módulo y no cadenas repetidas acá: si cambian, cambian en un solo lugar.
    """
    if batch.state == BatchState.COMPLETED:
        return BatchReason.NOTHING_TO_DO if batch.total_items == 0 else None

    if batch.state not in (BatchState.PARTIAL, BatchState.FAILED):
        return None

    summary = batch.summary or {}
    errors = [str(error) for error in (summary.get('errors') or [])]

    requested = summary.get('quota_requested') or 0
    granted = summary.get('quota_granted') or 0
    if requested and granted < requested:
        return BatchReason.QUOTA_EXHAUSTED

    if _any_of(errors, GoogleErrorCode.QUOTA_EXCEEDED, GoogleErrorCode.RATE_LIMITED):
        return BatchReason.PROVIDER_LIMIT
    if _any_of(errors, GoogleErrorCode.PERMISSION_DENIED, GoogleErrorCode.PROPERTY_NOT_FOUND):
        return BatchReason.PROVIDER_DENIED
    # Lo que decide es la cifra de fallidos y no que la lista tenga algo adentro.
    # En `summary['errors']` conviven los intentos que fallaron y las notas que
    # explican hasta dónde llegó el lote —«no quedaba cupo para enviar este
    # sitemap»—, y esas notas no incrementan `failed_items` porque no falló
    # nada. Con la lista como criterio, un lote sin una sola falla se explicaba
    # con el motivo de las fallas y la pantalla escribía «Fallaron 0 de 7».
    if batch.failed_items:
        return BatchReason.ITEM_ERRORS

    return BatchReason.INTERRUPTED


def _any_of(errors: list[str], *codes: str) -> bool:
    return any(MESSAGES[code] in error for error in errors for code in codes)


#: Cuántos motivos viajan a la pantalla.
#:
#: Un lote de dos mil URLs que falla entero guarda dos mil cadenas, y mandarlas
#: todas convierte las props de una ficha en medio megabyte que además se
#: retransmite en cada consulta del sondeo. Cien alcanzan para reconocer el
#: patrón; la pantalla dice cuántas quedaron afuera para que el recorte no se
#: lea como el total.
MAX_FAILURES = 100


def _failures(batch: Batch) -> list[dict]:
    """
    Los ítems que fallaron, cada uno con su motivo.

    Los servicios guardan cada error como «dirección: motivo». Se parte en dos
    para que la pantalla pueda armar una tabla con encabezados y no un párrafo
    con comas: el motivo de una URL concreta es lo que permite arreglarla, y
    mezclado en prosa no se lee ni se copia. Lo que no tiene forma de dirección
    —«no quedaba cupo diario»— viaja entero como motivo sin ítem.
    """
    failures = []

    for error in ((batch.summary or {}).get('errors') or [])[:MAX_FAILURES]:
        text = str(error)
        item, separator, reason = text.partition(': ')
        if separator and item.startswith(('http://', 'https://')):
            failures.append({'item': item, 'reason': reason})
        else:
            failures.append({'item': None, 'reason': text})

    return failures


def _daily_quota(batch: Batch) -> dict | None:
    """
    El presupuesto del día en que corrió el lote, si existe.

    No se crea la fila que falte. `budget_for` la crearía copiando los límites de
    hoy, y un lote de hace un mes quedaría explicado con un cupo que ese día no
    existió: exactamente el historial falso que la tabla evita al guardar los
    límites vigentes cuando se la crea.

    El día se corta en la zona de presentación, que es la misma con la que lo
    corta el presupuesto. Con la del navegador o con UTC, «el cupo de ese día»
    sería un día distinto del que la base cobró.
    """
    day = (batch.started_at or batch.created_at).astimezone(display_timezone()).date()
    budget = QuotaBudget.objects.filter(domain_id=batch.domain_id, date=day).first()

    if budget is None:
        return None

    return {
        'date': budget.date.isoformat(),
        'limit_total': budget.limit_total,
        'manual_reserve': budget.manual_reserve,
        'used_automatic': budget.used_automatic,
        'used_manual': budget.used_manual,
    }


def _inspection(batch: Batch) -> dict | None:
    """
    Qué estados dejó registrados un lote de inspección, y con qué fecha.

    El historial guarda una fila **sólo cuando el estado cambia**, más la primera
    lectura de cada URL. Así que este reparto no es «el estado de todas las URLs
    consultadas»: es el de las que trajeron información nueva. La pantalla lo
    dice con esas palabras y muestra al lado cuántas seguían igual, porque
    presentarlo como el reparto completo convertiría una foto parcial del sitio
    en una afirmación sobre el sitio entero.

    La fecha de obtención viaja siempre (R-A): un conteo por estado sin la fecha
    en que Google lo dijo no es un dato verificable.
    """
    if batch.kind != BatchKind.URL_INSPECTION:
        return None

    records = CoverageRecord.objects.filter(batch=batch)
    distribution = records.values('state').annotate(total=Count('id')).order_by('-total')
    fetched = records.aggregate(latest=Max('fetched_at'))['latest']

    summary = batch.summary or {}
    addresses = _addresses_by_state(batch) if batch.is_terminal else {}

    return {
        'fetched_at': fetched.isoformat() if fetched else None,
        'by_state': [
            {
                'state': row['state'],
                'total': row['total'],
                # Las direcciones viajan con la cifra para que se puedan mirar
                # sin salir de acá. Antes esto era un enlace a la tabla de
                # cobertura filtrada por estado, que contesta otra pregunta:
                # cuáles están así **hoy**, no cuáles dejó así este lote.
                'urls': addresses.get(row['state'], []),
            }
            for row in distribution
        ],
        # Las transiciones se calculan recién cuando el lote terminó. Son un
        # resultado suyo, no un dato en vivo, y calcularlas mientras corre sale
        # caro justo cuando la pantalla se está repidiendo cada cinco segundos:
        # `_transitions` recorre el historial completo de hasta dos mil URLs.
        'transitions': _transitions(batch) if batch.is_terminal else [],
        'unchanged': summary.get('unchanged') or 0,
        'changed': summary.get('changed') or 0,
        # Las que este recorrido vio caer del índice, con la dirección congelada
        # tal como estaba ese día. Es lo que hace atendible el aviso: sin esto
        # decía cuántas y no cuáles, y la tabla de cobertura —a donde llevaba—
        # ya muestra el estado que Google contestó después.
        'lost_indexing': summary.get('lost_indexing') or 0,
        'lost_indexing_urls': _lost_indexing_urls(summary),
    }


#: Cuántas direcciones acompañan a cada cifra de la ficha del lote.
#:
#: Mismo criterio que los motivos de fallo y que la ficha de indexación. Acá pesa
#: además el sondeo: la pantalla se repide cada cinco segundos, y una lista sin
#: tope por cada estado de un lote de dos mil URLs se enviaría entera en cada
#: tic. El total va aparte, así que el recorte se puede decir en pantalla.
MAX_STATE_URLS = 200


def _addresses_by_state(batch: Batch) -> dict[str, list[str]]:
    """
    Las direcciones que este lote dejó en cada estado.

    Se toman del historial del lote y no de `Url`, que es lo que hacía el enlace
    a la tabla de cobertura: ahí vive el **último** estado de cada dirección, así
    que un recorrido posterior ya lo pisó y la lista que aparecía no era la que
    la cifra de al lado prometía.
    """
    addresses: dict[str, list[str]] = defaultdict(list)

    rows = CoverageRecord.objects.filter(batch=batch).values_list('state', 'url__loc')
    for state, loc in rows:
        if loc and len(addresses[state]) < MAX_STATE_URLS:
            addresses[state].append(loc)

    return addresses


def _lost_indexing_urls(summary: dict) -> list[dict]:
    """
    Las direcciones caídas del índice que el lote alcanzó a guardar.

    Los lotes anteriores a este cambio no tienen la clave: se resuelve como
    lista vacía y la pantalla muestra la cifra sola, que es lo único que ese lote
    llegó a saber. Rellenarla consultando el historial daría una lista que
    aparenta ser la del aviso sin serlo.
    """
    rows = summary.get('lost_indexing_urls') or []

    return [
        {'loc': str(row.get('loc') or ''), 'state': str(row.get('state') or '')}
        for row in rows
        if isinstance(row, dict) and row.get('loc')
    ]


#: Cuántas direcciones de un lote de indexación viajan a su ficha.
#:
#: Mismo criterio que los motivos de fallo: doscientas alcanzan para reconocer
#: qué pasó, y la ficha dice cuántas quedaron afuera para que el recorte no se
#: lea como el total. La evidencia completa se baja como archivo.
MAX_INDEXING_ROWS = 200


def _indexing(batch: Batch) -> dict | None:
    """
    Qué se le pidió a Google en este lote y qué contestó, dirección por dirección.

    **No trae los cuerpos crudos.** La ficha es para entender qué pasó; el
    pedido y la respuesta enteros son un paso más —el panel de evidencia de la
    pantalla del lote, o el archivo— y ponerlos acá llenaría de JSON una
    pantalla que se abre para leer un resumen.
    """
    if batch.kind != BatchKind.URL_INDEXING:
        return None

    from apps.indexing.models import IndexingRequest
    from apps.indexing.services import batch_progress

    total = IndexingRequest.objects.filter(batch=batch).count()
    rows = (
        IndexingRequest.objects.filter(batch=batch)
        .order_by('position')
        .values('id', 'loc', 'position', 'state', 'sent_at', 'response_status', 'error_code')[
            :MAX_INDEXING_ROWS
        ]
    )

    return {
        'progress': batch_progress(batch),
        'stopped_by': (batch.summary or {}).get('stopped_by') or '',
        '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
        ],
        'total': total,
        'truncated': total > MAX_INDEXING_ROWS,
        'evidence_path': reverse('indexing.evidence', kwargs={'batch_id': batch.id}),
        'detail_path': reverse('indexing.batch', kwargs={'batch_id': batch.id}),
    }


def _export(batch: Batch) -> dict | None:
    """El archivo que produjo un lote de exportación, si es de esa clase."""
    if batch.kind != BatchKind.COVERAGE_EXPORT:
        return None

    export = CoverageExport.objects.filter(batch=batch).first()
    return export_props(export) if export is not None else None


def _transitions(batch: Batch) -> list[dict]:
    """
    De qué estado a cuál pasó cada URL que este lote movió.

    Como el historial sólo escribe cuando algo cambia, el estado anterior es el
    de la fila previa de la misma URL. Se calcula con una función de ventana y no
    con una consulta por URL: un lote toca hasta dos mil, y dos mil consultas
    para pintar una lista de cinco renglones se pagan en cada carga de la
    pantalla y en cada tic del sondeo.

    Una fila sin anterior no es una transición: es la primera vez que supimos
    algo de esa URL. Contarla como cambio inventaría un movimiento que nunca
    ocurrió.
    """
    from_batch = set(CoverageRecord.objects.filter(batch=batch).values_list('id', flat=True))
    if not from_batch:
        return []

    urls = CoverageRecord.objects.filter(batch=batch).values_list('url_id', flat=True)
    history = (
        CoverageRecord.objects.filter(url_id__in=urls)
        .annotate(
            previous=Window(
                expression=Lag('state'),
                partition_by=F('url_id'),
                order_by=F('fetched_at').asc(),
            )
        )
        .values_list('id', 'state', 'previous', 'url__loc')
    )

    counts: Counter = Counter()
    addresses: dict[tuple[str, str], list[str]] = defaultdict(list)
    for record_id, state, previous, loc in history:
        if record_id in from_batch and previous and previous != state:
            counts[(previous, state)] += 1
            # La dirección sale de la misma fila que ya se recorre: es lo que
            # permite mirar el movimiento sin irse a otra pantalla que ya no
            # sabe cuál era el estado anterior.
            if loc and len(addresses[(previous, state)]) < MAX_STATE_URLS:
                addresses[(previous, state)].append(loc)

    return [
        {
            'from_state': from_state,
            'to_state': to_state,
            'total': total,
            'urls': addresses[(from_state, to_state)],
        }
        for (from_state, to_state), total in counts.most_common()
    ]


def _neighbours(batch: Batch) -> dict:
    """
    El lote anterior y el siguiente del mismo dominio.

    Se recorre por fecha de creación y no por fecha de arranque porque un lote en
    cola todavía no arrancó, y saltearlo escondería justo el que está por correr.
    """
    previous_batch = (
        Batch.objects.filter(domain_id=batch.domain_id, created_at__lt=batch.created_at)
        .order_by('-created_at')
        .first()
    )
    next_batch = (
        Batch.objects.filter(domain_id=batch.domain_id, created_at__gt=batch.created_at)
        .order_by('created_at')
        .first()
    )

    return {'previous': _neighbour(previous_batch), 'next': _neighbour(next_batch)}


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

    return {
        'id': str(batch.id),
        'kind': batch.kind,
        'state': batch.state,
        'created_at': batch.created_at.isoformat(),
    }
