"""
Rendimiento del sitio en la búsqueda de Google, guardado día por día.

Dos tablas de datos y no una, y ésa es la decisión que más pesa de este módulo:
`KeywordDaily` guarda el total de una consulta y `KeywordPageDaily` guarda el
reparto de esa consulta entre las páginas del sitio. **Las impresiones de la
segunda no se suman para obtener la primera.**

Cuando varias páginas del sitio aparecen en la misma búsqueda, cada una registra
su impresión pero la búsqueda fue una sola: medido contra el sitio real, sumar
por página inflaba el 23% de las claves y en el peor caso multiplicaba por seis.
Google devuelve el total correcto sólo si se le pregunta sin la dimensión de
página, así que se le pregunta dos veces.
"""

from django.db import models

from apps.core.models import BaseModel


class ModuleStatus(models.TextChoices):
    """
    Estado de la sincronización, que no es el estado de una llamada.

    Un fallo contra Google se clasifica con `GoogleErrorCode`, que ya existe y
    tiene ocho valores; esto describe otra cosa: en qué punto está el módulo
    para este sitio. Los dos conviven —`ACTION_REQUIRED` guarda además cuál fue
    el código— y confundirlos llevaría a escribir el mismo mapa dos veces.
    """

    READY = 'READY', 'Ready'
    SYNCING = 'SYNCING', 'Syncing'
    #: Hace falta que alguien corrija la conexión, el acceso o los permisos. Es
    #: adonde van los `PERMANENT_CODES`: reintentar no los mejora.
    ACTION_REQUIRED = 'ACTION_REQUIRED', 'Action required'
    #: La integración funciona y Google no devuelve rendimiento para el período.
    #: **No es un error**, y distinguirlo importa: un sitio nuevo, o uno que no
    #: aparece por ninguna búsqueda, está exactamente en este estado.
    NO_DATA = 'NO_DATA', 'No data'
    ERROR = 'ERROR', 'Error'


class RunKind(models.TextChoices):
    BACKFILL = 'BACKFILL', 'Historical backfill'
    DAILY_SYNC = 'DAILY_SYNC', 'Daily sync'


class RunOrigin(models.TextChoices):
    """Quién lo pidió. El backfill lo aprieta alguien; el diario lo dispara el reloj."""

    MANUAL = 'MANUAL', 'Manual'
    SCHEDULED = 'SCHEDULED', 'Scheduled'


class RunState(models.TextChoices):
    """
    Los estados son de este módulo, pero **las palabras son las del producto**.

    `COMPLETED` y no `DONE` aunque nada obligue: la otra herramienta ya llama así
    a un trabajo que terminó bien, y usar un sinónimo para lo mismo en la
    herramienta de al lado obliga a traducir mentalmente entre dos pantallas.
    Lo que la separación evita es el acoplamiento de código —no se importa nada
    de `jobs`—, no que las dos hablen el mismo idioma.
    """

    QUEUED = 'QUEUED', 'Queued'
    RUNNING = 'RUNNING', 'Running'
    COMPLETED = 'COMPLETED', 'Completed'
    FAILED = 'FAILED', 'Failed'


#: Estados de los que no se sale. Sirve para saber si hay trabajo en curso sin
#: enumerar los otros dos en cada consulta.
TERMINAL_RUN_STATES = frozenset({RunState.COMPLETED, RunState.FAILED})


class PositionBand(models.TextChoices):
    """
    En qué tramo del listado cae una consulta.

    No es un campo de ninguna tabla: se calcula en `services.classify_band()` a
    partir de la posición promedio del período, y viaja en cada fila para que la
    tabla pueda filtrar por él.

    Existe porque el filtro y el indicador del inicio **tienen que cortar en el
    mismo lugar**. Antes el indicador cortaba en `services.py` y la tabla habría
    tenido que volver a cortar en el `.tsx`; dos veces la misma regla escrita en
    dos idiomas es la forma más rápida de que la tarjeta diga 43 y la tabla
    filtrada muestre 51, sin que nada falle.

    Los cuatro tramos son contiguos y cubren todo: una consulta cae en uno y sólo
    uno, así que elegir los cuatro es no filtrar.
    """

    #: Los tres primeros resultados, que se llevan la mayoría de los clics.
    TOP_3 = 'TOP_3', 'Top 3'
    #: El resto de la primera página.
    FIRST_PAGE = 'FIRST_PAGE', 'First page'
    #: La segunda página: adonde casi nadie llega y desde donde menos cuesta
    #: subir. Es el tramo del indicador «a un paso de la primera página».
    NEAR_FIRST_PAGE = 'NEAR_FIRST_PAGE', 'Near the first page'
    #: De la tercera en adelante. Se cuenta junto porque la diferencia entre el
    #: puesto 40 y el 90 no cambia qué hacer.
    BEYOND = 'BEYOND', 'Beyond the second page'


class Engagement(models.TextChoices):
    """
    Si el sitio apareció y si eso se convirtió en visitas.

    Igual que `PositionBand`, se calcula y no se guarda. Los tres valores son
    excluyentes y el del medio es exactamente el del indicador «alcance sin
    clics»: **con el piso de impresiones adentro**. Sin el piso, «sin clics»
    incluiría las consultas que aparecieron tres veces, que no dicen nada, y la
    tabla filtrada mostraría el doble de filas que la cifra de la tarjeta.
    """

    WITH_CLICKS = 'WITH_CLICKS', 'With clicks'
    #: Apareció lo suficiente como para que la falta de clics signifique algo, y
    #: no hubo ninguno. Casi nunca es la posición: es el título o la descripción.
    NO_CLICKS = 'NO_CLICKS', 'Reach without clicks'
    #: Sin clics, pero con tan pocas impresiones que no sostiene ninguna
    #: afirmación. Se separa en vez de mezclarse con la anterior.
    LOW_REACH = 'LOW_REACH', 'Too few impressions'


class CannibalizationSeverity(models.TextChoices):
    """
    Cuánto pesa una canibalización, por cuántas páginas se pisan el mismo día.

    Tres tramos y no un número suelto porque la decisión que sigue es la misma en
    todo el tramo: con tres páginas se mira si son de verdad la misma intención,
    con seis ya hay que rehacer la arquitectura de esa sección. La diferencia
    entre siete y ocho no cambia nada.

    Se calcula en `services.classify_severity()` y no se guarda, igual que
    `PositionBand`.
    """

    #: Tres. El piso del indicador: puede ser inofensivo —una de categoría y una
    #: de detalle— y por eso se mira antes de tocar nada.
    MODERATE = 'MODERATE', 'Moderate'
    HIGH = 'HIGH', 'High'
    #: Seis o más. Ya no es un par de páginas parecidas: es una sección entera
    #: compitiendo consigo misma.
    SEVERE = 'SEVERE', 'Severe'


class PageRotation(models.TextChoices):
    """
    Si el conjunto que compite es siempre el mismo o va cambiando.

    Es la diferencia entre las dos cifras que la tabla muestra al lado: cuántas
    se pisaron el peor día y cuántas distintas aparecieron en el período.

    **Son dos problemas distintos y se arreglan distinto.** Con un conjunto
    estable, Google tiene siempre las mismas candidatas y hay que elegir una y
    redirigir el resto. Con rotación, además está cambiando cuál gana, y eso
    mueve la posición promedio de la consulta sin que nadie haya tocado nada.
    """

    STABLE = 'STABLE', 'Always the same pages'
    ROTATING = 'ROTATING', 'The winning page changes'


class KeywordSource(models.TextChoices):
    GOOGLE_DISCOVERED = 'GOOGLE_DISCOVERED', 'Discovered by Google'
    #: Agregada a mano como objetivo. Puede existir sin un solo dato de Google
    #: detrás, que es justamente para lo que sirve: fijar la meta antes de que
    #: el sitio aparezca por esa búsqueda.
    CUSTOM = 'CUSTOM', 'Added by hand'


class SyncState(BaseModel):
    """
    En qué punto está el módulo para un sitio.

    Cuelga de `Domain` en vez de duplicarlo. El sitio, su propiedad de Search
    Console y el estado de nuestro acceso ya viven allá con su máquina de
    transiciones; una segunda tabla de propiedades dejaría dos filas
    describiendo lo mismo y ninguna regla sobre cuál manda cuando difieren.
    """

    domain = models.OneToOneField(
        'domains.Domain', on_delete=models.CASCADE, related_name='seo_state'
    )
    status = models.CharField(
        max_length=20, choices=ModuleStatus.choices, default=ModuleStatus.NO_DATA
    )
    #: El día más reciente que Google dio por cerrado y nosotros guardamos.
    #:
    #: **Es el presente del módulo**: los cuatro comparadores parten de acá y no
    #: de la fecha del calendario. Google publica con dos o tres días de atraso,
    #: así que «ayer» no existe en la API y anclar los cálculos a `today()`
    #: compararía contra días vacíos.
    #:
    #: También es de dónde sale el rango de la próxima corrida, y por eso se
    #: guarda en vez de deducirse: si el trabajador no corre un día, la corrida
    #: siguiente trae sola lo que faltó. Un rango escrito contra el calendario
    #: dejaría ese día como un agujero permanente.
    last_closed_date = models.DateField(null=True, blank=True)
    #: Desde cuándo hay historia. La fija el backfill y sirve para no prometer
    #: una comparación que no se puede calcular.
    coverage_start = models.DateField(null=True, blank=True)
    last_sync_at = models.DateTimeField(null=True, blank=True)
    #: El último `GoogleErrorCode`, cuando lo hubo. Se guarda el código y no la
    #: prosa por lo mismo que `Domain.access_error_code`: la pantalla ramifica
    #: por código, y deducirlo leyendo el texto se rompe al primer cambio de
    #: redacción.
    last_error_code = models.CharField(max_length=40, blank=True, default='')

    def __str__(self) -> str:
        return f'{self.domain.hostname} · {self.status}'


class KeywordDaily(BaseModel):
    """
    El total de una consulta en un día.

    Ésta es la tabla que alimenta la pantalla de keywords y sus variaciones.
    Sale de preguntarle a Google por `['date', 'query']`, **sin** la dimensión de
    página: es el único modo de que las impresiones sean las que Google cuenta.
    """

    domain = models.ForeignKey(
        'domains.Domain', on_delete=models.CASCADE, related_name='seo_keywords'
    )
    #: La fecha tal como la devuelve Google, que la calcula en horario del
    #: Pacífico. **No se reinterpreta** en la zona de la cuenta: correrla un día
    #: haría que nuestra serie no coincida con la que muestra Search Console.
    date = models.DateField()
    query = models.CharField(max_length=500)
    clicks = models.PositiveIntegerField(default=0)
    impressions = models.PositiveIntegerField(default=0)
    #: Los dos llegan calculados por Google y se guardan como vienen. El CTR no
    #: se recalcula desde clics e impresiones: son los mismos números, y
    #: derivarlo agregaría una diferencia de redondeo contra lo que la persona
    #: ve en Search Console.
    ctr = models.FloatField(default=0.0)
    position = models.FloatField(default=0.0)

    class Meta:
        ordering = ['-date', '-impressions']
        constraints = [
            models.UniqueConstraint(
                fields=['domain', 'date', 'query'], name='one_keyword_row_per_day'
            )
        ]
        indexes = [models.Index(fields=['domain', 'date'])]

    def __str__(self) -> str:
        return f'{self.date} · {self.query}'


class KeywordPageDaily(BaseModel):
    """
    Qué páginas del sitio aparecen para una consulta, en un día.

    Contesta «¿qué URLs compiten por esta búsqueda?» y la pregunta inversa, «¿por
    qué búsquedas aparece esta URL?». **Para nada más.**

    Ningún total por consulta se calcula sumando esta tabla: sus impresiones
    están repartidas por página y una misma búsqueda puede haber dejado varias
    filas. El total vive en `KeywordDaily`.
    """

    domain = models.ForeignKey(
        'domains.Domain', on_delete=models.CASCADE, related_name='seo_keyword_pages'
    )
    date = models.DateField()
    query = models.CharField(max_length=500)
    #: Mismo largo que `sitemaps.Url.loc`, que ya vive en un índice único en esta
    #: base: es la misma clase de dato y no hay motivo para acotarla distinto.
    page = models.URLField(max_length=2000)
    clicks = models.PositiveIntegerField(default=0)
    impressions = models.PositiveIntegerField(default=0)
    ctr = models.FloatField(default=0.0)
    position = models.FloatField(default=0.0)

    class Meta:
        ordering = ['-date', '-impressions']
        constraints = [
            models.UniqueConstraint(
                fields=['domain', 'date', 'query', 'page'],
                name='one_keyword_page_row_per_day',
            )
        ]
        indexes = [
            models.Index(fields=['domain', 'date']),
            models.Index(fields=['domain', 'page']),
        ]

    def __str__(self) -> str:
        return f'{self.date} · {self.query} → {self.page}'


class SyncRun(BaseModel):
    """
    Una corrida de sincronización, con lo que hizo y cómo terminó.

    Es el lote de este módulo, y es **propio**: `jobs.Batch` haría el trabajo
    casi campo por campo, pero atarlo traería consigo su presupuesto de cuota y
    su pantalla, y las dos herramientas quedarían mezcladas en una misma lista.
    Lo que sí se reutiliza son las piezas del cliente —`BatchProgress`,
    `BatchStateBadge`, `useBatchPolling`—, y por eso los contadores llevan sus
    nombres.

    Sus estados tampoco se importan de `jobs`: van a quedar idénticos, pero
    importarlos crearía justo la dependencia que esta separación evita. Si con el
    tiempo se confirma que son genéricos, su lugar es `apps/core`.
    """

    domain = models.ForeignKey(
        'domains.Domain', on_delete=models.CASCADE, related_name='seo_runs'
    )
    kind = models.CharField(max_length=20, choices=RunKind.choices)
    origin = models.CharField(max_length=20, choices=RunOrigin.choices)
    state = models.CharField(max_length=20, choices=RunState.choices, default=RunState.QUEUED)
    #: El rango pedido. Se guarda aunque Google devuelva menos días: la
    #: diferencia entre lo que se pidió y lo que llegó es el dato que explica un
    #: hueco en la serie.
    requested_start = models.DateField()
    requested_end = models.DateField()
    #: Días del rango, no filas. El progreso se cuenta en días porque es lo único
    #: que se sabe antes de empezar; cuántas filas va a devolver Google no.
    total_items = models.PositiveIntegerField(default=0)
    processed_items = models.PositiveIntegerField(default=0)
    failed_items = models.PositiveIntegerField(default=0)
    started_at = models.DateTimeField(null=True, blank=True)
    finished_at = models.DateTimeField(null=True, blank=True)
    #: Cuántas veces se llamó a Google y cuántas filas se guardaron en cada
    #: tabla. No consume `QuotaBudget` —ése es el presupuesto de inspección de
    #: URLs, que Search Analytics no comparte— así que es un dato para mirar,
    #: no un descuento contra un tope.
    summary = models.JSONField(default=dict, blank=True)
    error_code = models.CharField(max_length=40, blank=True, default='')

    class Meta:
        ordering = ['-created_at']
        indexes = [models.Index(fields=['domain', '-created_at'])]

    def __str__(self) -> str:
        return f'{self.kind} · {self.domain.hostname} · {self.state}'

    @property
    def is_running(self) -> bool:
        return self.state not in TERMINAL_RUN_STATES


class TrackedKeyword(BaseModel):
    """
    Una consulta que alguien decidió seguir.

    Puede no tener un solo dato de Google detrás. Eso no es un error ni un
    estado a corregir: es una meta escrita antes de que el sitio aparezca por
    esa búsqueda, y las sincronizaciones siguientes la van a poblar solas
    cuando Google empiece a reportarla.
    """

    domain = models.ForeignKey(
        'domains.Domain', on_delete=models.CASCADE, related_name='seo_tracked'
    )
    keyword = models.CharField(max_length=500)
    source = models.CharField(
        max_length=20, choices=KeywordSource.choices, default=KeywordSource.CUSTOM
    )

    class Meta:
        ordering = ['keyword']
        constraints = [
            models.UniqueConstraint(
                fields=['domain', 'keyword'], name='one_tracked_keyword_per_domain'
            )
        ]

    def __str__(self) -> str:
        return self.keyword
