"""
Servicios de la cuenta: su estado derivado y sus sesiones abiertas.

## Estado derivado (FR-064)

No es una tabla: se calcula mirando la credencial del módulo y los dominios con
acceso perdido. Existe porque los dos estados responden preguntas distintas y
sólo juntos dicen si algo funciona. El estado del dominio describe su propiedad
en Search Console; el de la cuenta describe si tenemos con qué consultarla.

Sin esta unión, un dominio cuya propiedad está perfectamente autorizada se
mostraría «operativo» mientras la credencial de la cuenta está muerta, y nadie
entendería por qué no llega ningún dato.

## Sesiones abiertas (FR-002, FR-045, FR-059)

La misma pregunta se contesta desde tres lados —la pantalla, la API pública y el
panel interno— y por eso vive acá y no en una vista. La regla de oro es que el
almacén de sesiones de Django manda: acá se lista lo que él da por vivo y se
cierra borrando primero allá.
"""

from collections.abc import Iterable
from dataclasses import asdict, dataclass, field
from datetime import timedelta
from importlib import import_module
from uuid import UUID

from django.conf import settings
from django.db.models import QuerySet
from django.urls import reverse
from django.utils import timezone

from apps.accounts.models import Session
from apps.core.errors import NotFound
from apps.core.tables import slug
from apps.credentials.models import CredentialStatus, Module
from apps.credentials.services import shared_active_credential
from apps.domains.models import AccessState
from apps.domains.services import shared_domains
from apps.jobs.models import Batch, BatchState

#: Hasta cuándo un lote que falló sigue contando como trabajo pendiente.
#:
#: Con ventana y no todos: uno que falló hace tres semanas por una credencial que
#: ya se arregló es historia, no pendiente, y dejarlo en la lista entrena a
#: ignorar la lista entera.
FAILED_BATCH_WINDOW = timedelta(days=1)


@dataclass(frozen=True)
class Notice:
    """
    Aviso permanente de la interfaz.

    Lleva la acción además del motivo: un aviso que describe un problema sin
    decir adónde ir obliga a buscar la pantalla, que es la fricción que el
    producto viene a sacar. `action_path` es esa acción, y se decide acá porque
    depende de los datos —a los lotes de *ese* dominio si hay uno solo—.

    **No lleva texto.** El código dice cuál es el aviso y `params` trae lo que la
    oración necesita: cuántas propiedades, cuántos lotes. El texto lo arma el
    catálogo del cliente, que es el único que sabe en qué idioma se está
    leyendo, y también es el que sabe poner el plural de ese idioma.
    """

    code: str
    action_path: str = ''
    level: str = 'warning'
    params: dict = field(default_factory=dict)


@dataclass(frozen=True)
class AccountState:
    """
    Lo que alimenta el aviso permanente, con los nombres del contrato.

    `can_operate` es el campo que manda: mientras sea falso, ningún dominio
    puede presentarse como operativo, sin importar lo que diga su propia
    columna.
    """

    credential_status: str | None
    can_operate: bool
    domains_with_lost_access: int
    onboarding_completed: bool
    display_timezone: str
    credential_client_email: str | None = None
    domains_total: int = 0
    notices: list[Notice] = field(default_factory=list)

    def as_dict(self) -> dict:
        return asdict(self)


def account_state(account) -> AccountState:
    """Arma el estado que la interfaz muestra de forma permanente, arriba de todo."""
    credential = shared_active_credential(Module.SEARCH_CONSOLE)
    can_operate = bool(credential and credential.is_usable)

    # Sólo los activos: este estado alimenta el aviso permanente de la barra
    # superior, y un sitio dado de baja que siga contando ahí pondría a la cuenta
    # entera en rojo por algo que nadie puede resolver desde ninguna pantalla.
    domains = shared_domains()
    access_lost = domains.filter(access_state=AccessState.ACCESS_LOST).count()
    awaiting_access = domains.filter(access_state=AccessState.AWAITING_ACCESS).count()

    progress = getattr(account, 'onboarding', None)

    return AccountState(
        credential_status=credential.status if credential else None,
        can_operate=can_operate,
        domains_with_lost_access=access_lost,
        onboarding_completed=bool(domains.exists() or (progress and progress.is_finished)),
        display_timezone=settings.DISPLAY_TIMEZONE,
        credential_client_email=credential.client_email if credential else None,
        domains_total=domains.count(),
        notices=_notices(
            credential,
            can_operate,
            access_lost,
            awaiting_access,
            _failed_batches(account),
        ),
    )


def _failed_batches(account) -> list[str]:
    """
    De qué dominio es cada lote que falló desde ayer, con repetidos.

    Devuelve los dominios y no los lotes porque es lo que decide el destino del
    aviso: la lista de lotes es **de un dominio**, así que con un solo dominio
    golpeado se puede llevar ahí, y con varios no hay una sola pantalla que los
    muestre juntos.
    """
    return list(
        Batch.objects.filter(
            domain__deactivated_at__isnull=True,
            state=BatchState.FAILED,
            finished_at__gte=timezone.now() - FAILED_BATCH_WINDOW,
        ).values_list('domain_id', flat=True)
    )


def _notices(
    credential,
    can_operate: bool,
    access_lost: int,
    awaiting_access: int,
    failed_batches: list[str],
) -> list[Notice]:
    """
    Todo lo que la cuenta tiene pendiente, de lo más grave a lo menos.

    Es **una sola lista** y no una por familia porque así se muestra: la barra
    superior la rinde entera bajo un indicador, y quien la abre necesita ver
    primero lo que impide operar y después lo que puede esperar.

    El orden lo da la posición en esta función y no un `sort`: las de nivel
    `error` se agregan antes que las de `warning`, y dentro de cada nivel el
    orden es el de la lectura —primero la conexión, que es la que puede estar
    causando todo lo demás—.
    """
    notices: list[Notice] = []

    if credential is None:
        notices.append(
            Notice(
                code='CREDENTIAL_MISSING',
                level='info',
                action_path=reverse('settings'),
            )
        )
    elif credential.status == CredentialStatus.UNVERIFIED:
        notices.append(
            Notice(
                code='CREDENTIAL_UNVERIFIED',
                level='warning',
                action_path=reverse('settings'),
            )
        )
    elif credential.status == CredentialStatus.REVOKED:
        notices.append(
            Notice(
                code='CREDENTIAL_REVOKED',
                level='error',
                action_path=reverse('settings'),
            )
        )
    elif not can_operate:
        # Cuando Google dio un motivo, es el motivo lo que se muestra: viene en
        # el idioma en que Google lo dijo y no se traduce, porque traducir lo que
        # dijo otro es ponerle palabras en la boca. Sin motivo hay un texto
        # nuestro, y por eso son dos códigos.
        detail = credential.last_error_detail
        notices.append(
            Notice(
                code='CREDENTIAL_INVALID_DETAIL' if detail else 'CREDENTIAL_INVALID',
                level='error',
                params={'detail': detail} if detail else {},
                action_path=reverse('settings'),
            )
        )

    if access_lost:
        notices.append(
            Notice(
                code='DOMAINS_ACCESS_LOST',
                level='error',
                params={'count': access_lost},
                # A la conexión, que es donde el sitio muestra su estado de acceso
                # y ofrece volver a comprobarlo. Antes esto llevaba al listado con
                # el filtro puesto: un recorte de una lista de varios, que ya no
                # existe. El destino tiene que ser la pantalla donde el problema
                # se resuelve, no una donde se lo vuelve a leer (RT-18).
                action_path=reverse('settings'),
            )
        )

    # --- Lo que puede esperar ------------------------------------------------

    if awaiting_access:
        notices.append(
            Notice(
                code='DOMAINS_AWAITING_ACCESS',
                level='warning',
                params={'count': awaiting_access},
                # Mismo destino y mismo motivo que el aviso de acceso perdido: en
                # la conexión está la dirección de la cuenta de servicio que hay
                # que autorizar en Search Console, que es el paso que falta.
                action_path=reverse('settings'),
            )
        )

    if failed_batches:
        hit = set(failed_batches)
        notices.append(
            Notice(
                code='BATCHES_FAILED',
                level='warning',
                # `domains` no es decoración: decide el texto de la acción, que
                # tiene que decir adónde lleva. Con un solo sitio golpeado se va
                # a sus lotes.
                #
                # El otro camino quedó defensivo: la interfaz trabaja contra un
                # solo sitio, así que `hit` no debería traer dos. Si igual pasa
                # —una cuenta anterior a ese cambio, con dos dominios activos— se
                # va a la conexión, que es donde se ve cuál es el sitio; el
                # listado que antes los mostraba juntos ya no tiene dirección.
                params={'count': len(failed_batches), 'domains': len(hit)},
                action_path=(
                    f'{reverse("batches", args=[next(iter(hit))])}?state={slug(BatchState.FAILED)}'
                    if len(hit) == 1
                    else reverse('settings')
                ),
            )
        )

    return notices


# --- Sesiones abiertas ------------------------------------------------------


def _session_store():
    """
    La clase de almacén que la instalación tenga configurada.

    Se resuelve por configuración y no importando el almacén de base de datos
    directamente: si mañana las sesiones pasan a Redis, cerrar una sesión tiene
    que seguir cerrándola de verdad y no borrar una fila que ya nadie consulta.
    """
    return import_module(settings.SESSION_ENGINE).SessionStore


def _discard_expired(account) -> None:
    """
    Da de baja las sesiones que el almacén ya no reconoce, **sin recorrerlas**.

    Una sesión que venció sola sigue teniendo acá su `revoked_at` vacío, y
    listarla como abierta sería ofrecer cerrar algo que ya no existe. El
    descarte se hacía preguntando por cada fila —`SessionStore().exists(...)`,
    una consulta por sesión—, y eso alcanzaba mientras la lista se armaba
    entera en memoria. Desde que la pantalla pagina en el servidor no alcanza:
    para saber el total hay que haber descartado **todas** las vencidas, no las
    cincuenta de la página, así que el costo pasaría a ser una consulta por
    sesión de la cuenta en cada visita.

    El almacén de base de datos —el que trae Django por omisión— publica su
    modelo, y ahí «sigue abierta» es una condición que la base sabe evaluar de a
    muchas: se pide en una sola sentencia. Un almacén sin tabla —caché, cookies
    firmadas— no tiene esa condición, y ahí se vuelve a preguntar fila por fila:
    es lento, pero es correcto, y no se puede resolver mejor desde afuera del
    almacén.
    """
    open_rows = Session.objects.filter(account=account, revoked_at__isnull=True)
    store = _session_store()
    stored_model = getattr(store, 'get_model_class', None)
    now = timezone.now()

    if stored_model is None:
        expired = [row.id for row in open_rows if not store().exists(row.session_key)]
        if expired:
            Session.objects.filter(id__in=expired).update(revoked_at=now, updated_at=now)
        return

    alive = stored_model().objects.filter(expire_date__gt=now).values('session_key')
    open_rows.exclude(session_key__in=alive).update(revoked_at=now, updated_at=now)


#: Coincidencias literales para nombrar el navegador y el sistema. El orden
#: importa: Edge y Opera se anuncian también como Chrome, y Android e iPhone
#: mencionan Linux y Mac OS X en su propia cadena.
_BROWSERS = (
    ('Edg/', 'Edge'),
    ('OPR/', 'Opera'),
    ('Firefox/', 'Firefox'),
    ('Chrome/', 'Chrome'),
    ('Safari/', 'Safari'),
)

_SYSTEMS = (
    ('Android', 'Android'),
    ('iPhone', 'iOS'),
    ('iPad', 'iPadOS'),
    ('Windows NT', 'Windows'),
    ('Mac OS X', 'macOS'),
    ('Linux', 'Linux'),
)


def describe_user_agent(value: str) -> tuple[str | None, str | None]:
    """
    Navegador y sistema declarados, o nada cuando no se reconocen.

    Deliberadamente corto y por coincidencia literal. La alternativa —una
    biblioteca que interpreta la cadena entera— siempre devuelve un nombre
    plausible, y un nombre inventado es peor que no decir nada: quien mira la
    lista lo usa para decidir si esa sesión es suya y cerrarla o no.
    """
    text = value or ''
    browser = next((name for marker, name in _BROWSERS if marker in text), None)
    system = next((name for marker, name in _SYSTEMS if marker in text), None)
    return browser, system


@dataclass(frozen=True)
class SessionInfo:
    """
    Una sesión tal como la ven la pantalla y la API.

    `is_current` lo decide el servidor comparando la clave de la sesión en
    curso. Que lo adivinara el navegador es justamente lo que haría peligrosa la
    acción principal de la vista.
    """

    id: str
    is_current: bool
    ip_address: str | None
    user_agent: str | None
    browser: str | None
    operating_system: str | None
    last_activity_at: str
    created_at: str

    def as_dict(self) -> dict:
        return asdict(self)


def session_info(row: Session, current_session_key: str | None) -> SessionInfo:
    """
    Una fila tal como la ven la pantalla y la API.

    Es pública porque la pantalla ya no recibe la lista entera: `table_props()`
    pagina el queryset y serializa fila por fila, así que necesita esta
    traducción suelta. Que sea la misma que usa `active_sessions()` es lo que
    garantiza que la tabla y la API digan lo mismo de la misma sesión.
    """
    browser, system = describe_user_agent(row.user_agent)
    return SessionInfo(
        id=str(row.id),
        is_current=bool(current_session_key) and row.session_key == current_session_key,
        ip_address=row.ip_address or None,
        user_agent=row.user_agent or None,
        browser=browser,
        operating_system=system,
        last_activity_at=row.last_activity_at.isoformat(),
        created_at=row.created_at.isoformat(),
    )


def record_session(
    account, *, session_key: str, ip_address: str = '', user_agent: str = ''
) -> Session:
    """
    Anota la sesión recién iniciada.

    Es `update_or_create` y no `create` porque la clave la elige Django: un
    reingreso que no la rote reusaría la misma y el alta fallaría por unicidad,
    dejando esa sesión fuera de la lista justo cuando alguien la busca.
    """
    now = timezone.now()
    row, _ = Session.objects.update_or_create(
        session_key=session_key,
        defaults={
            'account': account,
            'ip_address': ip_address,
            'user_agent': user_agent,
            'last_activity_at': now,
            'revoked_at': None,
        },
    )
    return row


def revoke_session_record(session_key: str) -> None:
    """Da de baja la fila al salir. Del almacén se encarga Django en el mismo acto."""
    now = timezone.now()
    Session.objects.filter(session_key=session_key, revoked_at__isnull=True).update(
        revoked_at=now, updated_at=now
    )


def touch_session(session_key: str) -> None:
    """Marca actividad. Cada cuánto llamarla lo decide el middleware, no esto."""
    now = timezone.now()
    Session.objects.filter(session_key=session_key, revoked_at__isnull=True).update(
        last_activity_at=now, updated_at=now
    )


def open_sessions(account) -> QuerySet:
    """
    Las sesiones abiertas de la cuenta, **como consulta** y no como lista.

    Devolver el queryset es lo que permite que la pantalla pagine, ordene y
    cuente en el servidor (RT-09): una lista ya materializada obliga a traer
    todas las filas para mostrar cincuenta, que es justamente lo que el paginado
    viene a evitar. Antes de devolverla se descartan las vencidas, porque el
    total que la tabla anuncia tiene que contar sólo las que siguen vivas.
    """
    _discard_expired(account)
    return Session.objects.filter(account=account, revoked_at__isnull=True)


def active_sessions(account, *, current_session_key: str | None = None) -> list[SessionInfo]:
    """
    Lo mismo, ya traducido y entero, para quien no pagina.

    Lo usan la API pública y el panel interno, que devuelven la lista completa.
    La pantalla no pasa por acá desde que pagina: arma sus filas con
    `open_sessions()` y `session_info()`, que son las dos mitades de esto.
    """
    return [session_info(row, current_session_key) for row in open_sessions(account)]


def close_session(account, *, session_id) -> Session:
    """
    Cierra una sesión de esta cuenta.

    El filtro por cuenta es lo que impide cerrar la sesión de otra persona. La
    respuesta cuando no aparece es «no existe» y no «no es tuya»: distinguirlas
    convertiría el endpoint en una forma de confirmar identificadores ajenos.
    """
    row = Session.objects.filter(id=session_id, account=account, revoked_at__isnull=True).first()
    if row is None:
        raise NotFound('Esa sesión no existe en esta cuenta o ya está cerrada.')

    _close(row)
    return row


def close_sessions(
    account,
    *,
    keep_session_key: str | None = None,
    only_ids: Iterable[str] | None = None,
) -> int:
    """
    Cierra las sesiones de la cuenta y devuelve cuántas cerró.

    Sin `keep_session_key` las cierra todas: es lo que necesita el panel interno
    ante una cuenta comprometida (FR-045). Con él conserva esa, que es la acción
    «cerrar todas las demás» de la pantalla.

    `only_ids` es la otra acción de la pantalla: cerrar lo que quedó marcado en
    la tabla. Una lista **vacía** cierra cero y no todas, que es la diferencia
    entre «no elegí ninguna» y «no acoté la selección»; por eso se distingue de
    `None` y no por si viene con contenido.

    Los identificadores que no son un UUID se descartan en silencio, igual que
    hace la tabla con un filtro que no reconoce: llegan desde el navegador, y uno
    inventado tiene que quedar afuera y no reventar la petición entera.
    """
    open_sessions = Session.objects.filter(account=account, revoked_at__isnull=True)
    if keep_session_key:
        open_sessions = open_sessions.exclude(session_key=keep_session_key)
    if only_ids is not None:
        open_sessions = open_sessions.filter(id__in=_as_uuids(only_ids))

    rows = list(open_sessions)
    for row in rows:
        _close(row)
    return len(rows)


def _as_uuids(values: Iterable[str]) -> list[UUID]:
    """Los que son un UUID de verdad. El resto no llega a la consulta."""
    parsed: list[UUID] = []
    for value in values:
        try:
            parsed.append(UUID(str(value)))
        except (AttributeError, TypeError, ValueError):
            continue
    return parsed


def _close(row: Session) -> None:
    # El almacén primero. Si algo se corta entre las dos escrituras queda una
    # sesión ya invalidada con una fila que dice «abierta», y eso el listado lo
    # corrige solo; al revés quedaría una fila «cerrada» con una cookie que
    # sigue entrando, que es la única de las dos que engaña.
    _session_store()(row.session_key).delete()
    row.revoked_at = timezone.now()
    row.save(update_fields=['revoked_at', 'updated_at'])


def purge_session_records() -> int:
    """
    Borra las filas cerradas que ya cumplieron su retención.

    La fila sobrevive un tiempo al cierre a propósito: la sospecha suele
    aparecer después, y sin ese resto no queda desde dónde se entró. Cumplido el
    plazo, el agente de usuario y la dirección dejan de tener para qué, y
    guardarlos más sería guardarlos porque sí.
    """
    cutoff = timezone.now() - timedelta(days=settings.SESSION_RECORD_RETENTION_DAYS)
    deleted, _ = Session.objects.filter(revoked_at__lt=cutoff).delete()
    return deleted
