"""Listado, alta y ficha de dominios."""

from django.conf import settings as django_settings
from django.contrib.auth.decorators import login_required
from django.db.models import Case, IntegerField, Value, When
from django.http import Http404
from django.shortcuts import redirect
from django.views.decorators.http import require_http_methods
from inertia import render

from apps.core.errors import CredentialNotReady, ValidationFailed, as_error, stash_errors
from apps.core.requests import field, payload
from apps.core.tables import Filter, TableSpec, filter_options, table_props
from apps.coverage.services import (
    coverage_annotations,
    coverage_row,
    coverage_summary,
    cycle_estimate,
    pending_urls,
)
from apps.credentials.models import Module
from apps.credentials.services import resolve_shared_credential, shared_active_credential
from apps.domains.models import AccessState, Domain, PropertyType
from apps.domains.services import (
    check_access,
    create_company_domain,
    deactivate_domain,
    ensure_company_can_connect_another,
    shared_domain,
    shared_domains,
    update_settings,
)
from apps.jobs.budget import remaining
from apps.jobs.models import TERMINAL_BATCH_STATES, Batch

#: Cuánta atención pide cada estado de acceso.
#:
#: Arriba lo que espera una acción y abajo lo que ya funciona: quien abre el
#: listado viene a saber qué hacer, no a leer los dominios en orden alfabético.
#: `AWAITING_ACCESS` va primero porque es el paso que falta para que un dominio
#: empiece a servir; `ACCESS_LOST` después, que duele pero conserva el
#: historial. Los dos estados detenidos —revocado y suspendido— van al final
#: junto a lo operativo: no hay nada que hacer con ellos desde acá.
_ATTENTION = Case(
    When(access_state=AccessState.AWAITING_ACCESS, then=Value(0)),
    When(access_state=AccessState.ACCESS_LOST, then=Value(1)),
    When(access_state=AccessState.ACCESS_REVOKED, then=Value(2)),
    When(access_state=AccessState.SUSPENDED, then=Value(3)),
    default=Value(4),
    output_field=IntegerField(),
)

#: Qué se puede ordenar y filtrar en el listado (RT-09).
#:
#: `access_state` es el nombre público del filtro y no un detalle interno:
#: `/domains?access_state=access-lost` es la dirección a la que entra el aviso
#: `DOMAINS_ACCESS_LOST` de la cuenta (RT-18), así que cambiarlo rompe ese enlace.
#:
#: `coverage` ordena por las URLs con dato antes que por el total: ascendente
#: deja arriba los dominios de los que menos sabemos, que es la pregunta con la
#: que se mira esa columna.
DOMAINS = TableSpec(
    sortable={
        'domain': ('hostname',),
        'access': ('attention', 'hostname'),
        'checked': ('access_checked_at', 'hostname'),
        'coverage': ('coverage_known', 'coverage_total', 'hostname'),
    },
    default_sort='access',
    filters=(
        Filter(param='access_state', lookup='access_state', choices=tuple(AccessState.values)),
    ),
)


@login_required
def index(request):
    """
    Dominios de la cuenta: cuáles están operativos y cuáles piden una acción.

    Es la primera pantalla de una cuenta ya configurada, así que la columna que
    manda es el estado de acceso y el orden por omisión pone arriba lo que
    espera algo del usuario. La cobertura viaja en la misma consulta que los
    dominios (FR-062): con su denominador delante, es lo que permite decidir a
    cuál entrar sin abrirlos de a uno.
    """
    domains = shared_domains()

    return render(
        request,
        'Domains/Index',
        props={
            'domains': table_props(
                request,
                domains.annotate(attention=_ATTENTION, **coverage_annotations()),
                DOMAINS,
                serialize=_row,
            ),
            'access_states': filter_options(AccessState.choices),
            # Cuántos hay sin filtrar. Es lo que separa «esta cuenta no tiene
            # dominios» de «el filtro no encontró ninguno», que son dos pantallas
            # vacías con dos salidas distintas.
            'total': domains.count(),
            'created': _just_created(request, domains),
        },
    )


def _just_created(request, domains) -> str | None:
    """
    El dominio que acaba de crearse, si el `?created=` de la redirección es real.

    Se comprueba contra la base en vez de mostrar el parámetro tal cual porque
    ese texto se rinde como confirmación nuestra: sin la comprobación, un enlace
    armado a mano pondría la frase que quisiera en boca del producto.
    """
    hostname = (request.GET.get('created') or '').strip()
    if not hostname:
        return None
    return hostname if domains.filter(hostname=hostname).exists() else None


@login_required
@require_http_methods(['GET', 'POST'])
def new(request):
    """
    Alta de dominio: la pantalla y su envío, en la misma dirección.

    Es una pantalla propia y no un panel que se despliega sobre el listado. La
    forma de la propiedad —de dominio o de prefijo de URL— es la decisión que
    puede arruinar todo lo que sigue, y necesita las dos opciones a la vista,
    explicadas y comparables. Encimada al listado no entraba nunca.

    **Queda fuera del recorrido, pero con dirección.** El alta vive adentro de la
    conexión desde que la interfaz trabaja contra un solo sitio; esta pantalla
    sigue respondiendo para el día que la cuenta necesite más de uno. Mientras
    tanto pasa por la misma guarda que la conexión: sin ella sería la puerta
    lateral por la que entra el segundo sitio, y ahora que el listado no tiene
    ruta sería además la única forma de crearlo sin que ninguna pantalla lo
    muestre.
    """
    if request.method == 'POST':
        return _process_new(request)

    try:
        # La credencial primero: es el prerrequisito, y su motivo es el más útil
        # de los dos cuando faltan las dos cosas.
        credential = resolve_shared_credential(Module.SEARCH_CONSOLE)
        ensure_company_can_connect_another()
    except CredentialNotReady as exc:
        # La vista no se ofrece sin credencial: sin ella el alta no puede
        # terminar, y ofrecer un formulario que va a fallar es peor que no
        # ofrecerlo. Se va a resolverlo, con el motivo a la vista.
        stash_errors(request, {'credential': as_error(exc)})
        return redirect('settings')
    except ValidationFailed as exc:
        # Ya hay un sitio conectado: el formulario no se dibuja, y el motivo
        # aterriza en la conexión, que es donde se ve cuál es y desde donde se lo
        # da de baja si lo que se quería era cambiarlo (RT-18).
        stash_errors(request, {exc.details.get('field', 'hostname'): as_error(exc)})
        return redirect('settings')

    return render(
        request,
        'Domains/Create',
        props={
            'property_types': [
                {'value': value, 'label': label} for value, label in PropertyType.choices
            ],
            # La dirección de la cuenta de servicio viaja antes del alta y no
            # después: el paso siguiente es autorizarla en Search Console, y
            # tenerla acá ahorra un viaje de ida y vuelta.
            'client_email': credential.client_email,
        },
    )


def _process_new(request):
    try:
        # La misma guarda que la conexión, y por el mismo motivo: la regla de «un
        # solo sitio» es de la interfaz, y esta pantalla es interfaz. En el
        # servicio cerraría también la API v1, que sí puede dar de alta varios.
        ensure_company_can_connect_another()
        create_company_domain(
            request.user,
            hostname=field(request, 'hostname'),
            property_type=field(request, 'property_type'),
        )
    except CredentialNotReady as exc:
        stash_errors(request, {'credential': as_error(exc)})
        return redirect('settings')
    except ValidationFailed as exc:
        stash_errors(request, _creation_errors(exc))
        return redirect('domain.new')

    # Vuelve a la conexión, que es donde el sitio se muestra ahora. El listado
    # —y con él el aviso de «recién dado de alta» que vivía en su cabecera— ya no
    # tiene dirección: la conexión enseña el sitio con su estado, que es lo mismo
    # que ese aviso venía a decir.
    return redirect('settings')


@login_required
@require_http_methods(['POST'])
def connect(request):
    """
    Conecta el sitio desde la pantalla de conexión.

    Es el mismo alta que `new` y contra el mismo servicio; lo único que cambia es
    **adónde vuelve**. El resultado de un envío tiene que aterrizar en la
    pantalla desde la que se hizo (RT-11), y `domain.new` está fuera del menú:
    mandar ahí un error de validación dejaría a la persona en una pantalla que ya
    no forma parte del recorrido, con el formulario vacío.

    La guarda de «un solo sitio» se llama acá y no dentro de `create_domain()`
    porque es una regla de la interfaz: el backend sigue admitiendo varios y la
    API v1 también.
    """
    try:
        ensure_company_can_connect_another()
        domain = create_company_domain(
            request.user,
            hostname=field(request, 'hostname'),
            property_type=field(request, 'property_type'),
        )
    except CredentialNotReady as exc:
        stash_errors(request, {'credential': as_error(exc)})
        return redirect('settings')
    except ValidationFailed as exc:
        stash_errors(request, _creation_errors(exc))
        return redirect('settings')

    # Se comprueba en el acto, como la credencial: dejar el sitio conectado y sin
    # comprobar obligaría a un segundo clic para averiguar lo único que importa,
    # que es si Google nos deja leerlo. Un fallo acá no invalida el alta —el
    # estado queda en «esperando autorización», que es su forma legítima—, así
    # que no se arrastra ningún error.
    try:
        check_access(domain)
    except CredentialNotReady:
        pass

    return redirect('settings')


def _creation_errors(exc: ValidationFailed) -> dict:
    """
    Los errores del alta, iguales los mande la conexión o el alta suelta.

    El duplicado lleva el identificador del que ya existe: sin él, «ya está dado
    de alta» obliga a ir a buscarlo a mano. Va como error propio y no dentro de
    los datos del otro porque la pantalla lo usa para armar un enlace, no una
    oración.
    """
    errors = {exc.details.get('field', 'hostname'): as_error(exc)}
    if exc.details.get('duplicate_id'):
        errors['duplicate_id'] = {
            'code': 'DUPLICATE_ID',
            'params': {'id': str(exc.details['duplicate_id'])},
        }
    return errors


@login_required
@require_http_methods(['GET', 'PATCH'])
def show(request, domain_id):
    """
    Ficha del dominio: si está operativo, qué falta si no, y cuánto cupo queda hoy.

    La misma dirección atiende la pantalla y su edición, como el alta. El `PATCH`
    es el mismo verbo que la API pública (FR-057) y contra el mismo servicio:
    dos caminos con validaciones distintas terminarían aceptando en una
    superficie lo que la otra rechaza.
    """
    domain = shared_domain_or_404(request, domain_id)

    if request.method == 'PATCH':
        return _save_settings(request, domain)

    coverage = coverage_summary(domain)
    cycle = cycle_estimate(domain)

    return render(
        request,
        'Domains/Show',
        props={
            'domain': _domain_props(domain),
            # La dirección de la cuenta de servicio es el dato central del
            # bloque de acceso: es lo que hay que pegar en Search Console. Puede
            # faltar —una cuenta sin credencial cargada—, y en ese caso la ficha
            # dice qué falta en vez de mostrar un recuadro vacío para copiar.
            'client_email': _client_email(),
            'quota': remaining(domain),
            'cycle': cycle,
            'coverage': {
                'total': coverage['total'],
                'with_data': coverage['with_data'],
                'last_checked_at': (
                    coverage['last_checked_at'].isoformat() if coverage['last_checked_at'] else None
                ),
            },
            'sitemaps_count': domain.sitemaps.count(),
            # Cuántas URLs consultaría una inspección a mano. Va al servidor
            # porque la confirmación tiene que decir el número real y no «las que
            # haya»: sin la cifra, aceptar es firmar en blanco.
            'pending_urls': pending_urls(domain).count(),
            'google_daily_limit': django_settings.DEFAULT_DAILY_INSPECTION_BUDGET,
            'last_batch': _batch(domain, terminal=True),
            'running_batch': _batch(domain, terminal=False),
        },
    )


def _save_settings(request, domain: Domain):
    """Aplica el `PATCH` de la ficha y vuelve a ella, con el error junto al campo."""
    try:
        update_settings(domain, payload(request))
    except ValidationFailed as exc:
        stash_errors(request, {exc.details.get('field', 'settings'): as_error(exc)})

    return redirect('domain.show', domain_id=domain.id)


@login_required
@require_http_methods(['POST'])
def check(request, domain_id):
    """Vuelve a preguntarle a Google si seguimos teniendo acceso a la propiedad."""
    domain = shared_domain_or_404(request, domain_id)

    try:
        check_access(domain)
    except CredentialNotReady as exc:
        stash_errors(request, {'check': as_error(exc)})

    # Vuelve a donde se disparó. El resultado de una comprobación tiene que
    # quedar en el bloque del objeto comprobado (RT-11), y ese bloque está en la
    # pantalla desde la que se apretó. Quien la dispara lo declara con un campo
    # propio en vez de que el servidor lea el `Referer`: esa cabecera la puede
    # recortar una política de referencia del navegador, y entonces comprobar
    # desde la ficha te dejaría en el listado sin ninguna razón visible.
    origin = field(request, 'from')
    if origin == 'show':
        return redirect('domain.show', domain_id=domain.id)
    # La conexión es hoy el origen normal **y el destino por omisión**: es la
    # pantalla que muestra el sitio y su estado de acceso mientras la interfaz
    # admita uno solo. Antes acá caía el listado, que ya no tiene dirección.
    return redirect('settings')


@login_required
@require_http_methods(['POST'])
def deactivate(request, domain_id):
    """
    Da de baja el sitio y vuelve a la conexión.

    Vuelve a la conexión y no a la ficha porque la ficha que se acaba de dar de
    baja deja de existir para la interfaz: redirigir ahí sería mandar a un 404
    inmediatamente después de una acción que salió bien. La conexión es además
    de donde se disparó y donde queda el hueco a llenar —ahí está el formulario
    para conectar otro— (RT-11).
    """
    domain = shared_domain_or_404(request, domain_id)
    deactivate_domain(domain)
    return redirect('settings')


def shared_domain_or_404(request, domain_id) -> Domain:
    """
    El dominio activo de la compañía, o un 404.

    Es público y vive acá porque lo usan las cuatro pantallas que cuelgan de un
    dominio. La consulta está en `services`; esto sólo elige cómo contesta la
    interfaz. Hoy cualquier perfil autenticado comparte el inventario de la
    empresa única. El selector `active_owned_domain()` queda reservado como
    frontera de acceso para una futura edición multitenant.

    Un sitio dado de baja también contesta 404. El filtro lo aplica
    `shared_domain()` para que las pantallas no repitan el criterio.
    """
    domain = shared_domain(domain_id)
    if domain is None:
        raise Http404('Ese dominio no existe en esta compañía.')
    return domain


def _client_email() -> str | None:
    credential = shared_active_credential(Module.SEARCH_CONSOLE)
    return credential.client_email if credential else None


def _batch(domain: Domain, *, terminal: bool) -> dict | None:
    """
    El lote de inspección más reciente, terminado o en curso según se pida.

    El que está en curso es lo que hace que la ficha se siga actualizando
    (RT-13); el último terminado es el número que justifica el acceso al
    historial de lotes. Son dos preguntas distintas y por eso viajan aparte:
    colapsarlas obligaría a la pantalla a adivinar cuál de las dos le tocó.
    """
    batches = Batch.objects.filter(domain=domain)
    batches = (
        batches.filter(state__in=TERMINAL_BATCH_STATES)
        if terminal
        else batches.exclude(state__in=TERMINAL_BATCH_STATES)
    )

    batch = batches.first()
    if batch is None:
        return None

    return {
        'id': str(batch.id),
        'kind': batch.kind,
        'state': batch.state,
        'total_items': batch.total_items,
        'processed_items': batch.processed_items,
        'finished_at': batch.finished_at.isoformat() if batch.finished_at else None,
    }


def _row(domain: Domain) -> dict:
    """
    Un renglón del listado: el dominio más su resumen de cobertura (FR-062).

    La cobertura se suma acá y no en `_domain_props` porque la ficha no la
    necesita en esa forma —tiene el resumen completo, con el reparto por
    estado— y agregarla allá obligaría a anotar el queryset en las dos vistas.
    """
    return {**_domain_props(domain), 'coverage': coverage_row(domain)}


def _domain_props(domain: Domain) -> dict:
    """
    Lo que la lista y la ficha muestran de cada dominio.

    El estado viaja con su etiqueta ya resuelta y con la fecha de la última
    comprobación: un estado sin fecha no dice si es de hace un minuto o de hace
    una semana, y eso cambia por completo lo que significa.
    """
    return {
        'id': str(domain.id),
        'hostname': domain.hostname,
        'property_type': domain.property_type,
        'property_uri': domain.property_uri,
        'access_state': domain.access_state,
        'access_checked_at': (
            domain.access_checked_at.isoformat() if domain.access_checked_at else None
        ),
        'access_error': domain.access_error or None,
        # El código, además del mensaje: la ficha ofrece una acción distinta
        # según cuál de los tres motivos haya sido, y deducirlo del texto la
        # rompería al primer cambio de redacción (RT-08).
        'access_error_code': domain.access_error_code or None,
        'is_operational': domain.is_operational,
        'daily_inspection_budget': domain.daily_inspection_budget,
        'manual_reserve': domain.manual_reserve,
        'notifications_enabled': domain.notifications_enabled,
    }
