"""
Pantallas de la cuenta: sus claves de API (V11) y sus sesiones abiertas (V12).

Como el resto de las vistas: arman props y delegan. Las dos cosas que se
resuelven acá y no en un servicio son decisiones de navegación, no de negocio:
qué hacer cuando alguien cierra su propia sesión, y por qué el alta de una clave
responde JSON en vez de redirigir como el resto de los formularios.

**El valor en claro de una clave no viaja nunca como prop de Inertia.** Se
devuelve una sola vez, en el cuerpo de la respuesta al alta, y de ahí no sale
del estado de React de la pantalla que lo pidió. Puesto en las props quedaría
además guardado en el historial de navegación de Inertia, y el botón «Atrás»
volvería a mostrar un secreto que el contrato promete irrepetible (FR-041,
RT-06).
"""

from django.contrib.auth import logout as django_logout
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.decorators import login_required
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import validate_email
from django.http import Http404, JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.views.decorators.http import require_http_methods
from inertia import render

from apps.accounts.models import Account, ApiKey, Language, Theme
from apps.accounts.preferences import Preferences, remember
from apps.accounts.services import close_session, close_sessions, open_sessions, session_info
from apps.core.errors import NotFound, ValidationFailed, stash_errors
from apps.core.requests import field, field_list
from apps.core.tables import TableSpec, from_slug, slug, table_props

#: Cuántas sesiones se cerraron en la acción anterior, esperando a la pantalla
#: siguiente. Va en la sesión porque la acción redirige: la respuesta que tiene
#: que anunciar el resultado es otra petición (RT-11).
CLOSED_COUNT_KEY = '_closed_sessions'

#: Qué guardó la acción anterior del perfil: `preferences`, `email` o `password`.
#: Mismo motivo que el conteo de sesiones, y por eso mismo dice **cuál** de las
#: tres: las tres redirigen a la misma pantalla, y un «listo» a secas dejaría
#: sin saber si se guardó lo que se acaba de tocar o lo de hace un minuto.
SAVED_KEY = '_profile_saved'

#: Nombre de la clave revocada en la acción anterior. Mismo motivo que el
#: conteo de sesiones: la acción redirige, así que quien anuncia el resultado es
#: la petición siguiente.
REVOKED_KEY_NAME = '_revoked_key'

#: La pestaña del perfil donde viven las sesiones, tal como la nombra la
#: dirección.
#:
#: El servidor la nombra porque es él quien redirige. Las tres acciones sobre
#: sesiones vuelven al perfil, y volver a la pestaña de omisión dejaría el
#: resultado anunciado en la que no se está mirando: quien acaba de cerrar una
#: sesión vería la pantalla de preferencias y ninguna señal de que pasó algo
#: (RT-11).
SESSIONS_TAB = 'sessions'

#: Tope del nombre de una clave: el mismo `max_length` que declara el modelo.
#: Sin comprobarlo antes, un nombre más largo llega a la base y vuelve como un
#: error del motor en vez de como un mensaje al lado del campo.
MAX_KEY_NAME_LENGTH = 100

#: Por qué se puede ordenar la lista de claves vigentes.
#:
#: Las claves de una cuenta son pocas, pero la tabla pasa igual por el paginado
#: del servidor (RT-09): es lo que hace que el orden viaje en la dirección y que
#: recargar después de revocar una muestre exactamente lo mismo que se estaba
#: mirando.
API_KEYS = TableSpec(
    sortable={
        'name': ('name',),
        'created': ('created_at',),
        'used': ('last_used_at', 'created_at'),
    },
    default_sort='-created',
)

#: Por qué se puede ordenar la lista de claves revocadas.
#:
#: Es otra tabla y no la misma con un filtro: una clave revocada no se reactiva,
#: así que su columna de fecha es «Revocada» y no «Creada», y ordenar por esa
#: fecha es lo que se hace después de un incidente (FR-061). Las revocadas no se
#: borran nunca, así que la lista sólo crece y tiene que paginar (RT-09).
REVOKED_KEYS = TableSpec(
    sortable={
        'name': ('name',),
        'revoked': ('revoked_at',),
        'used': ('last_used_at', 'created_at'),
    },
    default_sort='-revoked',
)

#: Prefijos de las dos listas de claves en la querystring.
#:
#: Las dos lo llevan, y eso no es simetría decorativa: un `page` sin prefijo en
#: una página con dos tablas no se puede atribuir a ninguna, así que `table_props`
#: no lo arrastra y la otra lista volvería a su primera página en cada clic.
ACTIVE_KEYS_PREFIX = 'active_'
REVOKED_KEYS_PREFIX = 'revoked_'

# --- Perfil (V13) -----------------------------------------------------------


@login_required
def profile(request):
    """
    Todo lo que es de la cuenta y de ninguna herramienta.

    Las sesiones abiertas viven acá adentro y no en una pantalla propia: la
    pregunta «¿desde dónde estoy adentro?» es de la misma familia que «¿con qué
    correo entro?» y «¿en qué idioma leo?». Las claves de API sí quedan aparte,
    porque no son de quien entra: las usa un pipeline de despliegue.

    `is_current` lo marca el servidor comparando la clave de la sesión en curso.
    Dejárselo adivinar al navegador convertiría la acción principal de la lista
    —cerrar una sesión— en una ruleta.

    **Las sesiones viajan enteras y no paginadas**, que es la excepción
    declarada a RT-09. Vale para esta lista y no para las demás: las sesiones
    abiertas de una cuenta están acotadas por naturaleza —una por dispositivo—,
    mientras que las URLs de un sitio no. Y la tabla del navegador pagina igual,
    así que lo que crece con la cuenta es el JSON y no lo dibujado. A cambio, la
    lista se busca y se ordena sin ir al servidor, que es lo que hace útil una
    tabla de veinte filas.

    `others_count` viaja aparte y no se cuenta en el navegador: la confirmación
    del cierre masivo lleva la cantidad en el título («¿Cerrar las otras 21
    sesiones?») y esa cantidad es la de la cuenta entera, no la de lo que el
    buscador de la tabla dejó a la vista.
    """
    current_key = request.session.session_key
    # Se materializa una sola vez: `open_sessions()` descarta las vencidas contra
    # el almacén en cada llamada, y pedirla dos veces pagaría ese trabajo dos
    # veces para contestar dos preguntas sobre las mismas filas.
    sessions = [
        session_info(row, current_key)
        for row in open_sessions(request.user).order_by('-last_activity_at', 'pk')
    ]

    return render(
        request,
        'Profile',
        props={
            'email': request.user.email,
            'sessions': [session.as_dict() for session in sessions],
            'others_count': sum(1 for session in sessions if not session.is_current),
            'closed_count': request.session.pop(CLOSED_COUNT_KEY, None),
            # Qué se guardó recién, para que la pantalla lo anuncie. Va por la
            # sesión porque la acción redirige: quien tiene que decirlo es la
            # petición siguiente (RT-11).
            'saved': request.session.pop(SAVED_KEY, None),
        },
    )


@login_required
@require_http_methods(['POST'])
def preferences_update(request):
    """
    Guarda idioma y tema, y los deja también en el navegador.

    La cookie no es un duplicado ocioso: la pantalla de ingreso no tiene cuenta
    de la cual leer nada, y sin ella quien lee el producto en español vería el
    ingreso en inglés cada vez.
    """
    language = from_slug(field(request, 'language'), Language.values)
    theme = from_slug(field(request, 'theme'), Theme.values)

    if not language or not theme:
        # No hay campo al que pegarle el error: los dos controles ofrecen sólo
        # valores válidos, así que llegar acá con otra cosa es un envío armado a
        # mano y no una equivocación de quien mira la pantalla.
        stash_errors(request, {'form': {'code': 'PREFERENCES_INVALID'}})
        return redirect('profile')

    request.user.language = language
    request.user.theme = theme
    request.user.save(update_fields=['language', 'theme', 'updated_at'])

    request.session[SAVED_KEY] = 'preferences'
    return remember(
        redirect('profile'),
        Preferences(language=slug(language), theme=slug(theme)),
    )


@login_required
@require_http_methods(['POST'])
def email_update(request):
    """
    Cambia la dirección con la que se ingresa.

    No cierra ninguna sesión: la que está abierta lo sigue estando, porque lo que
    la sostiene es la cookie de sesión y no el correo. Cerrar sesiones por un
    cambio de dirección obligaría a volver a ingresar con una dirección que
    todavía no se terminó de confirmar que funciona.
    """
    email = field(request, 'email').strip()

    if not email:
        stash_errors(request, {'email': {'code': 'EMAIL_REQUIRED'}})
        return redirect('profile')

    try:
        validate_email(email)
    except DjangoValidationError:
        stash_errors(request, {'email': {'code': 'EMAIL_INVALID'}})
        return redirect('profile')

    normalized = Account.objects.normalize_email(email)

    if normalized.lower() == request.user.email.lower():
        # Guardar lo mismo no es un error, pero tampoco es un cambio: anunciarlo
        # como «actualizamos tu correo» sería mentir sobre algo que no pasó.
        return redirect('profile')

    if Account.objects.filter(email__iexact=normalized).exclude(pk=request.user.pk).exists():
        stash_errors(request, {'email': {'code': 'EMAIL_TAKEN'}})
        return redirect('profile')

    request.user.email = normalized
    request.user.save(update_fields=['email', 'updated_at'])

    request.session[SAVED_KEY] = 'email'
    return redirect('profile')


@login_required
@require_http_methods(['POST'])
def password_update(request):
    """
    Cambia la contraseña sin echar a quien la está cambiando.

    `update_session_auth_hash` es lo que evita eso: cambiar la contraseña
    invalida el testigo de sesión de Django, y sin refrescarlo la respuesta
    siguiente llegaría sin sesión —con el cambio ya hecho— a la pantalla de
    ingreso. Las **otras** sesiones tampoco se cierran: si la razón para cambiar
    la contraseña fue una sospecha, cerrarlas es la acción de al lado, y hacerlo
    en silencio dejaría a la persona creyendo que ya resolvió algo que no.
    """
    current = field(request, 'current_password')
    new = field(request, 'new_password')
    confirmation = field(request, 'confirm_password')

    if not request.user.check_password(current):
        stash_errors(request, {'current_password': {'code': 'PASSWORD_WRONG'}})
        return redirect('profile')

    if new != confirmation:
        stash_errors(request, {'confirm_password': {'code': 'PASSWORD_MISMATCH'}})
        return redirect('profile')

    try:
        validate_password(new, user=request.user)
    except DjangoValidationError as exc:
        stash_errors(request, {'new_password': _password_error(exc)})
        return redirect('profile')

    request.user.set_password(new)
    request.user.save(update_fields=['password', 'updated_at'])
    update_session_auth_hash(request, request.user)

    request.session[SAVED_KEY] = 'password'
    return redirect('profile')


def _password_error(exc: DjangoValidationError) -> dict:
    """
    El primer motivo por el que Django rechazó la contraseña, como código.

    Se manda el código y no el mensaje aunque Django ya lo tenga escrito: el
    texto que ve la persona sale del catálogo del cliente, y dejar que una de
    las oraciones venga del servidor la dejaría siempre en inglés en una pantalla
    que está en español.

    Va el primero y no los cuatro: cuatro reproches simultáneos debajo de un
    campo se leen como una pared, y el segundo se corrige recién cuando el
    primero deja de aplicar.
    """
    first = exc.error_list[0]
    return {'code': (first.code or 'PASSWORD_INVALID').upper(), 'params': first.params or {}}


# --- Sesiones abiertas (V12) ------------------------------------------------


def _sessions_tab() -> str:
    """La dirección del perfil con la pestaña de sesiones ya elegida."""
    return f'{reverse("profile")}?tab={SESSIONS_TAB}'


@login_required
def index(request):
    """
    La dirección vieja de las sesiones, que ahora se muestran en el perfil.

    Sigue existiendo porque es una dirección que alguien pudo guardar: contestar
    404 a un favorito es romper algo que funcionaba, y la pantalla no
    desapareció —se mudó—. Y se mudó a una pestaña, así que el favorito tiene
    que llegar a la pestaña y no al perfil a secas.
    """
    return redirect(_sessions_tab())


@login_required
@require_http_methods(['POST'])
def close(request, session_id):
    """Cierra una sesión de la cuenta."""
    try:
        session = close_session(request.user, session_id=session_id)
    except NotFound as exc:
        raise Http404(exc.message) from exc

    # Cerrar la propia sesión es echarse a uno mismo: la cookie ya no sirve y
    # quedarse en la pantalla mostraría una lista que no se puede volver a
    # pedir. La interfaz no ofrece el botón en la fila actual, pero la ruta
    # existe y un envío hecho a mano llega igual hasta acá.
    if session.session_key == request.session.session_key:
        django_logout(request)
        return redirect('login')

    request.session[CLOSED_COUNT_KEY] = 1
    return redirect(_sessions_tab())


@login_required
@require_http_methods(['POST'])
def close_others(request):
    """Cierra todas las demás y conserva la actual, para no expulsar a quien la pidió."""
    closed = close_sessions(request.user, keep_session_key=request.session.session_key)

    request.session[CLOSED_COUNT_KEY] = closed
    return redirect(_sessions_tab())


@login_required
@require_http_methods(['POST'])
def close_selected(request):
    """
    Cierra las sesiones que quedaron marcadas en la tabla.

    Conserva la actual **aunque venga en la lista**. La interfaz no ofrece
    casilla en esa fila, pero la ruta existe y un envío hecho a mano llega igual
    hasta acá: echarse a uno mismo desde una acción sobre varias filas es un
    accidente distinto de cerrarse la propia sesión a propósito, que sigue
    teniendo su ruta y su redirección a la pantalla de ingreso.

    Una selección vacía cierra cero y contesta que cerró cero. No es un error:
    es lo que pasa si alguien desmarca todo entre que abre la confirmación y la
    acepta, y un mensaje de error ahí obligaría a explicar algo que ya se ve.
    """
    closed = close_sessions(
        request.user,
        keep_session_key=request.session.session_key,
        only_ids=field_list(request, 'session_ids'),
    )

    request.session[CLOSED_COUNT_KEY] = closed
    return redirect(_sessions_tab())


# --- Claves de API (V11) ----------------------------------------------------


@login_required
def api_keys(request):
    """
    Claves vigentes y claves revocadas, en dos listas separadas.

    Las revocadas van aparte, no como un filtro apagado de la misma tabla:
    después de un incidente lo que se busca es justamente qué clave existió,
    hasta cuándo y cuándo se usó por última vez, y eso tiene que estar a la vista
    al llegar (FR-061).

    Y van paginadas, como las vigentes. Una clave revocada no se borra nunca, así
    que esa lista sólo crece: es exactamente el tipo de lista que RT-09 obliga a
    paginar en el servidor. Que las dos convivan en la misma dirección sin
    pisarse es lo que resuelve el prefijo.
    """
    own_keys = ApiKey.objects.filter(account=request.user)

    return render(
        request,
        'ApiKeys',
        props={
            'keys': table_props(
                request,
                own_keys.filter(revoked_at__isnull=True),
                API_KEYS,
                serialize=_key_row,
                prefix=ACTIVE_KEYS_PREFIX,
            ),
            'revoked': table_props(
                request,
                own_keys.filter(revoked_at__isnull=False),
                REVOKED_KEYS,
                serialize=_key_row,
                prefix=REVOKED_KEYS_PREFIX,
            ),
            'revoked_name': request.session.pop(REVOKED_KEY_NAME, None),
            'docs_url': reverse('api-docs'),
            # La dirección absoluta y no una relativa: el ejemplo de integración
            # se copia a un pipeline que corre en otra máquina, donde «/api/v1»
            # no apunta a ningún lado.
            'api_base': request.build_absolute_uri('/api/v1'),
        },
    )


@login_required
@require_http_methods(['POST'])
def api_key_create(request):
    """
    Emite una clave y devuelve su valor en claro, la única vez que existe.

    Responde JSON y no una redirección de Inertia a propósito. El resto de los
    formularios redirige porque su resultado se puede volver a pedir; éste
    devuelve el único ejemplar de un secreto, y una prop de Inertia queda
    guardada en el historial del navegador: el botón «Atrás» lo volvería a
    mostrar. Así el valor no sale del estado de React de quien lo pidió (RT-06).
    """
    name = field(request, 'name').strip()

    if not name:
        return _invalid_create('La clave necesita un nombre para poder reconocerla después.')

    if len(name) > MAX_KEY_NAME_LENGTH:
        return _invalid_create(
            f'El nombre no puede pasar de {MAX_KEY_NAME_LENGTH} caracteres; escribiste {len(name)}.'
        )

    key, plaintext = ApiKey.issue(account=request.user, name=name)
    return JsonResponse({**_key_row(key), 'key': plaintext}, status=201)


@login_required
@require_http_methods(['POST'])
def api_key_revoke(request, key_id):
    """
    Revoca una clave. No la borra: la fila queda listada con su fecha (FR-061).

    Es `POST` y no `DELETE` porque no se borra nada, y porque el envío sale de
    un formulario de la pantalla; el `DELETE` de la API pública sigue siendo el
    de `apps/accounts/api.py`.
    """
    key = ApiKey.objects.filter(id=key_id, account=request.user).first()
    if key is None:
        raise Http404('Esa clave no existe en esta cuenta.')

    # Revocarla de nuevo no cambia la fecha, así que tampoco se anuncia: el
    # mensaje diría «revocamos» sobre algo que quedó revocado hace una semana.
    if not key.is_revoked:
        key.revoke()
        request.session[REVOKED_KEY_NAME] = key.name

    return redirect('api_keys')


def _invalid_create(message: str) -> JsonResponse:
    """
    El rechazo del alta, con la forma del contrato de errores.

    Lleva el campo en `details` para que la pantalla lo muestre pegado al
    control que lo causó y no en un aviso suelto arriba de todo (RT-08).
    """
    error = ValidationFailed(message, field='name')
    return JsonResponse(error.as_body(), status=error.status_code)


def _key_row(key: ApiKey) -> dict:
    """
    Lo que la pantalla puede mostrar de una clave.

    Se arma campo por campo y no con un serializador del modelo: es la garantía
    de que `hashed_key` no puede salir por acá aunque mañana alguien agregue un
    campo (principio III). El prefijo alcanza para reconocer cuál es cada clave;
    el hash no distingue nada que el prefijo no distinga y, publicado, permite
    comprobar conjeturas contra el secreto sin llamar a la API.
    """
    return {
        'id': str(key.id),
        'name': key.name,
        'prefix': key.prefix,
        'created_at': key.created_at.isoformat(),
        'last_used_at': key.last_used_at.isoformat() if key.last_used_at else None,
        'revoked_at': key.revoked_at.isoformat() if key.revoked_at else None,
    }
