"""
Reintento con backoff para las llamadas a los servicios de StoryFace.

Los endpoints de IA (`picasso-fast-seed`) y de remoción de fondo (`remove-bg`)
fallan de forma esporádica cuando les entra un lote en paralelo: se han visto
500 «Failed to generate image with model» y 500 «Failed to use credits» con la
misma petición funcionando 20 s después. Sin reintento esas generaciones se
pierden para siempre: nadie vuelve a pasar por una foto ya ordenada.

Solo se repite lo que puede arreglarse solo (red, timeout, 429, 5xx). Un error
de configuración —API key ausente o inválida, cualquier 4xx— falla al primer
intento: repetirlo únicamente retrasa el error.

Ajustable por entorno: `SF_RETRY_ATTEMPTS` (intentos totales, 1 = sin
reintento) y `SF_RETRY_BASE_DELAY` (segundos de la primera espera).
"""
import asyncio
import logging
import random
import re
from typing import Awaitable, Callable, TypeVar

import httpx
from fastapi import HTTPException

logger = logging.getLogger(__name__)

T = TypeVar("T")

from core import config  # noqa: E402

RETRY_ATTEMPTS = config.SF_RETRY_ATTEMPTS
RETRY_BASE_DELAY = config.SF_RETRY_BASE_DELAY

# El status del proveedor viaja dentro del mensaje de error ("… (HTTP 500): …"),
# porque el status de la HTTPException es el que esta app devuelve al frontend.
_HTTP_STATUS_RE = re.compile(r"HTTP (\d{3})")
_PERMANENT_MARKERS = ("no api key", "invalid api key", "sin api key")


def _retriable_status(code: int) -> bool:
    return code in (408, 429) or code >= 500


def is_transient(exc: BaseException) -> bool:
    """¿Merece la pena repetir esta llamada tal cual?"""
    if isinstance(exc, httpx.RequestError):          # red caída, timeout, DNS…
        return True
    if isinstance(exc, httpx.HTTPStatusError):
        return _retriable_status(exc.response.status_code)
    if isinstance(exc, HTTPException):
        detail = str(exc.detail)
        if any(m in detail.lower() for m in _PERMANENT_MARKERS):
            return False
        m = _HTTP_STATUS_RE.search(detail)
        if m:
            return _retriable_status(int(m.group(1)))
        return True      # fallo del proveedor sin status legible: se reintenta
    return False         # PIL, disco, programación… no se arregla repitiendo


async def with_retries(
    factory: Callable[[], Awaitable[T]],
    *,
    label: str,
    attempts: int | None = None,
    base_delay: float | None = None,
    retry_on: Callable[[BaseException], bool] = is_transient,
) -> T:
    """
    Ejecuta `factory()` reintentando los fallos transitorios con backoff
    exponencial y jitter (≈3 s, ≈6 s con los valores por defecto).

    `factory` es una función que DEVUELVE la corrutina, no una corrutina ya
    creada: hay que poder llamarla de nuevo en cada intento.

    Propaga la última excepción si se agotan los intentos o si el fallo no es
    transitorio, así que el llamante no cambia su manejo de errores.
    """
    total = RETRY_ATTEMPTS if attempts is None else max(1, attempts)
    delay = RETRY_BASE_DELAY if base_delay is None else base_delay

    for attempt in range(1, total + 1):
        try:
            return await factory()
        except Exception as exc:
            # str(exc) puede venir vacío (p.ej. httpx.ReadTimeout()) — sin el
            # nombre de la clase, esa línea de log no dice nada útil.
            detail = str(exc) or f"{type(exc).__name__} (sin mensaje)"
            if attempt >= total or not retry_on(exc):
                # El fallo DEFINITIVO: nadie más va a loguear esto (ai_health
                # solo guarda estado en memoria para el endpoint, no imprime
                # nada). Sin esta línea, un fallo final era invisible salvo
                # que el llamante tuviera su propio try/except con logging.
                logger.error(
                    "❌ %s falló definitivamente (intento %d/%d): %s",
                    label, attempt, total, detail,
                )
                raise
            wait = delay * (2 ** (attempt - 1)) * random.uniform(0.8, 1.2)
            logger.warning(
                "🔁 %s falló (intento %d/%d): %s — reintento en %.1fs",
                label, attempt, total, detail, wait,
            )
            await asyncio.sleep(wait)

    raise RuntimeError(f"with_retries({label}): estado inalcanzable")
