"""
Que ningún total por consulta salga de sumar la tabla de páginas.

Es el defecto que la especificación no vio y que sólo apareció llamando a la API
real: 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 un sitio de
verdad, sumar por página inflaba 109 de 473 claves —una de cada cuatro— y en el
peor caso multiplicaba el total por seis.

Por eso hay dos tablas. Esto es lo que impide que alguien las vuelva a juntar.
"""

from datetime import timedelta

import pytest

from apps.seo import services
from apps.seo.models import (
    CannibalizationSeverity,
    KeywordDaily,
    KeywordPageDaily,
    PageRotation,
    SyncState,
)
from apps.seo.services import keyword_detail, keyword_rows, last_available_date
from tests.factories import create_account, create_domain

QUERY = 'zapatillas running'


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


@pytest.fixture
def site_with_a_shared_search(domain):
    """
    Un día en el que seis páginas del sitio aparecieron por la misma búsqueda.

    Google reporta **15 impresiones** para la consulta y **90** repartidas entre
    las páginas. Las dos cifras son correctas y no son la misma pregunta.
    """
    last = last_available_date()
    SyncState.objects.create(domain=domain, last_closed_date=last, coverage_start=last)

    KeywordDaily.objects.create(
        domain=domain, date=last, query=QUERY, clicks=1, impressions=15, ctr=0.066, position=2.0
    )
    for index in range(6):
        KeywordPageDaily.objects.create(
            domain=domain,
            date=last,
            query=QUERY,
            page=f'https://example.test/page-{index}',
            clicks=0,
            impressions=15,
            ctr=0.0,
            position=2.0 + index,
        )
    return domain


class TestTheKeywordTable:
    def test_impressions_come_from_the_totals_table(self, site_with_a_shared_search):
        """
        Quince y no noventa.

        Noventa es lo que da sumar las seis filas por página, y es el número que
        la pantalla mostraría si alguien decidiera que una tabla alcanza.
        """
        rows = keyword_rows(site_with_a_shared_search)

        assert len(rows) == 1
        assert rows[0]['impressions'] == 15

    def test_the_ctr_is_not_inflated_either(self, site_with_a_shared_search):
        """
        El CTR se calcula sobre impresiones, así que hereda el error.

        Con el total inflado a noventa daría 1,1% en vez de 6,7%: seis veces
        menos, y sin nada en pantalla que lo delate.
        """
        rows = keyword_rows(site_with_a_shared_search)

        assert rows[0]['ctr'] == pytest.approx(1 / 15, rel=1e-3)

    def test_the_row_no_longer_carries_a_page_count(self, site_with_a_shared_search):
        """
        La columna se fue, y no por espacio.

        Contaba URLs distintas a lo largo de todo el período, y esa cuenta no
        decía lo que la columna prometía: dos páginas que rankearon en semanas
        distintas no compitieron, se reemplazaron. La competencia real vive en
        `cannibalized_keywords`, que cuenta el mismo día.
        """
        rows = keyword_rows(site_with_a_shared_search)

        assert 'pages' not in rows[0]


class TestCannibalization:
    def test_pages_competing_on_the_same_day_are_counted(self, site_with_a_shared_search):
        rows = services.cannibalized_keywords(site_with_a_shared_search)

        assert len(rows) == 1
        assert rows[0]['query'] == QUERY
        assert rows[0]['competing'] == 6
        assert rows[0]['pages_in_period'] == 6

    def test_the_list_does_not_carry_the_urls(self, site_with_a_shared_search):
        """
        Las URLs las pide la fila al abrirse, no la carga de la pantalla.

        La lista tiene decenas de consultas afectadas y cada una su lista de
        páginas; se abren dos o tres. Mandarlas todas era traer cientos de filas
        para dibujar unas pocas, y dejarlas acá **además** de pedirlas después
        sería pagar las dos veces.
        """
        rows = services.cannibalized_keywords(site_with_a_shared_search)

        assert 'pages' not in rows[0]

    def test_the_urls_of_one_keyword_come_on_demand(self, site_with_a_shared_search):
        pages = services.cannibalization_pages(site_with_a_shared_search, QUERY)

        assert len(pages) == 6
        assert set(pages[0]) == {'page', 'clicks', 'impressions', 'ctr', 'position'}

    def test_the_urls_come_ordered_by_ctr(self, domain):
        """
        La pregunta de esa lista es **cuál conservar**, y la que más se clickea
        es la que quien busca prefiere cuando se la ofrecen. No la que más
        aparece: una página puede llevarse todas las impresiones y ninguna
        visita, que es exactamente el problema a resolver.
        """
        last = last_available_date()
        SyncState.objects.create(domain=domain, last_closed_date=last)

        for page, clicks, impressions in (('muchas-vistas', 2, 200), ('mucho-clic', 20, 100)):
            KeywordPageDaily.objects.create(
                domain=domain,
                date=last,
                query=QUERY,
                page=f'https://example.test/{page}',
                clicks=clicks,
                impressions=impressions,
                ctr=clicks / impressions,
                position=3.0,
            )

        pages = services.cannibalization_pages(domain, QUERY)

        assert [page['page'].rsplit('/', 1)[-1] for page in pages] == [
            'mucho-clic',
            'muchas-vistas',
        ]

    def test_impressions_break_a_tie_in_ctr(self, domain):
        """
        Sin desempate, una página con dos apariciones y un clic —50 %— se pondría
        arriba de la que se lleva el tráfico de verdad con el mismo porcentaje.
        """
        last = last_available_date()
        SyncState.objects.create(domain=domain, last_closed_date=last)

        for page, clicks, impressions in (('apenas', 1, 2), ('de-verdad', 100, 200)):
            KeywordPageDaily.objects.create(
                domain=domain,
                date=last,
                query=QUERY,
                page=f'https://example.test/{page}',
                clicks=clicks,
                impressions=impressions,
                ctr=clicks / impressions,
                position=3.0,
            )

        pages = services.cannibalization_pages(domain, QUERY)

        assert pages[0]['page'].endswith('de-verdad')

    def test_each_row_carries_its_two_filter_axes(self, site_with_a_shared_search):
        """
        Sin las etiquetas en la fila, el filtro de la tabla tendría que volver a
        deducirlas en el navegador, que es la duplicación que esto evita.
        """
        row = services.cannibalized_keywords(site_with_a_shared_search)[0]

        assert row['severity'] == CannibalizationSeverity.SEVERE
        assert row['rotation'] == PageRotation.STABLE

    def test_a_keyword_without_pages_is_not_an_empty_list(self, site_with_a_shared_search):
        """
        `None` y no `[]`, y la diferencia es lo que la vista contesta.

        Una lista vacía diría «existe y no compite con nada», que no puede pasar:
        si está en la tabla, compite. Lo que hay es una consulta que no está, y
        eso es un 404.
        """
        assert services.cannibalization_pages(site_with_a_shared_search, 'no existe') is None

    def test_pages_that_ranked_on_different_days_are_not_cannibalization(self, domain):
        """
        **La distinción que define el indicador.**

        Seis páginas en seis días distintos no se pisaron: se turnaron. Contarlas
        como competencia —que es lo que hacía la columna vieja— casi duplicaba el
        número y llamaba problema a un simple cambio de URL dominante.
        """
        last = last_available_date()
        SyncState.objects.create(domain=domain, last_closed_date=last)

        for index in range(6):
            KeywordPageDaily.objects.create(
                domain=domain,
                date=last - timedelta(days=index),
                query=QUERY,
                page=f'https://example.test/page-{index}',
                clicks=0,
                impressions=10,
                ctr=0.0,
                position=3.0,
            )

        assert services.cannibalized_keywords(domain) == []

    def test_below_the_threshold_it_is_not_reported(self, domain):
        """
        Dos páginas por la misma búsqueda es corriente y muchas veces inofensivo.

        Empezar a contar en dos convertiría a una de cada cuatro consultas en
        «problema», y un indicador que marca un cuarto de todo deja de ser señal.
        """
        last = last_available_date()
        SyncState.objects.create(domain=domain, last_closed_date=last)

        for index in range(services.CANNIBALIZATION_THRESHOLD - 1):
            KeywordPageDaily.objects.create(
                domain=domain,
                date=last,
                query=QUERY,
                page=f'https://example.test/page-{index}',
                clicks=0,
                impressions=10,
                ctr=0.0,
                position=3.0,
            )

        assert services.cannibalized_keywords(domain) == []

    def test_it_reports_both_counts_because_they_tell_different_stories(self, domain):
        """
        Cuántas se pisaron y cuántas aparecieron en total van juntas.

        La diferencia entre las dos es cuánto rotó la URL que rankea, que es otra
        historia y conviene no confundirla con ésta.
        """
        last = last_available_date()
        SyncState.objects.create(domain=domain, last_closed_date=last)

        for index in range(3):
            KeywordPageDaily.objects.create(
                domain=domain,
                date=last,
                query=QUERY,
                page=f'https://example.test/page-{index}',
                clicks=0,
                impressions=10,
                ctr=0.0,
                position=3.0,
            )
        KeywordPageDaily.objects.create(
            domain=domain,
            date=last - timedelta(days=5),
            query=QUERY,
            page='https://example.test/old',
            clicks=0,
            impressions=10,
            ctr=0.0,
            position=3.0,
        )

        row = services.cannibalized_keywords(domain)[0]

        assert row['competing'] == 3
        assert row['pages_in_period'] == 4


class TestTheKeywordDetail:
    def test_the_header_uses_the_totals_table(self, site_with_a_shared_search):
        detail = keyword_detail(site_with_a_shared_search, QUERY)

        assert detail is not None
        assert detail['impressions'] == 15

    def test_the_share_is_measured_among_the_pages_themselves(
        self, site_with_a_shared_search
    ):
        """
        El reparto se calcula entre estas filas y no contra el total de la consulta.

        Dividir 90 por 15 daría porcentajes que suman 600%. El reparto contesta
        «qué porción de lo observado acá se lleva cada página», que es una
        pregunta que estas filas sí saben responder.
        """
        detail = keyword_detail(site_with_a_shared_search, QUERY)

        assert detail is not None
        assert sum(page['share'] for page in detail['pages']) == pytest.approx(1.0)
        for page in detail['pages']:
            assert page['share'] == pytest.approx(1 / 6)


class TestThePeriodComparison:
    def test_it_does_not_compare_against_a_period_the_history_does_not_cover(self, domain):
        """
        Una importación corta no puede fabricar una caída.

        Con historia desde mitad del tramo anterior, ahí hay unos pocos días y no
        un período: compararlos contra veintiocho y llamarlo «el período
        anterior» inventaría un desplome del tamaño de lo que falta importar.
        """
        last = last_available_date()
        SyncState.objects.create(
            domain=domain,
            last_closed_date=last,
            # La historia arranca dentro del período actual: del anterior no hay
            # un solo día completo.
            coverage_start=last - timedelta(days=10),
        )
        KeywordDaily.objects.create(
            domain=domain, date=last, query=QUERY, clicks=0, impressions=50, ctr=0.0, position=5.0
        )

        performance = services.period_performance(domain)

        assert performance['current'] is not None
        assert performance['previous'] is None

    def test_it_compares_when_the_history_covers_the_whole_period(self, domain):
        last = last_available_date()
        SyncState.objects.create(
            domain=domain,
            last_closed_date=last,
            coverage_start=last - timedelta(days=200),
        )
        for offset in (0, 40):
            KeywordDaily.objects.create(
                domain=domain,
                date=last - timedelta(days=offset),
                query=QUERY,
                clicks=0,
                impressions=50,
                ctr=0.0,
                position=5.0,
            )

        performance = services.period_performance(domain)

        assert performance['previous'] is not None

    def test_the_four_ends_travel_so_the_screen_can_state_the_scale(self, domain):
        """
        «Contra 84 el período anterior» sin decir de cuándo a cuándo no se puede
        juzgar: no se sabe si son siete días o noventa.
        """
        last = last_available_date()
        SyncState.objects.create(domain=domain, last_closed_date=last)

        performance = services.period_performance(domain)

        assert performance['current_end'] == last.isoformat()
        assert performance['current_start'] < performance['current_end']
        assert performance['previous_end'] < performance['current_start']


class TestTheAggregateAcrossDays:
    def test_position_is_weighted_by_impressions_across_the_period(self, domain):
        """
        Un día flojo no puede pesar lo mismo que uno con tráfico real.

        Sin ponderar, el promedio de 6,0 y 30,0 daría 18. Ponderado por las
        impresiones que sostienen cada uno, queda pegado al día que importa.
        """
        last = last_available_date()
        SyncState.objects.create(domain=domain, last_closed_date=last)

        KeywordDaily.objects.create(
            domain=domain, date=last, query=QUERY, clicks=0, impressions=500, ctr=0.0, position=6.0
        )
        KeywordDaily.objects.create(
            domain=domain,
            date=last - timedelta(days=1),
            query=QUERY,
            clicks=0,
            impressions=5,
            ctr=0.0,
            position=30.0,
        )

        rows = keyword_rows(domain)

        assert rows[0]['impressions'] == 505
        assert 6.0 < rows[0]['position'] < 6.3
