"""
Único punto de salida hacia Google.

Todo lo demás del sistema habla con Google a través de acá, y este módulo exige
una reserva de cupo concedida en toda llamada contra una API de cupo agotable.
Ésa es la razón de que exista como frontera y no como un puñado de funciones
sueltas: el principio II —la cuota es del usuario— queda garantizado por
construcción y no por disciplina. Si mañana alguien agrega una consulta nueva,
tiene que pasar por esta puerta, y la puerta pide el permiso.

**La excepción es Search Analytics, y es de naturaleza y no de conveniencia.**
`QuotaBudget` sabe describir un cupo que se agota —2.000 inspecciones por día y
por sitio— y no un límite de tasa que se renueva solo, que es lo que rige el
informe de rendimiento: 1.200 por minuto, sin tope diario. Descontarlo del
presupuesto de inspección le quitaría llamadas al recorrido de cobertura sin
gastar ninguna de las suyas. Sale por `_execute_unmetered`, que está nombrado
así justamente para que agregar algo por ese camino sea una decisión visible.

El reintento con retroceso y jitter no es una optimización: sin jitter, veinte
trabajadores que reciben un 429 al mismo tiempo reintentan al mismo tiempo, y el
segundo intento vuelve a fallar en bloque.
"""

import logging
import random
import time

from googleapiclient.errors import HttpError

from apps.gsc.auth import build_service, service_for
from apps.gsc.errors import GoogleCallError, GoogleErrorCode, classify
from apps.jobs.budget import Reservation

logger = logging.getLogger(__name__)

#: Cuántas veces se reintenta un fallo temporal y con qué demora base. Cinco
#: intentos con base de un segundo dan una espera acumulada de alrededor de
#: medio minuto, que es lo que suele durar un pico de limitación.
MAX_ATTEMPTS = 5
BASE_DELAY_SECONDS = 1.0
MAX_DELAY_SECONDS = 30.0

#: Cuántas filas devuelve Search Analytics por petición. Es el máximo que admite
#: la API: pedir menos sólo multiplicaría las llamadas para traer lo mismo.
SEARCH_ANALYTICS_ROW_LIMIT = 25_000


class MissingReservation(Exception):
    """
    Se intentó llamar a Google sin cupo concedido.

    Es un error de programación, no una condición de operación: significa que
    alguien esquivó el control de cuota. Por eso rompe fuerte en lugar de
    degradarse a un aviso.
    """


class SearchConsoleClient:
    """
    Cliente de Search Console atado a una cuenta.

    Recibe la cuenta y no la credencial: resolverla por módulo en el momento de
    usarla es lo que permite reemplazar una clave sin tocar ninguna fila de
    dominios (FR-012).
    """

    def __init__(self, account=None, *, credential=None, service=None, sleep=time.sleep):
        self._account = account
        self._credential = credential
        self._service = service
        # El dormir se inyecta para que la suite pueda ejercitar el retroceso
        # sin tardar medio minuto en cada caso.
        self._sleep = sleep

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

    # --- Operaciones ------------------------------------------------------

    def inspect_url(self, *, property_uri: str, url: str, reservation: Reservation) -> dict:
        """Inspecciona una URL. Consume exactamente una unidad del cupo concedido."""
        return self._execute(
            reservation,
            lambda: (
                self.service.urlInspection()
                .index()
                .inspect(body={'inspectionUrl': url, 'siteUrl': property_uri})
            ),
        )

    def list_sitemaps(self, *, property_uri: str, reservation: Reservation) -> dict:
        return self._execute(
            reservation, lambda: self.service.sitemaps().list(siteUrl=property_uri)
        )

    def submit_sitemap(self, *, property_uri: str, location: str, reservation: Reservation) -> dict:
        return (
            self._execute(
                reservation,
                lambda: self.service.sitemaps().submit(siteUrl=property_uri, feedpath=location),
            )
            or {}
        )

    def delete_sitemap(self, *, property_uri: str, location: str, reservation: Reservation) -> dict:
        return (
            self._execute(
                reservation,
                lambda: self.service.sitemaps().delete(siteUrl=property_uri, feedpath=location),
            )
            or {}
        )

    def list_sites(self, *, reservation: Reservation) -> dict:
        """
        Propiedades a las que la cuenta de servicio tiene acceso.

        Es la consulta con la que se comprueba una credencial recién cargada: si
        devuelve una lista vacía, la clave sirve pero nadie la autorizó todavía,
        que es un problema distinto de que la clave no sirva.
        """
        return self._execute(reservation, lambda: self.service.sites().list())

    def get_site(self, *, property_uri: str, reservation: Reservation) -> dict:
        """
        Confirma el acceso a una propiedad concreta.

        Se prefiere sobre recorrer el listado porque distingue los dos motivos
        que hay que separar: que la propiedad no exista en esa Search Console y
        que exista pero sin permiso para nosotros.
        """
        return self._execute(reservation, lambda: self.service.sites().get(siteUrl=property_uri))

    def search_analytics(
        self,
        *,
        property_uri: str,
        start_date: str,
        end_date: str,
        dimensions: list[str],
        row_limit: int = SEARCH_ANALYTICS_ROW_LIMIT,
        start_row: int = 0,
    ) -> dict:
        """
        Rendimiento en la búsqueda: clics, impresiones, CTR y posición.

        **Es la única operación de esta clase que no pide reserva**, y la
        excepción está medida, no asumida. `QuotaBudget` modela el cupo de la
        inspección de URLs, que es **agotable**: 2.000 por día y por sitio, y
        cuando se acaba hay que esperar al día siguiente. Search Analytics tiene
        otra clase de límite —1.200 por minuto y por sitio, **sin tope diario**,
        que se renuevan solos— así que descontarlo de ese presupuesto le comería
        inspecciones al recorrido de cobertura sin usar ninguna.

        Lo que sí comparte es el reintento: Google impone además cuotas por
        carga, no publicadas en números, así que un `RATE_LIMITED` es posible en
        un backfill grande y se maneja como en cualquier otra llamada.

        Las fechas van como `YYYY-MM-DD` y las devuelve Google en horario del
        Pacífico. Quien las guarde **no debe reinterpretarlas** en otra zona.
        """
        return self._execute_unmetered(
            lambda: self.service.searchanalytics().query(
                siteUrl=property_uri,
                body={
                    'startDate': start_date,
                    'endDate': end_date,
                    'dimensions': dimensions,
                    'rowLimit': row_limit,
                    'startRow': start_row,
                },
            )
        )

    # --- Ejecución --------------------------------------------------------

    def _execute(self, reservation: Reservation | None, build_request):
        """
        Valida la reserva y recién entonces arma y ejecuta la petición.

        El orden es deliberado y es lo que hace real a la garantía. Armar la
        petición no es gratis: construye el servicio, y construir el servicio
        descifra la credencial. Si el control llegara después, ya habríamos
        hecho trabajo con el material de la clave por una llamada que no tenía
        permiso para existir.
        """
        if reservation is None or reservation.granted <= 0:
            raise MissingReservation(
                'Se intentó llamar a Google sin una reserva de cupo concedida. '
                'Toda llamada tiene que pasar por apps.jobs.budget.reserve primero.'
            )

        return self._run(build_request)

    def _execute_unmetered(self, build_request):
        """
        Ejecuta sin control de cuota.

        Existe **una** operación que pasa por acá y está nombrada en
        `search_analytics`. No es un atajo para saltear el control: es la puerta
        de las APIs de Google cuyo límite es de tasa y no de cupo, que son las
        que `QuotaBudget` no sabe describir.

        Agregar una segunda operación por este camino exige comprobar en la
        documentación de Google que su límite también se renueva solo. Confundir
        el alcance de una cuota ya causó errores en este repositorio.
        """
        return self._run(build_request)

    def _run(self, build_request):
        """El bucle de reintento, común a los dos caminos."""
        request = build_request()
        last_error: GoogleCallError | None = None

        for attempt in range(1, MAX_ATTEMPTS + 1):
            try:
                return request.execute()
            except HttpError as exc:
                error = _from_http_error(exc)
                if error.is_permanent or error.code == GoogleErrorCode.QUOTA_EXCEEDED:
                    raise error from exc
                last_error = error
            except (TimeoutError, ConnectionError, OSError) as exc:
                last_error = GoogleCallError(
                    GoogleErrorCode.PROVIDER_UNAVAILABLE, detail=type(exc).__name__
                )

            if attempt < MAX_ATTEMPTS:
                delay = backoff(attempt)
                logger.warning(
                    'Google respondió %s; reintento %s de %s en %.1fs',
                    last_error.code,
                    attempt,
                    MAX_ATTEMPTS,
                    delay,
                )
                self._sleep(delay)

        raise last_error or GoogleCallError(GoogleErrorCode.PROVIDER_UNAVAILABLE)


def backoff(attempt: int) -> float:
    """
    Retroceso exponencial con jitter completo.

    Es público porque lo reusa el cliente de indexación: la política de espera
    es la misma para las dos APIs de Google, y tener dos copias garantiza que
    algún día difieran sin que nadie lo haya decidido.

    El jitter va sobre todo el intervalo y no sobre un margen chico: con muchos
    trabajadores golpeando a la vez, un margen angosto sigue agrupando los
    reintentos en la misma ventana.
    """
    ceiling = min(BASE_DELAY_SECONDS * (2 ** (attempt - 1)), MAX_DELAY_SECONDS)
    # Aleatoriedad no criptográfica a propósito: acá sólo hace falta que dos
    # trabajadores no elijan la misma espera, no que nadie pueda predecirla.
    return random.uniform(0, ceiling)  # noqa: S311


def _from_http_error(exc: HttpError) -> GoogleCallError:
    status = getattr(exc.resp, 'status', None)
    body = ''
    if exc.content:
        body = (
            exc.content.decode(errors='ignore')
            if isinstance(exc.content, bytes)
            else str(exc.content)
        )
    code = classify(status, body)
    # El cuerpo no se guarda entero: puede traer la petición que lo originó, y
    # en una llamada firmada eso incluye la cabecera de autorización.
    return GoogleCallError(code, http_status=status, detail=body[:200])
