"""
Lo que se lee de un vistazo al abrir la plataforma (V0).

Las dos preguntas que trae a alguien a la raíz son «¿hay algo roto?» y «¿cuánto
sabemos hoy de mis sitios?», en ese orden. Todo lo que sale de acá está armado
para contestarlas sin tener que entrar a ningún dominio.

Las tres reglas que gobiernan este módulo:

**Ninguna cifra viaja sin su denominador.** «1.240 URLs con dato» no significa
nada; «1.240 de 5.000» sí. Un porcentaje de indexación calculado sobre lo que se
alcanzó a consultar es la mentira más barata de cometer, y en un tablero —donde
las cifras se leen sueltas y grandes— es también la más fácil.

**Las series del gráfico son conteos, no estimaciones.** Las consultas salen de
lo que los lotes gastaron y los cambios de estado, del historial. Ninguna se
proyecta ni se suaviza: una línea inventada en un tablero se vuelve una creencia
sobre el sitio en dos visitas.

**Lo que está roto se cuenta aparte de lo que funciona.** Un dominio sin acceso
no aporta datos nuevos desde que lo perdió, así que sumarlo en silencio al resto
presenta una foto vieja como si fuera de hoy.
"""

from collections import defaultdict
from datetime import datetime, time, timedelta

from django.db.models import Count, Max
from django.db.models.functions import Coalesce
from django.utils import timezone

from apps.core.dates import display_timezone, today
from apps.coverage.models import CoverageRecord
from apps.credentials.models import Module
from apps.credentials.services import shared_active_credential
from apps.domains.models import AccessState, Domain
from apps.domains.services import primary_shared_domain, shared_domains
from apps.jobs.models import TERMINAL_BATCH_STATES, Batch, BatchKind, BatchState, QuotaBudget
from apps.sitemaps.models import CoverageState, Sitemap, Url

#: Estados de acceso que piden algo de la persona.
#:
#: Revocado y suspendido quedan afuera a propósito: son decisiones tomadas desde
#: la administración, y ofrecerlas como pendientes mandaría a resolver algo que
#: desde acá no se resuelve. Aparecen en el conteo de dominios, no en la lista de
#: lo que hay que atender.
NEEDS_ACTION = (AccessState.AWAITING_ACCESS, AccessState.ACCESS_LOST)

#: Rangos que ofrece el gráfico, en días.
RANGES = (7, 30, 90)

#: Cuántos lotes recientes se muestran.
#:
#: Son un vistazo a lo último que corrió, no el historial: para eso está la lista
#: por dominio, que pagina.
RECENT_BATCHES = 5


def dashboard(account, *, days: int = 30, open_panel: str = '') -> dict:
    """
    Todo lo que la pantalla de inicio necesita, en una sola lectura.

    `open_panel` es el panel que la dirección declara abierto. Lo único que
    cambia es cuánto detalle trae el reparto de cobertura: las direcciones de
    cada estado sólo se leen cuando ese panel está a la vista. Mandarlas siempre
    sería recorrer el inventario en cada carga de la pantalla más visitada del
    producto para llenar una lista que casi nunca se despliega.
    """
    # **El sitio de la compañía, y nada más.** No alcanza con excluir los dados de
    # baja: la interfaz trabaja contra uno solo, y una cuenta anterior a ese
    # cambio puede tener dos activos. Contándolos a todos, el tablero diría «2
    # sitios monitoreados» mientras las demás pantallas hablan de uno, y ninguna
    # de las dos cifras se podría cuadrar con la otra.
    #
    # Es una lista y no un objeto porque todo lo que sigue reparte por dominio;
    # el día que la interfaz admita varios, vuelve a ser `shared_domains()`.
    site = primary_shared_domain()
    domains = [site] if site else []

    return {
        'domains': _domains(domains),
        'attention': _needs_action(account, domains),
        'coverage': _coverage(account, domains, with_urls=open_panel == 'coverage'),
        'quota': _todays_quota(domains),
        'sitemaps': _sitemaps(domains),
        'activity': _activity(account, days),
        'last_batches': _last_batches(account),
        'last_cycle': _last_cycle(account),
    }


def _domains(domains: list[Domain]) -> dict:
    """Cuántos hay y cuántos están operativos, que son dos cifras distintas."""
    return {
        'total': len(domains),
        'operational': sum(1 for domain in domains if domain.is_operational),
        # El id sólo cuando hay uno solo, y nulo si no.
        #
        # Con un único dominio, mandar al listado a elegir entre uno es un clic
        # que no decide nada. Con dos o más el id no existe a propósito: el
        # checklist prohíbe adivinar cuál dominio le importa a la persona, así
        # que la pantalla ofrece el listado y ahí se elige.
        'only_id': str(domains[0].id) if len(domains) == 1 else None,
        'checked_at': _most_recent(
            domain.access_checked_at for domain in domains if domain.access_checked_at
        ),
    }


def _needs_action(account, domains: list[Domain]) -> dict:
    """
    Lo que hay que resolver, con el motivo de cada cosa y dónde se resuelve.

    El estado de la cuenta va primero y aparte: si la credencial dejó de servir,
    ningún dominio se puede comprobar, y listar seis dominios «esperando acceso»
    mandaría a seis pantallas donde el botón no va a funcionar (RT-18).
    """
    credential = shared_active_credential(Module.SEARCH_CONSOLE)
    credential_is_usable = credential is not None and credential.is_usable

    return {
        # `None` y no un objeto vacío: la pantalla decide con esto si dibuja el
        # bloque de la cuenta, y un objeto siempre presente obligaría a mirar
        # adentro para saber si hay algo que decir.
        'credential': None
        if credential_is_usable
        else {
            'has_credential': credential is not None,
            'status': credential.status if credential else None,
            'error_code': credential.last_error_code if credential else None,
        },
        'domains': [
            {
                'id': str(domain.id),
                'hostname': domain.hostname,
                'access_state': domain.access_state,
                'access_checked_at': _as_iso(domain.access_checked_at),
            }
            for domain in domains
            if domain.access_state in NEEDS_ACTION
        ],
        'failed_batches': _failed_batches(account),
    }


def _failed_batches(account) -> list[dict]:
    """
    Los lotes que fallaron desde ayer.

    Con ventana y no todos: un lote que falló hace tres semanas por una
    credencial que ya se arregló no es trabajo pendiente, es historia, y
    mostrarlo como pendiente entrena a ignorar el bloque entero.

    Los parciales quedan afuera. Un lote cortado por cupo no falló: lo que quedó
    entra en el ciclo siguiente sin que nadie haga nada (R-C).
    """
    since = timezone.now() - timedelta(days=1)

    return [
        {
            'id': str(batch.id),
            'hostname': batch.domain.hostname,
            # Para poder llevar a los lotes de ese dominio y no sólo a la ficha
            # del que falló: si falló uno, lo siguiente que se quiere ver es si
            # los anteriores venían fallando también.
            'domain_id': str(batch.domain_id),
            'kind': batch.kind,
            'finished_at': _as_iso(batch.finished_at),
        }
        for batch in Batch.objects.filter(
            domain__deactivated_at__isnull=True,
            state=BatchState.FAILED,
            finished_at__gte=since,
        ).select_related('domain')[:RECENT_BATCHES]
    ]


#: Cuántas direcciones acompañan a cada cifra del reparto por estado.
#:
#: El reparto dice «3 descubiertas y sin rastrear» y hasta ahora no había forma
#: de saber cuáles: la única salida era ir a la tabla de cobertura y filtrar a
#: mano. Veinticinco alcanzan para reconocerlas —los estados que importan son los
#: minoritarios— y el total va al lado, así que el recorte se puede decir.
MAX_URLS_PER_STATE = 25


def _urls_by_state(urls) -> dict[str, list[str]]:
    """
    Las direcciones de cada estado, recortadas.

    Se piden **sólo** las de los estados que no son «indexada»: son las que
    alguien mira, y traer doscientas treinta y ocho direcciones correctas para
    una lista que nadie despliega es el grueso del costo de esta consulta.
    """
    addresses: dict[str, list[str]] = defaultdict(list)

    rows = (
        urls.exclude(coverage_state__in=(CoverageState.INDEXED, CoverageState.UNKNOWN))
        .order_by('loc')
        .values_list('coverage_state', 'loc')
    )
    for state, loc in rows:
        if len(addresses[state]) < MAX_URLS_PER_STATE:
            addresses[state].append(loc)

    return addresses


def _coverage(account, domains: list[Domain], *, with_urls: bool = False) -> dict:
    """
    Cuántas URLs tienen dato sobre el total, en toda la cuenta.

    `UNKNOWN` se cuenta aparte y queda fuera de cualquier porcentaje: significa
    «todavía no le preguntamos a Google», y meterlo adentro lo convertiría en
    «no está indexada», que es una afirmación sobre Google que no podemos hacer
    (R-B).

    Sólo entran las URLs que hoy declara un sitemap, igual que el resumen de cada
    dominio. Las que salieron del sitemap conservan su historial y se ven en la
    cobertura de su dominio; sumarlas acá inflaría el total de la cuenta con
    páginas que ya nadie monitorea.

    El reparto sale agrupado **por dominio y por estado a la vez**, y de ahí se
    suman los totales de la cuenta. Es una sola consulta para las dos cosas: el
    panel de cobertura necesita el reparto de la cuenta y el enlace a cada
    dominio con su cifra, y pedirlos por separado sería recorrer las mismas
    filas dos veces para contarlas igual.
    """
    urls = Url.objects.filter(domain__deactivated_at__isnull=True, in_sitemap=True)

    by_domain: dict[str, dict[str, int]] = {}
    checked_by_domain: dict[str, datetime | None] = {}
    for row in urls.values('domain_id', 'coverage_state').annotate(
        total=Count('id'), checked_at=Max('last_checked_at')
    ):
        domain_id = str(row['domain_id'])
        by_domain.setdefault(domain_id, {})[row['coverage_state']] = row['total']
        checked_by_domain[domain_id] = _later(checked_by_domain.get(domain_id), row['checked_at'])

    counts: dict[str, int] = {}
    for states in by_domain.values():
        for state, total in states.items():
            counts[state] = counts.get(state, 0) + total

    without_data = counts.pop(CoverageState.UNKNOWN, 0)
    with_data = sum(counts.values())

    # Los dominios que dejaron de ser operativos siguen aportando sus URLs al
    # total, con lo último que se supo. Cuántos son viaja al lado para que la
    # pantalla pueda decir que esa parte del dato está congelada (R-F).
    frozen = sum(1 for domain in domains if not domain.is_operational)

    return {
        'total': with_data + without_data,
        'with_data': with_data,
        'without_data': without_data,
        'indexed': counts.get(CoverageState.INDEXED, 0),
        # El reparto por estado de la cuenta, **sin** `UNKNOWN`: eso es lo que
        # Google contestó, y de las sin consultar no contestó nada (R-B). Las
        # sin consultar viajan aparte, en `without_data`.
        'by_state': counts,
        # Cuáles son, y no sólo cuántas. Vacío mientras el panel esté cerrado:
        # la pantalla dibuja el desplegable con lo que haya, y sin panel abierto
        # no hay nada que desplegar.
        'urls_by_state': _urls_by_state(urls) if with_urls else {},
        'domains': _coverage_by_domain(domains, by_domain, checked_by_domain),
        'domains_with_urls': len(by_domain),
        'frozen_domains': frozen,
        'last_checked_at': _most_recent(
            value for value in checked_by_domain.values() if value is not None
        ),
    }


def _coverage_by_domain(
    domains: list[Domain],
    by_domain: dict[str, dict[str, int]],
    checked_by_domain: dict[str, datetime | None],
) -> list[dict]:
    """
    La misma cuenta, abierta por dominio y sólo para los que tienen URLs.

    Un dominio sin ninguna URL descubierta no tiene cobertura que repartir: su
    fila diría «0 de 0» y mandaría a una pantalla vacía. Lo que le falta es un
    sitemap, y eso lo contesta el aspecto de sitemaps.

    Cada fila lleva su propia fecha de obtención (R-A). Un reparto por estado sin
    decir de cuándo es podría ser de esta mañana o de hace tres meses, y con
    varios dominios la fecha de la cuenta es la del que se consultó último, que
    no describe a ninguno de los otros.
    """
    rows = []
    for domain in domains:
        states = by_domain.get(str(domain.id))
        if not states:
            continue

        without_data = states.get(CoverageState.UNKNOWN, 0)
        with_data = sum(total for state, total in states.items() if state != CoverageState.UNKNOWN)
        rows.append(
            {
                'id': str(domain.id),
                'hostname': domain.hostname,
                'total': with_data + without_data,
                'with_data': with_data,
                'without_data': without_data,
                'indexed': states.get(CoverageState.INDEXED, 0),
                'last_checked_at': _as_iso(checked_by_domain.get(str(domain.id))),
            }
        )

    return rows


def _todays_quota(domains: list[Domain]) -> dict:
    """
    El cupo del día sumado sobre las propiedades de la cuenta.

    El límite se toma de los presupuestos que existen, no del que tiene
    configurado cada dominio: un dominio sin fila todavía no gastó nada hoy, y
    contar su límite haría aparecer un saldo que nadie reservó.

    La fecha viaja siempre. «Quedan 1.588» sin decir de qué día es se lee como un
    saldo permanente, y el cupo se renueva cada mañana.

    Las filas viajan abiertas por dominio y la suma se hace acá y no en la base.
    El cupo es de **cada propiedad** en Search Console y no un saldo común: el
    total sirve para la tarjeta, pero lo que dice qué se puede consultar hoy es
    la fila de cada dominio, con sus dos bolsillos. Es la misma consulta: lo que
    cambia es que no se descarta el detalle al agregarlo.
    """
    day = today()
    hostnames = {str(domain.id): domain.hostname for domain in domains}

    rows = [
        {
            'id': str(budget.domain_id),
            'hostname': hostnames.get(str(budget.domain_id), ''),
            'limit_total': budget.limit_total,
            'manual_reserve': budget.manual_reserve,
            'used_automatic': budget.used_automatic,
            'used_manual': budget.used_manual,
        }
        for budget in QuotaBudget.objects.filter(domain__in=domains, date=day)
    ]
    rows.sort(key=lambda row: row['hostname'])

    return {
        'date': day.isoformat(),
        'limit_total': sum(row['limit_total'] for row in rows),
        # La reserva manual viaja aunque la tarjeta no dibuje los dos bolsillos:
        # sin ella, «quedan 1.438» se lee como disponible para cualquier cosa, y
        # una parte de eso está apartada para lo que se pida a mano.
        'manual_reserve': sum(row['manual_reserve'] for row in rows),
        'used': sum(row['used_automatic'] + row['used_manual'] for row in rows),
        'used_automatic': sum(row['used_automatic'] for row in rows),
        'used_manual': sum(row['used_manual'] for row in rows),
        'domains': rows,
    }


def _sitemaps(domains: list[Domain]) -> dict:
    """
    Cuántos sitemaps hay registrados y cuándo se leyó cada uno.

    Los dominios sin ningún sitemap **también viajan**, con su cero. Es el único
    aspecto donde el cero es la respuesta: una cuenta sin URLs descubiertas casi
    siempre es una cuenta a la que le falta registrar un sitemap, y esconder al
    dominio que no tiene ninguno dejaría el aspecto contestando de qué no
    hablar.

    La fecha es la de la última lectura nuestra del archivo, no la del último
    envío a Google: son dos hechos distintos y el que explica de cuándo son las
    URLs es el primero.
    """
    read_by_domain = {
        str(row['domain_id']): (row['total'], row['last_read_at'])
        for row in Sitemap.objects.filter(domain__in=domains)
        .values('domain_id')
        .annotate(total=Count('id'), last_read_at=Max('last_read_at'))
    }

    rows = [
        {
            'id': str(domain.id),
            'hostname': domain.hostname,
            'total': read_by_domain.get(str(domain.id), (0, None))[0],
            'last_read_at': _as_iso(read_by_domain.get(str(domain.id), (0, None))[1]),
        }
        for domain in domains
    ]

    return {
        'total': sum(row['total'] for row in rows),
        'domains_with_sitemaps': sum(1 for row in rows if row['total'] > 0),
        'domains': rows,
        'last_read_at': _most_recent(
            value for _total, value in read_by_domain.values() if value is not None
        ),
    }


def _activity(account, days: int) -> dict:
    """
    Qué se hizo cada día: consultas a Google y cambios de estado registrados.

    Las dos series miden cosas distintas y por eso van juntas. Las consultas son
    esfuerzo —cuántas veces le preguntamos a Google por una URL— y los cambios
    son hallazgo —cuántas contestaron algo distinto de lo que ya sabíamos—. Una
    sola de las dos se malinterpreta: las consultas solas parecen resultado, y
    los cambios solos no dicen sobre cuánto trabajo salieron.

    Ninguna se estima. Las consultas salen del cupo que cada lote gastó y los
    cambios, del historial, que sólo escribe cuando el estado cambia o es la
    primera lectura de una URL.

    Los días sin actividad viajan en cero y no se saltean: un gráfico que une el
    lunes con el jueves dibuja una pendiente donde hubo dos días parados.

    El corte es el **comienzo** del primer día en la zona de presentación, no
    «hace tantos días a esta hora». Con la hora adentro, la primera barra sólo
    contaba lo ocurrido después de ese momento y quedaba sistemáticamente más
    baja que las demás: un día parcial dibujado como si fuera entero, justo en
    el extremo desde el que se lee la tendencia.
    """
    days = days if days in RANGES else RANGES[1]
    zone = display_timezone()
    first_day = (timezone.now().astimezone(zone) - timedelta(days=days - 1)).date()
    since = datetime.combine(first_day, time.min, tzinfo=zone)

    queries: dict[str, int] = {}
    for row in Batch.objects.filter(
        domain__deactivated_at__isnull=True,
        kind=BatchKind.URL_INSPECTION,
        started_at__gte=since,
    ).values_list('started_at', 'quota_consumed'):
        started_at, spent = row
        key = started_at.astimezone(zone).date().isoformat()
        queries[key] = queries.get(key, 0) + (spent or 0)

    changes: dict[str, int] = {}
    for fetched_at in CoverageRecord.objects.filter(
        url__domain__deactivated_at__isnull=True, fetched_at__gte=since
    ).values_list('fetched_at', flat=True):
        key = fetched_at.astimezone(zone).date().isoformat()
        changes[key] = changes.get(key, 0) + 1

    series = []
    for offset in range(days):
        day = (first_day + timedelta(days=offset)).isoformat()
        series.append(
            {
                'date': day,
                'queries': queries.get(day, 0),
                'changes': changes.get(day, 0),
            }
        )

    return {
        'days': days,
        'ranges': list(RANGES),
        'series': series,
        'total_queries': sum(point['queries'] for point in series),
        'total_changes': sum(point['changes'] for point in series),
        # Cuántos lotes arrancaron hoy, que es la cifra del aspecto y no del
        # gráfico: el gráfico cuenta consultas y cambios, que son unidades de
        # trabajo, y «5 lotes hoy» contesta si la cuenta se movió hoy. El corte
        # es el comienzo del día en la zona de presentación, igual que la serie.
        'batches_today': Batch.objects.filter(
            domain__deactivated_at__isnull=True,
            created_at__gte=datetime.combine(
                timezone.now().astimezone(zone).date(), time.min, tzinfo=zone
            ),
        ).count(),
    }


def _last_batches(account) -> list[dict]:
    """Lo último que corrió en toda la cuenta, con su dominio."""
    return [
        {
            'id': str(batch.id),
            'hostname': batch.domain.hostname,
            'domain_id': str(batch.domain_id),
            'kind': batch.kind,
            'origin': batch.origin,
            'state': batch.state,
            'total_items': batch.total_items,
            'processed_items': batch.processed_items,
            'failed_items': batch.failed_items,
            'created_at': _as_iso(batch.created_at),
            'started_at': _as_iso(batch.started_at),
            'finished_at': _as_iso(batch.finished_at),
            'is_terminal': batch.is_terminal,
        }
        for batch in Batch.objects.filter(
            domain__deactivated_at__isnull=True
        ).select_related('domain')[:RECENT_BATCHES]
    ]


def _last_cycle(account) -> dict | None:
    """
    Cuándo terminó el último recorrido de inspección y qué alcanzó a hacer.

    Se exige `processed_items` mayor que cero: un lote que no llegó a consultar
    nada no produjo ningún dato, y presentarlo como el último ciclo haría buscar
    la explicación de lo que hay en pantalla en el lugar equivocado.
    """
    batch = (
        Batch.objects.filter(
            domain__deactivated_at__isnull=True,
            kind=BatchKind.URL_INSPECTION,
            state__in=list(TERMINAL_BATCH_STATES),
            processed_items__gt=0,
        )
        .select_related('domain')
        .order_by('-finished_at')
        .first()
    )

    if batch is None:
        return None

    return {
        'id': str(batch.id),
        'hostname': batch.domain.hostname,
        'domain_id': str(batch.domain_id),
        'state': batch.state,
        'processed_items': batch.processed_items,
        'total_items': batch.total_items,
        'finished_at': _as_iso(batch.finished_at),
    }


def active_work(account) -> list[dict]:
    """
    El trabajo que todavía no terminó: qué es, en qué estado y desde cuándo.

    Con esto la pantalla decide si tiene que seguir consultando (RT-13) y qué
    decir mientras tanto. Se mira la cuenta entera y no los lotes que se
    muestran: el que está corriendo puede no haber entrado entre los cinco más
    recientes, y el tablero quedaría quieto justo mientras hay trabajo.

    **Va la fecha y no sólo el estado.** «Hay trabajo en curso» sin decir desde
    cuándo es una afirmación que no se puede contradecir: un lote encolado hace
    dos días que ya no avanza se lee igual que uno que arrancó recién. Con la
    fecha al lado, quedarse trabado se ve.

    `started_at` es nulo mientras el lote está en cola, así que se cae a
    `created_at`, que es cuando se encoló — que es exactamente lo que hay que
    mostrar mientras no haya arrancado.
    """
    return [
        {'state': state, 'kind': kind, 'started_at': _as_iso(started)}
        for state, kind, started in (
            Batch.objects.filter(domain__deactivated_at__isnull=True)
            .exclude(state__in=list(TERMINAL_BATCH_STATES))
            .annotate(started=Coalesce('started_at', 'created_at'))
            .values_list('state', 'kind', 'started')
        )
    ]


def has_domains(account) -> bool:
    return shared_domains().exists()


def _most_recent(dates) -> str | None:
    dates = list(dates)
    return _as_iso(max(dates)) if dates else None


def _later(current: datetime | None, candidate: datetime | None) -> datetime | None:
    """La más nueva de dos fechas, tolerando que falte cualquiera de las dos."""
    if candidate is None:
        return current
    if current is None:
        return candidate
    return max(current, candidate)


def _as_iso(value) -> str | None:
    return value.isoformat() if value else None
