"""
La importación de historia, de punta a punta y sin salir a la red.

Tres cosas se defienden acá, y ninguna se ve leyendo el código de a una función:
que repetir un rango corrija en vez de duplicar, que una corrida fallida no
destruya lo que ya estaba, y que el borrado de quien administra no se lleve nada
de la otra herramienta.
"""

from datetime import timedelta

import pytest

from apps.gsc.errors import GoogleCallError, GoogleErrorCode
from apps.seo import services
from apps.seo.models import (
    KeywordDaily,
    KeywordPageDaily,
    ModuleStatus,
    RunState,
    SyncRun,
)
from tests.doubles import FakeClient
from tests.factories import create_account, create_domain

TOTALS = 'search_analytics:date,query'
PAGES = 'search_analytics:date,query,page'


def rows(*entries) -> dict:
    return {'rows': [{'keys': list(keys), **metrics} for keys, metrics in entries]}


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


@pytest.fixture
def answers():
    """
    Un día en el que dos páginas aparecieron por la misma búsqueda.

    El total de la consulta es **10** y el reparto por página suma **20**: es el
    caso real que obliga a las dos tablas, en miniatura.
    """
    day = '2026-08-20'
    return {
        TOTALS: rows(
            ([day, 'pool pavers'], {'clicks': 1, 'impressions': 10, 'ctr': 0.1, 'position': 4.0}),
        ),
        PAGES: rows(
            (
                [day, 'pool pavers', 'https://example.test/a'],
                {'clicks': 1, 'impressions': 10, 'ctr': 0.1, 'position': 3.0},
            ),
            (
                [day, 'pool pavers', 'https://example.test/b'],
                {'clicks': 0, 'impressions': 10, 'ctr': 0.0, 'position': 5.0},
            ),
        ),
    }


@pytest.fixture
def client_for(monkeypatch, answers):
    """Instala el doble en lugar del cliente real, con las respuestas dadas."""

    def install(**overrides):
        fake = FakeClient(**{**answers, **overrides})
        monkeypatch.setattr(services, '_client', lambda: fake)
        return fake

    return install


class TestTheImport:
    def test_it_asks_twice_and_stores_each_answer_where_it_belongs(self, domain, client_for):
        """
        Dos consultas por rango, y cada una a su tabla.

        Es la decisión central del módulo: preguntar una sola vez y repartir
        después daría totales inflados en una de cada cuatro consultas.
        """
        fake = client_for()

        run = services.queue_backfill(domain, 1)
        services.execute(run)

        assert [call for call, _ in fake.calls] == [TOTALS, PAGES]
        assert KeywordDaily.objects.count() == 1
        assert KeywordPageDaily.objects.count() == 2

    def test_the_total_is_not_the_sum_of_the_pages(self, domain, client_for):
        client_for()
        services.execute(services.queue_backfill(domain, 1))

        assert KeywordDaily.objects.get().impressions == 10
        assert sum(entry.impressions for entry in KeywordPageDaily.objects.all()) == 20

    def test_repeating_a_range_corrects_instead_of_duplicating(self, domain, client_for):
        """
        El upsert es lo que permite relanzar sin miedo.

        Sin él, importar dos veces el mismo rango dejaría la serie con el doble
        de filas y todas las cifras al doble.
        """
        client_for()
        services.execute(services.queue_backfill(domain, 1))

        corrected = {
            TOTALS: rows(
                (
                    ['2026-08-20', 'pool pavers'],
                    {'clicks': 3, 'impressions': 40, 'ctr': 0.075, 'position': 2.0},
                ),
            )
        }
        client_for(**corrected)
        services.execute(services.queue_backfill(domain, 1))

        assert KeywordDaily.objects.count() == 1
        assert KeywordDaily.objects.get().impressions == 40

    def test_it_records_what_it_did(self, domain, client_for):
        client_for()
        run = services.execute(services.queue_backfill(domain, 1))

        assert run.state == RunState.COMPLETED
        assert run.summary['api_requests'] == 2
        assert run.summary['keyword_rows'] == 1
        assert run.summary['keyword_page_rows'] == 2


class TestTheState:
    def test_a_successful_run_moves_the_last_closed_date(self, domain, client_for):
        client_for()
        run = services.execute(services.queue_backfill(domain, 1))

        state = services.state_for(domain)
        assert state.last_closed_date == run.requested_end
        assert state.coverage_start == run.requested_start
        assert state.status == ModuleStatus.READY

    def test_an_empty_range_does_not_move_it(self, domain, client_for):
        """
        Un rango vacío no es un rango cubierto.

        Moverlo igual saltearía esos días para siempre: la corrida siguiente
        arranca justo después de esta marca, así que lo que quede atrás no se
        vuelve a pedir nunca.
        """
        client_for(**{TOTALS: {}, PAGES: {}})
        services.execute(services.queue_backfill(domain, 1))

        state = services.state_for(domain)
        assert state.last_closed_date is None
        assert state.status == ModuleStatus.NO_DATA

    def test_a_failed_run_keeps_the_last_valid_dataset(self, domain, client_for):
        """
        Un 503 no puede vaciar el módulo.

        Es lo que la especificación pide en tantas palabras: una sincronización
        fallida conserva el último conjunto válido y muestra un estado degradado,
        en vez de dejar la pantalla sin nada.
        """
        client_for()
        services.execute(services.queue_backfill(domain, 1))
        assert KeywordDaily.objects.count() == 1

        client_for(**{TOTALS: GoogleCallError(GoogleErrorCode.PROVIDER_UNAVAILABLE)})
        with pytest.raises(GoogleCallError):
            services.execute(services.queue_backfill(domain, 1))

        state = services.state_for(domain)
        assert KeywordDaily.objects.count() == 1
        assert state.last_closed_date is not None
        assert state.status == ModuleStatus.ERROR

    def test_a_permanent_error_asks_for_a_person(self, domain, client_for):
        """
        Reintentar un permiso denegado gasta tiempo para volver al mismo lugar.

        La partición ya existe en `PERMANENT_CODES`, y el módulo la usa en vez de
        escribir su propio criterio.
        """
        client_for(**{TOTALS: GoogleCallError(GoogleErrorCode.PERMISSION_DENIED)})

        with pytest.raises(GoogleCallError):
            services.execute(services.queue_backfill(domain, 1))

        state = services.state_for(domain)
        assert state.status == ModuleStatus.ACTION_REQUIRED
        assert state.last_error_code == GoogleErrorCode.PERMISSION_DENIED


class TestTheReset:
    def test_it_only_deletes_what_belongs_to_this_module(self, domain, client_for):
        """
        El borrado de quien administra no toca la otra herramienta.

        Es la garantía que hace usable la herramienta de desarrollo: relanzar la
        importación no puede llevarse meses de cobertura ni el historial de lotes.
        """
        from apps.jobs.models import Batch, BatchKind, BatchOrigin
        from apps.sitemaps.models import Url

        client_for()
        services.execute(services.queue_backfill(domain, 1))

        url = Url.objects.create(domain=domain, loc='https://example.test/a')
        batch = Batch.objects.create(
            domain=domain, kind=BatchKind.SITEMAP_SYNC, origin=BatchOrigin.MANUAL
        )

        services.reset_and_repopulate(domain, 1)

        assert KeywordDaily.objects.count() == 0
        assert KeywordPageDaily.objects.count() == 0
        assert Url.objects.filter(id=url.id).exists()
        assert Batch.objects.filter(id=batch.id).exists()

    def test_it_leaves_a_run_ready_to_execute(self, domain, client_for):
        client_for()
        services.execute(services.queue_backfill(domain, 1))

        run = services.reset_and_repopulate(domain, 1)

        assert run.state == RunState.QUEUED
        assert services.state_for(domain).last_closed_date is None

    def test_the_history_of_runs_survives_the_reset(self, domain, client_for):
        """
        Lo que se borra son los datos, no el registro de lo que se hizo.

        El historial es cómo se comprueba qué pasó, y perderlo justo al relanzar
        borraría la evidencia del problema que motivó el relanzamiento.
        """
        client_for()
        services.execute(services.queue_backfill(domain, 1))
        before = SyncRun.objects.count()

        services.reset_and_repopulate(domain, 1)

        assert SyncRun.objects.count() == before + 1


class TestTheDailySync:
    def test_without_history_there_is_nothing_to_queue(self, domain):
        assert services.queue_daily_sync(domain) is None

    def test_after_a_backfill_it_asks_only_for_what_is_missing(self, domain, client_for):
        client_for()
        services.execute(services.queue_backfill(domain, 1))

        state = services.state_for(domain)
        state.last_closed_date = services.last_available_date() - timedelta(days=2)
        state.save(update_fields=['last_closed_date'])

        run = services.queue_daily_sync(domain)

        assert run is not None
        assert run.total_items == 2
