"""
Armar, correr, cortar y reanudar un lote de indexación.

La regla que ordena todo el módulo: **nada sale a Google sin que una persona lo
confirme**. Los tres caminos que crean trabajo —a mano, por la API tras un
despliegue, y solo después de un recorrido— terminan los tres en un lote en
`DRAFT`, y `run_batch()` es el único paso de ahí a la cola. Eso deja un solo
disparador de ejecución en lugar de tres mecánicas que habría que mantener
parejas, y ningún camino que pueda gastar cupo real mientras nadie mira.

La otra regla, que es la que da sentido a la task: **la app no lleva contador de
cuota de indexación**. Envía hasta que Google diga basta. Cuando la respuesta
viene mal, se corta el lote ahí mismo, la dirección que recibió el error queda
marcada con él, y el resto queda pendiente. Un contador propio sería una
afirmación de la aplicación sobre cuánto se puede; el error de Google, guardado
crudo con su fecha, es un documento de Google.
"""

import logging
from dataclasses import dataclass
from http import HTTPStatus

from django.db import transaction
from django.db.models import Count
from django.db.models.functions import Coalesce
from django.utils import timezone

from apps.core.errors import ApiError, ErrorCode
from apps.domains.services import ensure_operational
from apps.gsc.errors import GoogleErrorCode
from apps.indexing.client import IndexingClient
from apps.indexing.errors import cuts_the_batch
from apps.indexing.models import IndexingRequest, IndexingState
from apps.jobs.models import Batch, BatchKind, BatchOrigin, BatchState
from apps.sitemaps.models import Url

logger = logging.getLogger(__name__)

#: Cada cuántos envíos se anota el avance del lote.
#:
#: Sin esto la pantalla queda mirando un contador quieto durante un lote largo,
#: y con esto en cada envío se paga una escritura por llamada.
PROGRESS_EVERY = 10


class BatchNotEditable(ApiError):
    """
    Se intentó cambiar un lote que ya salió de borrador.

    Usa `CONFLICT` y no un código propio: los códigos de la API son contrato, y
    éste no le pide al cliente nada que no le pida cualquier otro conflicto de
    estado. El detalle concreto va en el mensaje.
    """

    def __init__(self, message='Este lote ya no se puede cambiar.', **details):
        super().__init__(ErrorCode.CONFLICT, message, HTTPStatus.CONFLICT, details)


class NothingToSend(ApiError):
    """El lote quedó sin ninguna dirección que enviar."""

    def __init__(self, message='No hay ninguna dirección para enviar.', **details):
        super().__init__(
            ErrorCode.VALIDATION_FAILED, message, HTTPStatus.UNPROCESSABLE_ENTITY, details
        )


# --- Armar ------------------------------------------------------------------


def eligible_urls(domain, locs):
    """
    Cuáles de las direcciones recibidas se pueden pedir, y por qué las otras no.

    Devuelve las filas aceptadas junto al detalle de los descartes. El detalle
    viaja siempre —también cuando no hay descartes— porque quien manda un
    despliegue por la API necesita poder contestar «mandé cuarenta, entraron
    treinta y siete, ¿qué pasó con las otras tres?» sin abrir la pantalla.

    Los dos motivos de descarte son distintos y se informan por separado: una
    dirección ajena a la propiedad nunca va a entrar, y una que salió del
    sitemap puede volver a entrar mañana.
    """
    wanted = [loc.strip() for loc in locs if loc and loc.strip()]
    unique = list(dict.fromkeys(wanted))

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

    accepted, foreign, retired = [], [], []
    for loc in unique:
        url = known.get(loc)
        if url is None:
            # No está en el inventario del dominio: o es de otro sitio, o nunca
            # apareció en ningún sitemap que hayamos leído.
            foreign.append(loc)
        elif not url.in_sitemap:
            # Estuvo y ya no está. Pedir la indexación de algo que el propio
            # sitio dejó de declarar es pedirle a Google que mire una página
            # que su dueño retiró.
            retired.append(loc)
        else:
            accepted.append(url)

    return accepted, {'foreign': foreign, 'retired': retired}


@transaction.atomic
def create_batch(domain, urls, *, origin: str = BatchOrigin.MANUAL) -> Batch:
    """
    Arma un lote en borrador con sus pedidos, uno por dirección.

    Nace en `DRAFT` y no en `QUEUED` venga de donde venga. Es la decisión que
    hace que los tres caminos compartan toda la maquinaria: la pantalla del
    lote, el quitar filas, el «Run» y la ficha de progreso son las mismas para
    el que armó una persona y para el que propuso un despliegue.
    """
    ensure_operational(domain)

    selection = list(urls)
    if not selection:
        raise NothingToSend(
            'No quedó ninguna dirección para pedir. Puede que las que mandaste no '
            'pertenezcan a esta propiedad o ya no estén en el sitemap.'
        )

    batch = Batch.objects.create(
        domain=domain,
        kind=BatchKind.URL_INDEXING,
        origin=origin,
        state=BatchState.DRAFT,
        total_items=len(selection),
    )

    IndexingRequest.objects.bulk_create(
        [
            IndexingRequest(batch=batch, url=url, loc=url.loc, position=position)
            for position, url in enumerate(selection, start=1)
        ]
    )

    if origin != BatchOrigin.MANUAL:
        # Sólo avisan los caminos que no tienen a nadie delante. Quien armó el
        # lote a mano lo está mirando, y avisarle de lo que tiene en pantalla es
        # ruido que gasta el único canal que el producto tiene para decir que
        # algo pasó. El aviso se deja al confirmar la transacción porque apunta
        # a un lote que hasta entonces no existe para nadie más.
        from apps.notifications import services as notifications

        transaction.on_commit(lambda: notifications.indexing_batch_ready(batch))

    return batch


def remove_url(batch: Batch, request_id) -> IndexingRequest:
    """
    Saca una dirección del borrador sin tocar el resto.

    Existe por un caso concreto: un despliegue avisa de un archivo que cambió
    pero que no se quiere reenviar. Se lo saca y los demás siguen.

    Marca en vez de borrar. El lote tiene que poder explicar qué se decidió no
    enviar, y una fila que desaparece no explica nada.
    """
    _ensure_draft(batch)

    request = batch.indexing_requests.filter(id=request_id).first()
    if request is None:
        raise BatchNotEditable('Esa dirección no está en este lote.')

    if request.state != IndexingState.REMOVED:
        request.state = IndexingState.REMOVED
        request.save(update_fields=['state', 'updated_at'])
        _recount(batch)

    return request


def restore_url(batch: Batch, request_id) -> IndexingRequest:
    """Devuelve al lote una dirección que se había sacado, mientras siga en borrador."""
    _ensure_draft(batch)

    request = batch.indexing_requests.filter(id=request_id).first()
    if request is None:
        raise BatchNotEditable('Esa dirección no está en este lote.')

    if request.state == IndexingState.REMOVED:
        request.state = IndexingState.PENDING
        request.save(update_fields=['state', 'updated_at'])
        _recount(batch)

    return request


def delete_batch(batch: Batch) -> None:
    """
    Descarta un borrador entero.

    Sólo en borrador: un lote que ya salió a Google tiene evidencia adentro, y
    la evidencia no se borra nunca. Eso no es una restricción técnica sino el
    punto entero de esta parte del producto.
    """
    _ensure_draft(batch)
    batch.delete()


# --- Correr -----------------------------------------------------------------


def run_batch(batch: Batch) -> Batch:
    """
    Pasa el borrador a la cola y encola el trabajo.

    Es el **único** camino de `DRAFT` a `QUEUED`, y por lo tanto el único lugar
    del producto donde se decide que algo va a salir hacia Google.
    """
    _ensure_draft(batch)
    ensure_operational(batch.domain)

    pending = batch.indexing_requests.filter(state=IndexingState.PENDING).count()
    if not pending:
        raise NothingToSend(
            'Este lote no tiene ninguna dirección pendiente. Devolvé alguna de las que '
            'sacaste, o armá uno nuevo.'
        )

    batch.state = BatchState.QUEUED
    batch.total_items = pending
    batch.save(update_fields=['state', 'total_items', 'updated_at'])

    from apps.indexing.tasks import send_indexing_batch

    transaction.on_commit(lambda: send_indexing_batch.delay(str(batch.id)))
    return batch


def resume_batch(batch: Batch) -> Batch:
    """
    Retoma un lote que se detuvo por un error, desde donde quedó.

    **Nunca reenvía lo que ya salió.** Recorre las que siguen en `PENDING` y las
    `SENT` no vuelven a entrar: cada reenvío gasta del techo real de Google, así
    que un reanudar flojo consume el cupo dos veces y encima ensucia la
    evidencia con envíos que nadie pidió.
    """
    if batch.kind != BatchKind.URL_INDEXING:
        raise BatchNotEditable('Este lote no es de indexación.')

    if batch.state in (BatchState.QUEUED, BatchState.RUNNING):
        raise BatchNotEditable('Este lote ya está corriendo.')

    ensure_operational(batch.domain)

    pending = batch.indexing_requests.filter(state=IndexingState.PENDING).count()
    if not pending:
        raise NothingToSend('No quedó nada pendiente en este lote.')

    batch.state = BatchState.QUEUED
    batch.finished_at = None
    batch.save(update_fields=['state', 'finished_at', 'updated_at'])

    from apps.indexing.tasks import send_indexing_batch

    transaction.on_commit(lambda: send_indexing_batch.delay(str(batch.id)))
    return batch


def clear_error(request: IndexingRequest) -> IndexingRequest:
    """
    Devuelve a la cola una dirección que había quedado marcada con un error.

    **No borra la evidencia.** `request_body` y `response_body` quedan donde
    estaban: lo que se limpia es el estado que frena el lote, no el rastro de lo
    que pasó. Un pedido fallido y su respuesta son prueba tanto como un éxito.
    """
    if request.state != IndexingState.ERROR:
        return request

    request.state = IndexingState.PENDING
    request.error_code = ''
    request.error_message = ''
    request.save(update_fields=['state', 'error_code', 'error_message', 'updated_at'])
    return request


def send_batch(batch: Batch, *, client=None) -> Batch:
    """
    Manda los pedidos pendientes del lote, en orden, hasta que Google diga basta.

    Guarda la evidencia **siempre**, salga bien o salga mal, y lo hace antes de
    decidir si sigue: si la escritura quedara después de la bifurcación, el
    único caso que perdería su prueba sería justamente el del error que corta,
    que es el que hay que poder mostrar.
    """
    batch.state = BatchState.RUNNING
    batch.started_at = batch.started_at or timezone.now()
    batch.save(update_fields=['state', 'started_at', 'updated_at'])

    pending = list(
        batch.indexing_requests.filter(state=IndexingState.PENDING).order_by('position')
    )

    if not pending:
        return _close(batch, stopped_by='')

    api = client or IndexingClient(batch.domain.account)
    stopped_by = ''

    for done, request in enumerate(pending, start=1):
        call = api.publish(request.loc)

        request.request_body = call.request_body
        request.response_status = call.response_status
        request.response_body = call.response_body

        if call.ok:
            request.state = IndexingState.SENT
            request.sent_at = timezone.now()
            request.error_code = ''
            request.error_message = ''
            batch.processed_items += 1
        else:
            request.state = IndexingState.ERROR
            request.error_code = call.error_code
            request.error_message = call.error_message
            batch.failed_items += 1

        request.save(
            update_fields=[
                'state',
                'sent_at',
                'request_body',
                'response_status',
                'response_body',
                'error_code',
                'error_message',
                'updated_at',
            ]
        )

        if not call.ok and cuts_the_batch(call.error_code):
            stopped_by = call.error_code
            break

        if done % PROGRESS_EVERY == 0:
            batch.save(update_fields=['processed_items', 'failed_items', 'updated_at'])

    return _close(batch, stopped_by=stopped_by)


# --- Lectura ----------------------------------------------------------------


def batch_progress(batch: Batch) -> dict:
    """Cuántas de cada clase tiene el lote, contadas sobre las filas y no sobre el resumen."""
    counts = dict.fromkeys(IndexingState.values, 0)
    for row in batch.indexing_requests.values('state').annotate(total=Count('id')):
        counts[row['state']] = row['total']

    return {
        'pending': counts[IndexingState.PENDING],
        'sent': counts[IndexingState.SENT],
        'errors': counts[IndexingState.ERROR],
        'removed': counts[IndexingState.REMOVED],
    }


def url_requests(url):
    """Todos los pedidos que se hicieron sobre una dirección, del más nuevo al más viejo."""
    return (
        IndexingRequest.objects.filter(url=url)
        .exclude(state=IndexingState.REMOVED)
        .select_related('batch')
        .order_by('-created_at')
    )


def has_been_requested(url) -> bool:
    """
    Si alguna vez salió un pedido por esta dirección.

    Lo usa la regla del recorrido automático para `FETCH_ERROR`, que entra una
    sola vez por URL: si el fallo era pasajero el pedido sirvió, y si sigue
    igual el problema está en el sitio y reintentar sólo quema cupo.
    """
    return IndexingRequest.objects.filter(
        url=url, state__in=(IndexingState.SENT, IndexingState.ERROR)
    ).exists()


class ConnectionState:
    """
    Qué sabemos del acceso a la Indexing API, y **de dónde lo sabemos**.

    Los cinco salen de la evidencia guardada y de ningún otro lado. No hay una
    consulta que lo averigüe: la Indexing API no tiene lectura, y lo único que
    se le puede pedir es que publique una dirección de verdad. Sondearla para
    tildar una casilla gastaría una de las publicaciones del día del proyecto
    entero, así que no se sondea.

    `NEVER_ASKED` e `INCONCLUSIVE` son estados honestos y no huecos por llenar:
    los dos significan «no lo sabemos», y decir «funciona» sin haberlo
    comprobado es exactamente lo que este producto no hace.
    """

    #: Nunca salió un pedido. No hay nada que Google nos haya dicho.
    NEVER_ASKED = 'NEVER_ASKED'
    #: El último pedido salió y Google lo aceptó.
    WORKING = 'WORKING'
    #: La API está apagada en el proyecto de Google Cloud.
    API_NOT_ENABLED = 'API_NOT_ENABLED'
    #: La cuenta de servicio no es propietaria de la propiedad.
    NOT_AUTHORIZED = 'NOT_AUTHORIZED'
    #: Google contestó otra cosa, que no dice nada sobre la conexión: una
    #: dirección rechazada, un corte. Adjudicárselo mandaría a habilitar una API
    #: que puede estar encendida hace meses.
    INCONCLUSIVE = 'INCONCLUSIVE'


@dataclass(frozen=True)
class IndexingConnection:
    """Lo que la evidencia dice sobre la conexión, con la prueba al lado."""

    state: str
    #: Cuándo salió el pedido del que sale esta conclusión. Nulo si no hubo.
    checked_at: object = None
    #: Sobre qué dirección salió. Es lo que hace verificable la afirmación: sin
    #: ella, «funciona» es una palabra nuestra y no un hecho con respaldo.
    url: str = ''
    batch_id: str = ''


def connection_state(account) -> IndexingConnection:
    """
    Lo que sabemos del acceso a la Indexing API de una cuenta.

    Vive acá y no en la pantalla ni en el paso del recorrido porque **los dos lo
    necesitan y tienen que decir lo mismo**. Escrito dos veces, es de la clase
    de regla que se afloja: alguien agrega un código en un lado, y la tarjeta de
    la conexión y el recorrido empiezan a contradecirse sobre la misma cuenta.

    **Manda la última respuesta**, y ordenar eso tiene una trampa: una fila
    rechazada no tiene `sent_at` —nunca salió— y PostgreSQL pone los nulos
    primero al ordenar de mayor a menor. Ordenar por `sent_at` a secas dejaba
    arriba justo la que no había salido, así que un permiso arreglado seguía
    informándose como roto para siempre. Por eso se ordena por «cuándo contestó
    Google», que en un éxito es `sent_at` y en un rechazo es cuándo se escribió
    la respuesta.
    """
    latest = (
        IndexingRequest.objects.filter(
            batch__domain__account=account,
            state__in=(IndexingState.SENT, IndexingState.ERROR),
        )
        .annotate(answered_at=Coalesce('sent_at', 'updated_at'))
        .order_by('-answered_at')
        .first()
    )

    if latest is None:
        return IndexingConnection(ConnectionState.NEVER_ASKED)

    common = {
        'checked_at': latest.sent_at or latest.updated_at,
        'url': latest.loc,
        'batch_id': str(latest.batch_id),
    }

    if latest.state == IndexingState.SENT:
        return IndexingConnection(ConnectionState.WORKING, **common)

    reasons = {
        GoogleErrorCode.API_NOT_ENABLED: ConnectionState.API_NOT_ENABLED,
        GoogleErrorCode.PERMISSION_DENIED: ConnectionState.NOT_AUTHORIZED,
    }
    return IndexingConnection(
        reasons.get(latest.error_code, ConnectionState.INCONCLUSIVE), **common
    )


def pending_request_exists(url) -> bool:
    """
    Si esta dirección ya está esperando en un lote que todavía no terminó.

    Sirve para no proponerla dos veces. Dos borradores con la misma dirección
    adentro terminarían mandándola dos veces, y cada envío gasta del techo real
    de Google para decirle lo mismo.
    """
    return IndexingRequest.objects.filter(
        url=url,
        state=IndexingState.PENDING,
        batch__state__in=(BatchState.DRAFT, BatchState.QUEUED, BatchState.RUNNING),
    ).exists()


# --- Interno ----------------------------------------------------------------


def _ensure_draft(batch: Batch) -> None:
    if batch.kind != BatchKind.URL_INDEXING:
        raise BatchNotEditable('Este lote no es de indexación.')

    if batch.state != BatchState.DRAFT:
        raise BatchNotEditable(
            'Este lote ya salió de borrador. Lo que se envió a Google no se puede cambiar.'
        )


def _recount(batch: Batch) -> None:
    batch.total_items = batch.indexing_requests.exclude(state=IndexingState.REMOVED).count()
    batch.save(update_fields=['total_items', 'updated_at'])


def _close(batch: Batch, *, stopped_by: str) -> Batch:
    """
    Cierra el lote con el estado que le corresponde y deja el aviso.

    Un lote que se detuvo por un error es `PARTIAL` y nunca `COMPLETED`, igual
    que en la inspección (R-C): quedaron direcciones sin enviar, y llamarlo
    terminado convertiría un envío parcial en una afirmación sobre el lote
    entero.
    """
    # Las cifras se recuentan sobre las filas y no se leen del contador que
    # `send_batch` fue subiendo. La diferencia aparece al reanudar: el contador
    # es acumulado entre corridas, así que un lote que se detuvo por un error,
    # se limpió y terminó entero seguiría diciendo que tuvo una falla y cerraría
    # `PARTIAL` para siempre. Lo que hay que informar es en qué quedó el lote, y
    # eso lo dicen las filas.
    #
    # La evidencia del intento fallido no se pierde: sigue en su fila, con su
    # pedido y su respuesta. Lo que deja de contarse es el intento, no el rastro.
    counts = batch_progress(batch)
    still_pending = counts['pending']

    batch.processed_items = counts['sent']
    batch.failed_items = counts['errors']

    if still_pending:
        batch.state = BatchState.PARTIAL
    elif counts['errors'] and not counts['sent']:
        batch.state = BatchState.FAILED
    elif counts['errors']:
        batch.state = BatchState.PARTIAL
    else:
        batch.state = BatchState.COMPLETED

    summary = {
        'sent': counts['sent'],
        'failed': counts['errors'],
        'pending': still_pending,
        'removed': counts['removed'],
    }
    if stopped_by:
        # Por qué se detuvo, en código y no en prosa: es lo que la pantalla
        # necesita para ofrecer «limpiar y reanudar» sin leer un mensaje.
        summary['stopped_by'] = stopped_by

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

    from apps.notifications import services as notifications

    notifications.batch_finished(batch)
    return batch
