"""
La puerta hacia la Indexing API de Google.

Es un cliente aparte del de Search Console y no un método más de aquél, por tres
razones que no se acumulan sino que apuntan al mismo lado: es otra API
(`indexing.googleapis.com`), pide otro alcance, y su techo lo administra el
proyecto de Google Cloud en vez de la propiedad. Meterla adentro de
`SearchConsoleClient` habría puesto dos APIs en una clase cuyo nombre nombra
una, y —lo que importa de verdad— habría obligado a perforar el control de cuota
que esa clase le impone a todas sus llamadas: acá no hay reserva que pedir,
porque la decisión del producto es no llevar contador propio.

**Este cliente nunca levanta la excepción de un fallo de Google.** Devuelve
siempre un `IndexingCall`, salga bien o salga mal, con el cuerpo enviado y el
recibido tal como viajaron. Ésa es toda la razón de ser del módulo: la respuesta
que informa que se agotó la cuota es tan evidencia como la que confirma un
envío, y un cliente que la convirtiera en excepción dejaría el dato más
importante en el rastreo de una traza en vez de en una fila de la base.
"""

import json
import logging
import time
from dataclasses import dataclass, field

from googleapiclient.errors import HttpError

from apps.gsc.auth import build_indexing_service, indexing_service_for
from apps.gsc.client import MAX_ATTEMPTS, backoff
from apps.gsc.errors import GoogleCallError, GoogleErrorCode
from apps.indexing.errors import classify_indexing, message_for

logger = logging.getLogger(__name__)


class NotificationType:
    """
    Qué se le está diciendo a Google sobre la dirección.

    `URL_UPDATED` cubre tanto «esta página es nueva» como «esta página cambió»:
    Google no distingue las dos, y usar un tipo distinto para cada una sería
    inventar una diferencia que la API no tiene.
    """

    URL_UPDATED = 'URL_UPDATED'
    URL_DELETED = 'URL_DELETED'


@dataclass
class IndexingCall:
    """
    Lo que ocurrió en una llamada, con las dos puntas guardadas.

    Es un valor y no un error: se devuelve igual cuando Google acepta y cuando
    rechaza. Quien llama decide qué hacer mirando `error_code`, y lo que guarda
    es esta pieza entera.
    """

    request_body: dict
    response_status: int | None = None
    response_body: dict = field(default_factory=dict)
    error_code: str = ''
    error_message: str = ''

    @property
    def ok(self) -> bool:
        return not self.error_code


class IndexingClient:
    """
    Cliente de la Indexing API atado a una cuenta.

    Igual que el de Search Console, recibe la cuenta y resuelve la credencial al
    usarla: es lo que permite reemplazar una clave sin tocar ninguna fila
    (FR-012). La credencial es la misma para las dos APIs.
    """

    def __init__(self, account=None, *, credential=None, service=None, sleep=time.sleep):
        self._account = account
        self._credential = credential
        self._service = service
        self._sleep = sleep

    @property
    def service(self):
        if self._service is None:
            if self._credential is not None:
                self._service = build_indexing_service(self._credential)
            else:
                self._service = indexing_service_for(self._account)
        return self._service

    def publish(self, url: str, *, notification_type: str = NotificationType.URL_UPDATED):
        """
        Le pide a Google que mire una dirección, y devuelve lo que pasó.

        No promete indexación y este método tampoco la afirma en su nombre: lo
        que hace es publicar un aviso. Que Google lo honre no está bajo control
        de nadie de este lado, y el producto entero está construido sobre esa
        distinción.
        """
        request_body = {'url': url, 'type': notification_type}

        try:
            service = self.service
        except GoogleCallError as exc:
            # La credencial no sirve o no se pudo descifrar. Es un fallo previo
            # a la red, y se guarda igual: para la evidencia, «no se pudo ni
            # intentar» es un desenlace tan registrable como un rechazo.
            return IndexingCall(
                request_body=request_body,
                error_code=exc.code,
                error_message=exc.message,
            )

        last: IndexingCall | None = None

        for attempt in range(1, MAX_ATTEMPTS + 1):
            request = service.urlNotifications().publish(body=request_body)

            try:
                response = request.execute()
            except HttpError as exc:
                last = _from_http_error(request_body, exc)
                if _is_permanent(last.error_code):
                    return last
            except (TimeoutError, ConnectionError, OSError) as exc:
                last = IndexingCall(
                    request_body=request_body,
                    error_code=GoogleErrorCode.PROVIDER_UNAVAILABLE,
                    error_message=message_for(GoogleErrorCode.PROVIDER_UNAVAILABLE),
                    response_body={'error': type(exc).__name__},
                )
            else:
                return IndexingCall(
                    request_body=request_body,
                    response_status=200,
                    response_body=response or {},
                )

            if attempt < MAX_ATTEMPTS:
                delay = backoff(attempt)
                logger.warning(
                    'La Indexing API respondió %s para %s; reintento %s de %s en %.1fs',
                    last.error_code,
                    url,
                    attempt,
                    MAX_ATTEMPTS,
                    delay,
                )
                self._sleep(delay)

        return last or IndexingCall(
            request_body=request_body,
            error_code=GoogleErrorCode.PROVIDER_UNAVAILABLE,
            error_message=message_for(GoogleErrorCode.PROVIDER_UNAVAILABLE),
        )


#: Motivos que no mejoran reintentando, más el rechazo de una dirección puntual.
#:
#: `URL_REJECTED` entra acá aunque no sea un fallo del canal: reintentar cinco
#: veces una dirección que Google ya dijo que está mal formada gasta cinco
#: llamadas del techo real para volver al mismo lugar.
_PERMANENT = frozenset(
    {
        GoogleErrorCode.PERMISSION_DENIED,
        GoogleErrorCode.PROPERTY_NOT_FOUND,
        GoogleErrorCode.API_NOT_ENABLED,
        GoogleErrorCode.INVALID_KEY,
        GoogleErrorCode.QUOTA_EXCEEDED,
        'URL_REJECTED',
    }
)


def _is_permanent(code: str) -> bool:
    return code in _PERMANENT


def _from_http_error(request_body: dict, exc: HttpError) -> IndexingCall:
    """
    Arma la evidencia de un rechazo, con el cuerpo entero de la respuesta.

    A diferencia de Search Console, acá el cuerpo **no se recorta**: es el
    producto de esta task. Lo que allá se recorta por prudencia —el cuerpo puede
    devolver la petición que lo originó, y en una llamada firmada eso incluiría
    la cabecera de autorización— no aplica igual, así que se guarda lo que
    Google contestó salvo esa cabecera, que se saca si aparece.
    """
    status = getattr(exc.resp, 'status', None)
    raw = ''
    if exc.content:
        raw = (
            exc.content.decode(errors='ignore')
            if isinstance(exc.content, bytes)
            else str(exc.content)
        )

    try:
        body = json.loads(raw) if raw else {}
    except ValueError:
        # Google contestó algo que no es JSON —una pasarela intermedia, casi
        # siempre—. Se guarda como texto: una respuesta ilegible sigue siendo
        # la respuesta que llegó, y perderla sería perder la prueba.
        body = {'raw': raw[:4000]}

    if isinstance(body, dict):
        body = _without_authorization(body)

    code = classify_indexing(status, raw)
    return IndexingCall(
        request_body=request_body,
        response_status=status,
        response_body=body if isinstance(body, dict) else {'raw': str(body)[:4000]},
        error_code=code,
        error_message=message_for(code),
    )


def _without_authorization(body: dict) -> dict:
    """
    Saca la cabecera de autorización si Google devolvió la petición dentro del error.

    Es lo único que se censura de la evidencia, y se censura porque es un
    secreto vivo: el resto se guarda entero, incluido lo que no entendemos.
    """
    cleaned = dict(body)
    for key in list(cleaned):
        if key.lower() in ('authorization', 'auth'):
            cleaned[key] = '[redacted]'
    return cleaned
