"""
Resolución de credenciales por módulo.

La regla que sostiene todo este archivo: **el dominio no guarda con qué
credencial se lo consulta**. La resuelve acá, por módulo, en el momento de
usarla. Copiar el identificador de la credencial en cada dominio parecería más
directo, pero convertiría el reemplazo de una clave en una migración de datos
sobre todas las filas de dominios, y cualquier fila que quedara sin actualizar
seguiría intentando firmar con una clave muerta (FR-012).
"""

from dataclasses import dataclass

from django.db import transaction
from django.utils import timezone

from apps.core.errors import CredentialNotReady
from apps.credentials.crypto import encrypt
from apps.credentials.models import (
    Credential,
    CredentialError,
    CredentialStatus,
    GoogleProject,
    Module,
    ModuleCredential,
)
from apps.credentials.validation import (
    InvalidKeyFile,
    KeyFileError,
    validate_key_file,
    validate_project_id,
)


def active_assignment(account, module_code: str) -> ModuleCredential | None:
    """Asignación vigente de credencial para un módulo, o nada si no hay."""
    return (
        ModuleCredential.objects.filter(account=account, module__code=module_code, is_active=True)
        .select_related('credential', 'module', 'credential__google_project')
        .first()
    )


def active_credential(account, module_code: str) -> Credential | None:
    """
    Credencial asignada al módulo, esté comprobada o no.

    Devuelve también las inválidas a propósito: la pantalla de configuración
    tiene que poder mostrar *cuál* credencial dejó de servir, y para eso
    necesita la fila, no un vacío.
    """
    assignment = active_assignment(account, module_code)
    return assignment.credential if assignment else None


def shared_active_assignment(module_code: str) -> ModuleCredential | None:
    """
    Asignación vigente de la empresa única.

    Si ya existe un sitio, manda la credencial de su propietario porque es la
    que creó y opera ese recurso. Sin sitio todavía, se toma la primera
    asignación activa de la instalación. `active_assignment(account, ...)`
    conserva la frontera por propietario para una futura edición multitenant.
    """
    from apps.domains.services import primary_shared_domain

    domain = primary_shared_domain()
    assignments = ModuleCredential.objects.filter(
        module__code=module_code, is_active=True
    ).select_related('credential', 'module', 'credential__google_project')
    if domain is not None:
        return assignments.filter(account=domain.account).first()
    return assignments.order_by('created_at').first()


def shared_active_credential(module_code: str) -> Credential | None:
    """Credencial visible y utilizable por todos los perfiles de la compañía."""
    assignment = shared_active_assignment(module_code)
    return assignment.credential if assignment else None


def resolve_shared_credential(module_code: str) -> Credential:
    """Resuelve la credencial común con los mismos errores que la variante por cuenta."""
    credential = shared_active_credential(module_code)

    if credential is None:
        raise CredentialNotReady(
            'No Search Console credential has been uploaded yet.',
            module=module_code,
            reason='MISSING',
        )

    if not credential.is_usable:
        raise CredentialNotReady(
            _message_for_status(credential),
            module=module_code,
            reason=credential.status,
            credential_id=str(credential.id),
            error_code=credential.last_error_code or None,
        )

    return credential


def shared_resource_account(fallback_account, module_code: str = Module.SEARCH_CONSOLE):
    """
    Cuenta técnica bajo la que se escriben los recursos comunes del MVP.

    El esquema todavía exige un propietario. Mientras la instalación sea de una
    sola empresa, se conserva el propietario del sitio o de la asignación ya
    existente para que reemplazar una clave desde otro perfil sí reemplace la
    que usan los workers. En multitenant este puente desaparece y el workspace
    pasa a ser el propietario explícito.
    """
    from apps.domains.services import primary_shared_domain

    domain = primary_shared_domain()
    if domain is not None:
        return domain.account
    assignment = shared_active_assignment(module_code)
    return assignment.account if assignment is not None else fallback_account


def resolve_credential(account, module_code: str) -> Credential:
    """
    Devuelve la credencial con la que se puede trabajar, o falla explicando qué falta.

    Falla en vez de devolver nada porque el llamador siguiente es una llamada a
    Google: seguir sin credencial terminaría en un error de la biblioteca de
    Google que no le dice nada a nadie, en lugar de un mensaje que señala la
    pantalla donde se resuelve.
    """
    credential = active_credential(account, module_code)

    if credential is None:
        raise CredentialNotReady(
            'Todavía no cargaste una credencial para Search Console.',
            module=module_code,
            reason='MISSING',
        )

    if not credential.is_usable:
        raise CredentialNotReady(
            _message_for_status(credential),
            module=module_code,
            reason=credential.status,
            credential_id=str(credential.id),
            error_code=credential.last_error_code or None,
        )

    return credential


def _message_for_status(credential: Credential) -> str:
    if credential.status == CredentialStatus.UNVERIFIED:
        return 'La credencial está cargada pero todavía no se comprobó.'
    if credential.status == CredentialStatus.INVALID:
        return credential.last_error_detail or 'La credencial dejó de ser válida.'
    if credential.status == CredentialStatus.REVOKED:
        return 'La credencial fue revocada. Cargá una nueva para volver a operar.'
    return 'La credencial no está en condiciones de usarse.'


def search_console_module() -> Module:
    """El módulo del primer corte. Lo crea una migración de datos, así que siempre existe."""
    return Module.objects.get(code=Module.SEARCH_CONSOLE)


# --- Alta y reemplazo -------------------------------------------------------


@transaction.atomic
def upload_credential(
    account, *, project_id: str, key_file, module_code: str | None = None
) -> Credential:
    """
    Guarda una credencial nueva y la deja asignada al módulo.

    Reemplazar es exactamente esto: se crea una fila nueva y se desactiva la
    anterior. No se actualiza la vieja en su lugar porque el historial de qué
    clave se usó y hasta cuándo es lo que permite explicar después por qué un
    dominio dejó de responder en una fecha concreta.

    **Ninguna fila de dominios se toca.** La credencial se resuelve por módulo en
    tiempo de uso, así que reemplazarla no requiere recorrer nada (FR-012).
    """
    module_code = module_code or Module.SEARCH_CONSOLE

    declared = validate_project_id(project_id)
    validated = validate_key_file(key_file)

    if validated.project_id != declared:
        raise InvalidKeyFile(
            KeyFileError.PROJECT_ID_MISMATCH,
            f'The key belongs to project "{validated.project_id}" and you declared '
            f'"{declared}". They are two different projects.',
            field='project_id',
            params={'found': validated.project_id, 'declared': declared},
        )

    project, _ = GoogleProject.objects.get_or_create(
        account=account,
        project_id=declared,
        defaults={'display_name': ''},
    )

    credential = Credential.objects.create(
        account=account,
        google_project=project,
        client_email=validated.client_email,
        encrypted_key=encrypt(validated.raw),
        key_fingerprint=validated.fingerprint,
        private_key_id=validated.private_key_id,
        status=CredentialStatus.UNVERIFIED,
    )

    _assign_to_module(account, credential, module_code)
    return credential


def _assign_to_module(account, credential: Credential, module_code: str) -> ModuleCredential:
    """
    Deja esta credencial como la vigente del módulo, desactivando la anterior.

    Se desactiva primero y se crea después, en la misma transacción: la
    restricción de la base admite una sola asignación activa por cuenta y
    módulo, y hacerlo al revés la violaría a mitad de camino.
    """
    ModuleCredential.objects.filter(
        account=account, module__code=module_code, is_active=True
    ).update(is_active=False, updated_at=timezone.now())

    module = Module.objects.get(code=module_code)
    return ModuleCredential.objects.create(
        account=account, module=module, credential=credential, is_active=True
    )


# --- Comprobación -----------------------------------------------------------


@dataclass(frozen=True)
class VerificationResult:
    """
    Resultado de comprobar una credencial contra Google.

    Trae las propiedades accesibles porque el paso siguiente del recorrido es
    elegir una: pedirlas en otra llamada obligaría a gastar dos veces lo mismo y
    a manejar el caso de que la segunda falle después de que la primera dijo que
    todo estaba bien.
    """

    ok: bool
    status: str
    error_code: str = ''
    message: str = ''
    hint: str = ''
    properties: tuple = ()
    #: Falso cuando el veredicto no quedó escrito en la credencial.
    #:
    #: Pasa cuando no llegamos a preguntarle nada a Google. La pantalla lo
    #: necesita para saber que este resultado no va a estar cuando se recargue,
    #: y mostrarlo ella junto al botón que lo disparó (RT-11).
    persisted: bool = True


def verify_credential(credential: Credential, *, client=None) -> VerificationResult:
    """
    Pregunta a Google si la credencial sirve, y guarda el veredicto.

    Los cinco fallos se distinguen entre sí porque cada uno se arregla en una
    pantalla distinta: habilitar la API, autorizar la cuenta de servicio, volver
    a bajar la clave, dar de alta la propiedad, o simplemente esperar. Un único
    «no se pudo verificar» dejaría a la persona probando las cinco.
    """
    # Importaciones diferidas: acá empieza el trato con Google, y traerlo al
    # importar el módulo ataría cualquier consulta de credenciales a que la
    # biblioteca de Google esté disponible.
    from apps.gsc.client import SearchConsoleClient
    from apps.gsc.errors import GoogleCallError, GoogleErrorCode
    from apps.jobs.budget import reserve_verification

    reservation = reserve_verification(credential.account)
    if not reservation:
        # No se guarda nada porque no hubo comprobación: quedarnos sin cupo no
        # es un veredicto sobre la clave. Escribirlo pisaría la última
        # comprobación real y su fecha, que es el único dato que la pantalla
        # tiene para decir desde cuándo sabe lo que sabe.
        return VerificationResult(
            ok=False,
            status=credential.status,
            error_code=CredentialError.PROVIDER_UNAVAILABLE,
            message='Demasiadas comprobaciones seguidas. Probá de nuevo en un rato.',
            hint='El límite se reinicia mañana.',
            persisted=False,
        )

    client = client or SearchConsoleClient(credential=credential)

    try:
        response = client.list_sites(reservation=reservation)
    except GoogleCallError as exc:
        return _store_result(credential, _failure_result(exc, GoogleErrorCode, credential.status))
    except ValueError as exc:
        # La biblioteca de Google rechaza el material antes de salir a la red
        # cuando la clave está mal formada.
        return _store_result(
            credential,
            VerificationResult(
                ok=False,
                status=CredentialStatus.INVALID,
                error_code=CredentialError.INVALID_KEY,
                message='Google no pudo leer la clave del archivo.',
                hint=f'Detalle: {exc}',
            ),
        )

    properties = tuple(
        {
            'property_uri': entry.get('siteUrl', ''),
            'permission': entry.get('permissionLevel', ''),
        }
        for entry in (response or {}).get('siteEntry', [])
    )

    if not properties:
        return _store_result(
            credential,
            VerificationResult(
                ok=False,
                status=CredentialStatus.INVALID,
                error_code=CredentialError.NO_PROPERTIES,
                message=(
                    'La clave funciona, pero la cuenta de servicio todavía no tiene ninguna '
                    'propiedad autorizada en Search Console.'
                ),
                hint=(
                    f'Entrá a Search Console, elegí tu sitio, y en Configuración → Usuarios y '
                    f'permisos agregá {credential.client_email} como **propietario**. '
                    f'Un permiso menor no habilita la inspección de URLs.'
                ),
            ),
        )

    return _store_result(
        credential,
        VerificationResult(ok=True, status=CredentialStatus.VERIFIED, properties=properties),
    )


#: Cada fallo de Google se traduce al motivo que la pantalla sabe explicar.
_EQUIVALENT_ERROR = {
    'API_NOT_ENABLED': CredentialError.API_NOT_ENABLED,
    'INVALID_KEY': CredentialError.INVALID_KEY,
    'PERMISSION_DENIED': CredentialError.NO_PROPERTIES,
    'PROPERTY_NOT_FOUND': CredentialError.NO_PROPERTIES,
    'PROVIDER_UNAVAILABLE': CredentialError.PROVIDER_UNAVAILABLE,
    'QUOTA_EXCEEDED': CredentialError.PROVIDER_UNAVAILABLE,
    'RATE_LIMITED': CredentialError.PROVIDER_UNAVAILABLE,
}


def _failure_result(exc, codes, current_status: str) -> VerificationResult:
    reason = _EQUIVALENT_ERROR.get(exc.code, CredentialError.PROVIDER_UNAVAILABLE)

    # Que Google no responda no dice nada de la credencial: conserva el estado
    # que tenía. Bajar a «sin comprobar» una credencial verificada por un corte
    # de red apaga la cuenta entera —`can_operate` mira ese campo— y deja a la
    # pantalla mandando a recargar una clave que no tiene nada roto.
    status = (
        current_status
        if exc.code in (codes.PROVIDER_UNAVAILABLE, codes.RATE_LIMITED)
        else CredentialStatus.INVALID
    )

    return VerificationResult(
        ok=False, status=status, error_code=reason, message=exc.message, hint=exc.detail[:200]
    )


def _store_result(credential: Credential, result: VerificationResult) -> VerificationResult:
    # El estado anterior, para saber si esto es una caída o una confirmación de
    # algo que ya estaba roto. Sin la comparación, cada comprobación de una
    # credencial ya inválida dejaría un aviso nuevo.
    previous_status = credential.status

    credential.status = result.status
    credential.last_checked_at = timezone.now()
    credential.last_error_code = result.error_code
    credential.last_error_detail = result.message if not result.ok else ''

    fields = ['status', 'last_checked_at', 'last_error_code', 'last_error_detail', 'updated_at']

    # La lista de propiedades se toca sólo cuando Google contestó algo sobre
    # ellas. Vaciarla porque no pudimos preguntar diría «perdiste el acceso»
    # cuando lo único que pasó fue un corte de red.
    if result.ok or result.status == CredentialStatus.INVALID:
        credential.accessible_properties = [dict(prop) for prop in result.properties]
        fields.append('accessible_properties')

    credential.save(update_fields=fields)

    # Que la conexión se caiga apaga la cuenta entera, así que es el aviso que
    # menos puede faltar. Sólo al caer: la llave lleva además el código de
    # error, para que un motivo distinto sí vuelva a avisar.
    if result.status == CredentialStatus.INVALID and previous_status != CredentialStatus.INVALID:
        from apps.notifications import services as notifications

        notifications.credential_invalid(credential.account, credential)

    return result
