"""
Que la cifra de una tarjeta y el largo de la tabla filtrada sean el mismo número.

El inicio del módulo dice «43 consultas cerca de la primera página» y su botón
lleva a la tabla de consultas recortada por ese tramo. La regla del producto
—una cifra se abre donde se la lee— sólo admite ese enlace bajo una condición:
que el destino conteste **la misma pregunta con el mismo conjunto**. Si la
tarjeta dice 43 y la tabla muestra 51, el enlace pasa a ser una mentira, y una
que nadie detecta: las dos pantallas dibujan bien, contestan bien y ninguna
prueba falla.

Lo que lo garantiza es que el corte esté escrito **una sola vez**. `keyword_rows`
etiqueta cada fila con su tramo y su alcance, el indicador filtra por esa
etiqueta y la tabla del navegador filtra por esa misma etiqueta, que le llega en
la fila. Acá se comprueba que siga siendo así: que los indicadores no vuelvan a
cortar por su cuenta y que los cortes caigan donde dicen que caen.
"""

from datetime import timedelta

import pytest

from apps.seo import services
from apps.seo.models import (
    CannibalizationSeverity,
    Engagement,
    KeywordDaily,
    PageRotation,
    PositionBand,
    SyncState,
)
from tests.factories import create_account, create_domain


@pytest.fixture
def domain(db):
    return create_domain(create_account())


def record(domain, query, *, position=5.0, impressions=100, clicks=10, days_ago=0):
    """
    Una consulta con un solo día de historia, para poder fijar su promedio.

    Cada prueba declara **lo que le importa** y deja lo demás en un valor que no
    cae en ningún borde: la del alcance no dice posición porque el alcance no
    depende de ella, y viceversa.
    """
    last = services.last_available_date()
    return KeywordDaily.objects.create(
        domain=domain,
        date=last - timedelta(days=days_ago),
        query=query,
        clicks=clicks,
        impressions=impressions,
        ctr=clicks / impressions if impressions else 0.0,
        position=position,
    )


@pytest.fixture
def site(domain):
    last = services.last_available_date()
    SyncState.objects.create(
        domain=domain,
        last_closed_date=last,
        coverage_start=last - timedelta(days=400),
    )
    return domain


class TestWhereTheBandsCut:
    """
    Los cortes son **por arriba**: la posición 10,0 es primera página y la 10,1
    ya es la segunda. Es como los cuenta Google y como los contaba
    `near_first_page` con su `10 < position <= 20`.
    """

    @pytest.mark.parametrize(
        ('position', 'expected'),
        [
            (1.0, PositionBand.TOP_3),
            (3.0, PositionBand.TOP_3),
            (3.1, PositionBand.FIRST_PAGE),
            (10.0, PositionBand.FIRST_PAGE),
            (10.1, PositionBand.NEAR_FIRST_PAGE),
            (20.0, PositionBand.NEAR_FIRST_PAGE),
            (20.1, PositionBand.BEYOND),
            (91.4, PositionBand.BEYOND),
        ],
    )
    def test_each_position_falls_in_one_band(self, position, expected):
        assert services.classify_band(position) == expected

    def test_the_four_bands_cover_everything(self):
        """
        Ninguna posición se queda sin tramo.

        Importa porque el filtro ofrece los cuatro: si alguna posición cayera
        fuera, elegir los cuatro dejaría filas afuera y «no filtrar» pasaría a
        recortar en silencio.
        """
        bands = {services.classify_band(position / 10) for position in range(1, 2000)}

        assert bands == set(PositionBand.values)


class TestWhereTheReachCuts:
    def test_a_keyword_with_clicks_is_never_reach_without_clicks(self):
        assert services.classify_engagement(1, 5) == Engagement.WITH_CLICKS

    def test_the_impression_floor_is_part_of_the_definition(self):
        """
        El piso vive **adentro** de la clasificación y no en quien la consume.

        Afuera se podía olvidar, y olvidarlo no rompe nada: la tabla filtrada por
        «sin clics» traería además todas las consultas que aparecieron tres
        veces, que no sostienen ninguna afirmación, y mostraría el doble de filas
        que la cifra de la tarjeta que llevó hasta ella.
        """
        floor = services.REACH_MINIMUM_IMPRESSIONS

        assert services.classify_engagement(0, floor) == Engagement.NO_CLICKS
        assert services.classify_engagement(0, floor - 1) == Engagement.LOW_REACH


class TestTheCannibalizationAxes:
    """
    Los dos ejes por los que se filtra la tabla de canibalización.

    Mismo criterio que los de arriba: se cortan una sola vez, en el servidor, y
    la tabla lee la etiqueta. La gravedad se podría deducir en el navegador
    comparando `competing`, y la rotación comparando las dos cifras; hacerlo
    sería la misma regla escrita en dos idiomas.
    """

    @pytest.mark.parametrize(
        ('competing', 'expected'),
        [
            (3, CannibalizationSeverity.MODERATE),
            (4, CannibalizationSeverity.HIGH),
            (5, CannibalizationSeverity.HIGH),
            (6, CannibalizationSeverity.SEVERE),
            (18, CannibalizationSeverity.SEVERE),
        ],
    )
    def test_the_severity_cuts_where_it_says(self, competing, expected):
        assert services.classify_severity(competing) == expected

    def test_the_lightest_severity_starts_at_the_threshold(self):
        """
        El tramo más leve arranca en el piso del indicador y no más abajo: por
        debajo del umbral la consulta no está en la tabla, así que un tramo para
        dos páginas sería una opción de filtro que nunca devuelve nada.
        """
        assert services.classify_severity(services.CANNIBALIZATION_THRESHOLD) == (
            CannibalizationSeverity.MODERATE
        )

    def test_more_pages_in_the_period_than_in_the_worst_day_is_rotation(self):
        assert services.classify_rotation(3, 7) == PageRotation.ROTATING

    def test_the_same_count_is_a_stable_set(self):
        """
        Las mismas tres todos los días: no rota, compiten siempre las mismas. Es
        otro problema y se arregla distinto —elegir una y redirigir el resto—,
        por eso es un eje aparte y no una cifra más.
        """
        assert services.classify_rotation(3, 3) == PageRotation.STABLE


class TestTheIndicatorAndTheTableAgree:
    """
    Los dos conjuntos tienen que ser **el mismo**, no dos parecidos.

    Cada prueba de acá arma un sitio con consultas de los cuatro tramos y
    comprueba que lo que devuelve el indicador es exactamente lo que la tabla
    filtrada por la etiqueta de ese indicador va a mostrar.
    """

    def test_near_first_page_is_what_the_band_filter_would_show(self, site):
        record(site, 'primera', position=4.0)
        record(site, 'segunda', position=14.0)
        record(site, 'otra segunda', position=19.5)
        record(site, 'lejos', position=45.0)

        indicator = {row['query'] for row in services.near_first_page(site)}
        filtered = {
            row['query']
            for row in services.keyword_rows(site)
            if row['band'] == PositionBand.NEAR_FIRST_PAGE
        }

        assert indicator == filtered == {'segunda', 'otra segunda'}

    def test_reach_without_clicks_is_what_the_reach_filter_would_show(self, site):
        record(site, 'con clics', impressions=200, clicks=9)
        record(site, 'sin clics', impressions=200, clicks=0)
        # Por debajo del piso: sin clics, pero sin muestra que lo sostenga.
        record(site, 'apenas vista', impressions=2, clicks=0)

        indicator = {row['query'] for row in services.reach_without_clicks(site)}
        filtered = {
            row['query']
            for row in services.keyword_rows(site)
            if row['engagement'] == Engagement.NO_CLICKS
        }

        assert indicator == filtered == {'sin clics'}

    def test_the_home_figure_is_the_length_of_that_same_set(self, site):
        """
        La cifra de la tarjeta sale de contar el mismo conjunto.

        Es la prueba de punta a punta de por qué esas tarjetas pueden enlazar:
        el botón lleva a la tabla recortada por la etiqueta, y la tabla recortada
        tiene tantas filas como dice la tarjeta.
        """
        record(site, 'segunda', position=14.0)
        record(site, 'otra segunda', position=11.0)
        record(site, 'primera', position=2.0)
        record(site, 'sin clics', position=30.0, impressions=200, clicks=0)

        metrics = services.home_metrics(site)
        rows = services.keyword_rows(site)

        assert metrics['near_first_page']['total'] == len(
            [row for row in rows if row['band'] == PositionBand.NEAR_FIRST_PAGE]
        )
        assert metrics['reach_without_clicks']['total'] == len(
            [row for row in rows if row['engagement'] == Engagement.NO_CLICKS]
        )

    def test_every_row_carries_both_labels(self, site):
        """
        Sin la etiqueta en la fila, el filtro de la tabla no tiene por dónde
        agarrarse y tendría que volver a deducirla en el navegador, que es
        exactamente la duplicación que esto existe para evitar.
        """
        record(site, 'una', position=7.0)

        row = services.keyword_rows(site)[0]

        assert row['band'] in set(PositionBand.values)
        assert row['engagement'] in set(Engagement.values)
