"""
Un único lugar donde se decide cómo se ve un error.

Las dos superficies del producto fallan distinto y a propósito. La API devuelve
un código estable que un pipeline de despliegue puede comparar sin leer prosa;
la interfaz devuelve el error pegado al campo que lo causó, porque quien está
mirando la pantalla necesita saber dónde corregir, no qué identificador se
disparó.

Lo que no cambia entre las dos es que el mensaje diga qué hacer. Un error que
sólo informa que algo salió mal deja al usuario en el mismo lugar donde estaba.
"""

from dataclasses import dataclass, field
from http import HTTPStatus

from django.http import HttpRequest

# Las piezas de DRF se importan dentro de las funciones y no acá arriba a
# propósito: este módulo lo nombra la configuración de DRF como manejador de
# excepciones, y traer sus vistas al importarlo cierra un ciclo que impide
# arrancar. Los códigos de estado salen de la biblioteca estándar, que no
# arrastra nada.

#: Clave bajo la que viajan los errores de validación entre una petición que
#: falla y la respuesta siguiente. Se guardan en la sesión porque el flujo de
#: Inertia ante un error es redirigir de vuelta al formulario.
SESSION_ERRORS_KEY = '_validation_errors'


class ErrorCode:
    """
    Códigos estables de la API.

    Son parte del contrato: cambiarlos rompe integraciones que ya los comparan,
    así que se agregan pero no se renombran.
    """

    UNAUTHENTICATED = 'UNAUTHENTICATED'
    PERMISSION_DENIED = 'PERMISSION_DENIED'
    NOT_FOUND = 'NOT_FOUND'
    VALIDATION_FAILED = 'VALIDATION_FAILED'
    CONFLICT = 'CONFLICT'
    QUOTA_EXHAUSTED = 'QUOTA_EXHAUSTED'
    CREDENTIAL_NOT_READY = 'CREDENTIAL_NOT_READY'
    DOMAIN_NOT_OPERATIONAL = 'DOMAIN_NOT_OPERATIONAL'
    PROVIDER_UNAVAILABLE = 'PROVIDER_UNAVAILABLE'
    RATE_LIMITED = 'RATE_LIMITED'
    INTERNAL_ERROR = 'INTERNAL_ERROR'


@dataclass
class ApiError(Exception):
    """
    Error con código estable, pensado para salir por la API.

    `details` existe para lo que el cliente necesita para reaccionar sin
    interpretar el mensaje: el campo que falta, el cupo que queda, cuándo
    reintentar.
    """

    code: str
    message: str
    status_code: int = int(HTTPStatus.BAD_REQUEST)
    details: dict = field(default_factory=dict)

    def __str__(self) -> str:
        return f'{self.code}: {self.message}'

    def as_body(self) -> dict:
        body = {'code': self.code, 'message': self.message}
        if self.details:
            body['details'] = self.details
        return {'error': body}

    def as_response(self):
        from rest_framework.response import Response

        return Response(self.as_body(), status=self.status_code)


class Unauthenticated(ApiError):
    def __init__(self, message='La clave de API falta, no es válida o fue revocada.', **details):
        super().__init__(ErrorCode.UNAUTHENTICATED, message, HTTPStatus.UNAUTHORIZED, details)


class NotFound(ApiError):
    def __init__(self, message='El recurso no existe o no pertenece a esta cuenta.', **details):
        super().__init__(ErrorCode.NOT_FOUND, message, HTTPStatus.NOT_FOUND, details)


class ValidationFailed(ApiError):
    def __init__(self, message='Los datos enviados no son válidos.', **details):
        super().__init__(
            ErrorCode.VALIDATION_FAILED, message, HTTPStatus.UNPROCESSABLE_ENTITY, details
        )


class QuotaExhausted(ApiError):
    def __init__(self, message='Se agotó el cupo diario de esta propiedad.', **details):
        super().__init__(ErrorCode.QUOTA_EXHAUSTED, message, HTTPStatus.TOO_MANY_REQUESTS, details)


class CredentialNotReady(ApiError):
    def __init__(
        self,
        message='No hay una credencial de Search Console comprobada para esta cuenta.',
        **details,
    ):
        super().__init__(ErrorCode.CREDENTIAL_NOT_READY, message, HTTPStatus.CONFLICT, details)


class DomainNotOperational(ApiError):
    def __init__(self, message='El dominio no tiene acceso confirmado a su propiedad.', **details):
        super().__init__(ErrorCode.DOMAIN_NOT_OPERATIONAL, message, HTTPStatus.CONFLICT, details)


class ProviderUnavailable(ApiError):
    def __init__(self, message='Google no respondió. Volvé a intentar en unos minutos.', **details):
        super().__init__(
            ErrorCode.PROVIDER_UNAVAILABLE, message, HTTPStatus.SERVICE_UNAVAILABLE, details
        )


#: Traducción de los errores propios de DRF al formato del contrato. Sin esto,
#: la mitad de las respuestas de error saldrían con la forma de DRF y la otra
#: mitad con la nuestra, y un cliente tendría que contemplar las dos.
_DRF_STATUS_TO_CODE = {
    400: ErrorCode.VALIDATION_FAILED,
    401: ErrorCode.UNAUTHENTICATED,
    403: ErrorCode.PERMISSION_DENIED,
    404: ErrorCode.NOT_FOUND,
    405: ErrorCode.VALIDATION_FAILED,
    409: ErrorCode.CONFLICT,
    415: ErrorCode.VALIDATION_FAILED,
    422: ErrorCode.VALIDATION_FAILED,
    429: ErrorCode.RATE_LIMITED,
    503: ErrorCode.PROVIDER_UNAVAILABLE,
}


def api_exception_handler(exc, context):
    """Manejador de DRF: deja toda respuesta de error con la forma del contrato."""
    from rest_framework.views import exception_handler as drf_exception_handler

    if isinstance(exc, ApiError):
        return exc.as_response()

    response = drf_exception_handler(exc, context)
    if response is None:
        return None

    code = _DRF_STATUS_TO_CODE.get(response.status_code, ErrorCode.INTERNAL_ERROR)
    detail = response.data
    message = _flatten_message(detail)
    body = {'code': code, 'message': message}
    if isinstance(detail, dict) and set(detail) != {'detail'}:
        body['details'] = detail
    response.data = {'error': body}
    return response


def _flatten_message(detail) -> str:
    if isinstance(detail, dict):
        if 'detail' in detail:
            return str(detail['detail'])
        first = next(iter(detail.values()), '')
        return _flatten_message(first)
    if isinstance(detail, list):
        return _flatten_message(detail[0]) if detail else ''
    return str(detail)


# --- Errores de validación en la interfaz -----------------------------------


def as_error(exc: 'ApiError') -> dict:
    """
    Un error de servicio, con la forma que la pantalla sabe traducir.

    Existe para que las vistas no repitan la conversión doce veces y para que la
    hagan todas igual. `reason` gana sobre el código genérico cuando está: un
    `VALIDATION_FAILED` no dice nada que se pueda escribir debajo de un campo, y
    el motivo concreto —qué campo del archivo faltaba— sí.

    `field` no viaja adentro: es **dónde** se muestra el error, y eso lo decide
    la vista al elegir bajo qué clave lo guarda.
    """
    aside = {'field', 'reason', 'params'}
    rest = {name: value for name, value in exc.details.items() if name not in aside}

    return {
        'code': exc.details.get('reason') or exc.code,
        'params': exc.details.get('params') or rest,
    }


def stash_errors(request: HttpRequest, errors: dict[str, dict]) -> None:
    """
    Deja los errores listos para la respuesta siguiente.

    Van en la sesión y no en la respuesta porque el flujo de Inertia ante un
    formulario inválido es redirigir de vuelta: la respuesta que los muestra es
    otra petición.

    **Lo que viaja es un código y sus datos, nunca una oración.** El texto lo
    arma el catálogo del cliente, que es el único que sabe en qué idioma está
    leyendo esta persona; una oración escrita acá saldría siempre en el mismo
    idioma en una pantalla que puede estar en otro. La forma es
    `{campo: {'code': ..., 'params': {...}}}`, y `params` sólo aparece cuando el
    texto necesita un dato —cuántos caracteres faltan, qué límite se pasó—.

    La clave `form` está reservada para lo que no pertenece a ningún campo: unas
    credenciales que no coinciden no son culpa del correo ni de la contraseña.
    """
    request.session[SESSION_ERRORS_KEY] = errors


def pop_errors(request: HttpRequest) -> dict[str, dict]:
    """Lee los errores pendientes y los consume, para que no reaparezcan al recargar."""
    return request.session.pop(SESSION_ERRORS_KEY, {})
