"""
Quién puede importar la historia, y qué ve cada perfil.

Es la única regla del módulo que vive en la vista y no en el servicio, porque es
una pregunta de la interfaz: el servicio hace lo que se le pide, y decidir a
quién se le ofrece el botón es de acá.
"""

import pytest

from apps.accounts.models import AccountRole
from apps.seo import services
from apps.seo.models import (
    KeywordDaily,
    KeywordPageDaily,
    ModuleStatus,
    RunState,
    SyncRun,
    SyncState,
)
from tests.factories import create_account, create_domain


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


def sign_in(client, domain, role):
    account = create_account(email=f'{role.lower()}@ejemplo.test')
    account.role = role
    account.save(update_fields=['role'])
    client.force_login(account)
    return account


def connection(client):
    return client.get('/seo/connection', HTTP_X_INERTIA='true').json()['props']


class TestWhoCanImport:
    def test_before_importing_anyone_can(self, client, domain):
        sign_in(client, domain, AccountRole.VIEWER)

        props = connection(client)

        assert props['can_import'] is True
        assert props['imported'] is False
        assert props['destructive'] is False

    def test_once_imported_the_button_is_gone(self, client, domain):
        """
        Hecha la importación no hay nada que importar, así que el botón se va.

        Dejarlo ofrecería una acción que no cambia nada, o peor: que rehace un
        trabajo ya hecho sin decir que lo rehace.
        """
        SyncState.objects.create(domain=domain, last_closed_date=services.last_available_date())
        sign_in(client, domain, AccountRole.ADMIN)

        props = connection(client)

        assert props['imported'] is True
        assert props['can_import'] is False

    def test_the_super_admin_always_sees_it_and_it_is_destructive(self, client, domain):
        """
        A quien administra no se le esconde, porque para ese perfil el botón hace
        otra cosa: borra y repuebla. Es la herramienta para relanzar cuando los
        datos salen mal.
        """
        SyncState.objects.create(domain=domain, last_closed_date=services.last_available_date())
        sign_in(client, domain, AccountRole.SUPER_ADMIN)

        props = connection(client)

        assert props['can_import'] is True
        assert props['destructive'] is True


class TestTheImportRequest:
    def test_a_first_import_is_queued(self, client, domain, monkeypatch):
        """
        El caso que tiene que salir bien, y va **primero**.

        Sin él, los tres que siguen —todos con la forma «no se creó nada»— pasan
        igual contra una dirección que no existe: un 404 tampoco crea nada. Éste
        es el que se rompe si la ruta cambia, y por eso está.
        """
        monkeypatch.setattr('apps.seo.tasks.run_backfill.delay', lambda *args: None)
        sign_in(client, domain, AccountRole.VIEWER)

        response = client.post('/seo/backfill', {'months': 12})

        assert response.status_code == 302
        assert SyncRun.objects.count() == 1

    def test_a_second_import_from_a_regular_profile_is_refused(self, client, domain):
        """
        La comprobación no está sólo en el botón escondido.

        Esconder el control no alcanza: la dirección sigue contestando, y el
        envío llega igual desde una pestaña vieja o desde la API.
        """
        SyncState.objects.create(domain=domain, last_closed_date=services.last_available_date())
        sign_in(client, domain, AccountRole.ADMIN)

        client.post('/seo/backfill', {'months': 12})

        assert not SyncRun.objects.exists()

    def test_a_range_outside_the_offered_ones_is_refused(self, client, domain):
        sign_in(client, domain, AccountRole.VIEWER)

        client.post('/seo/backfill', {'months': 99})

        assert not SyncRun.objects.exists()

    def test_two_imports_at_once_are_refused(self, client, domain):
        """
        Dos importaciones sobre el mismo sitio se pisarían al escribir, y
        encolar la segunda no la haría más rápida.
        """
        SyncRun.objects.create(
            domain=domain,
            kind='BACKFILL',
            origin='MANUAL',
            state=RunState.RUNNING,
            requested_start=services.last_available_date(),
            requested_end=services.last_available_date(),
        )
        sign_in(client, domain, AccountRole.VIEWER)

        client.post('/seo/backfill', {'months': 1})

        assert SyncRun.objects.count() == 1


class TestWithoutASite:
    def test_the_screens_do_not_break(self, client, db):
        """
        Sin sitio conectado no hay nada que resumir, y lo que corresponde no es
        un error sino decir qué falta.
        """
        account = create_account()
        client.force_login(account)

        for path in ('/seo', '/seo/connection'):
            response = client.get(path, HTTP_X_INERTIA='true')
            assert response.status_code == 200
            assert response.json()['props']['domain'] is None


class TestTheOverview:
    def test_it_reports_activity_and_never_a_quota_meter(self, client, domain):
        """
        El inicio dice **actividad**, no consumo.

        Search Analytics tiene un límite de tasa que se renueva solo y un techo
        de proyecto de treinta millones diarios: un medidor marcaría cero para
        siempre, y un medidor que nunca se mueve entrena a no mirar ese rincón.
        """
        SyncState.objects.create(domain=domain, status=ModuleStatus.READY)
        sign_in(client, domain, AccountRole.VIEWER)

        overview = client.get('/seo', HTTP_X_INERTIA='true').json()['props']['overview']

        assert set(overview) == {
            'status',
            'error_code',
            'last_closed_date',
            'coverage_start',
            'last_sync_at',
            'keywords',
            'rows',
            'last_run',
        }


class TestTheHomeChart:
    def test_without_history_the_chart_does_not_travel(self, client, domain):
        """
        `None` y no una estructura vacía: la pantalla decide con eso si dibujar
        la tarjeta, y un gráfico sin una sola línea se lee como un error de
        carga.
        """
        SyncState.objects.create(domain=domain, status=ModuleStatus.NO_DATA)
        sign_in(client, domain, AccountRole.VIEWER)

        props = client.get('/seo', HTTP_X_INERTIA='true').json()['props']

        assert props['top_keywords'] is None

    def test_with_history_it_brings_the_axis_the_rows_and_the_series(self, client, domain):
        last = services.last_available_date()
        SyncState.objects.create(domain=domain, last_closed_date=last)
        KeywordDaily.objects.create(
            domain=domain,
            date=last,
            query='patios',
            clicks=3,
            impressions=100,
            ctr=0.03,
            position=4.0,
        )
        sign_in(client, domain, AccountRole.VIEWER)

        top = client.get('/seo', HTTP_X_INERTIA='true').json()['props']['top_keywords']

        assert set(top) == {'dates', 'keywords', 'series'}
        assert [row['query'] for row in top['keywords']] == ['patios']
        assert [entry['query'] for entry in top['series']] == ['patios']


class TestTheCannibalizationDetail:
    """
    La única vista del módulo que contesta JSON.

    Existe porque la pantalla lista decenas de consultas afectadas y de esas se
    abren dos o tres: mandar las URLs de todas en la carga inicial era traer
    cientos de filas para dibujar unas pocas.
    """

    def test_it_answers_the_pages_of_one_keyword(self, client, domain):
        last = services.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='patios',
                page=f'https://ejemplo.test/p-{index}',
                clicks=0,
                impressions=10,
                ctr=0.0,
                position=3.0,
            )
        sign_in(client, domain, AccountRole.VIEWER)

        response = client.get('/seo/cannibalization/pages', {'q': 'patios'})

        assert response.status_code == 200
        assert len(response.json()['pages']) == 3

    def test_a_keyword_without_pages_is_a_404(self, client, domain):
        """
        Y no una lista vacía: vacía diría «existe y no compite con nada», que no
        puede pasar —si está en la tabla, compite—.
        """
        SyncState.objects.create(domain=domain, last_closed_date=services.last_available_date())
        sign_in(client, domain, AccountRole.VIEWER)

        assert client.get('/seo/cannibalization/pages', {'q': 'inventada'}).status_code == 404

    def test_without_a_keyword_it_is_a_404(self, client, domain):
        sign_in(client, domain, AccountRole.VIEWER)

        assert client.get('/seo/cannibalization/pages').status_code == 404

    def test_it_needs_a_session(self, client, domain):
        """
        Es una dirección que contesta datos del sitio, así que pide sesión como
        cualquier pantalla. Que devuelva JSON no la hace pública.
        """
        response = client.get('/seo/cannibalization/pages', {'q': 'patios'})

        assert response.status_code == 302
        assert '/login' in response['Location']


class TestOpeningTheTableAlreadyFiltered:
    """
    La dirección sabe abrir la tabla de consultas recortada.

    Es lo que permite que una tarjeta del inicio **enlace** en vez de abrir un
    modal: la regla del producto reserva el enlace para cuando el destino
    contesta la misma pregunta con el mismo conjunto, y sin recorte el destino
    contesta con las cuatrocientas setenta consultas del sitio.

    Lo que viaja es la forma pública del valor —`near-first-page`— y lo que llega
    a la pantalla es la del enum, que es la que lleva cada fila. Que las dos
    coincidan no se puede comprobar mirando la pantalla, porque un filtro que no
    engancha no se ve roto: la tabla vuelve completa y no falla nada.
    """

    def keywords(self, client, **params):
        response = client.get('/seo/keywords', params, HTTP_X_INERTIA='true')
        return response.json()['props']['filters']

    def test_a_band_travels_as_its_public_form(self, client, domain):
        sign_in(client, domain, AccountRole.VIEWER)

        assert self.keywords(client, band='near-first-page') == {'band': ['NEAR_FIRST_PAGE']}

    def test_the_reach_axis_travels_the_same_way(self, client, domain):
        sign_in(client, domain, AccountRole.VIEWER)

        assert self.keywords(client, engagement='no-clicks') == {'engagement': ['NO_CLICKS']}

    def test_several_values_of_one_axis_travel_together(self, client, domain):
        """
        Los valores de un mismo eje son alternativas, no requisitos: «primera
        página o segunda» es una pregunta corriente, y las filas que sean las dos
        a la vez no existen.
        """
        sign_in(client, domain, AccountRole.VIEWER)

        chosen = self.keywords(client, band='first-page,near-first-page')

        assert chosen == {'band': ['FIRST_PAGE', 'NEAR_FIRST_PAGE']}

    def test_an_invented_value_is_discarded_and_nothing_breaks(self, client, domain):
        """
        Casi siempre es un enlace guardado y no un ataque, así que se descarta en
        silencio y la pantalla abre completa. Es el mismo trato que le da
        `TableSpec` a un valor fuera del juego.
        """
        sign_in(client, domain, AccountRole.VIEWER)

        assert self.keywords(client, band='derretido') == {}
        assert self.keywords(client, band='NEAR_FIRST_PAGE') == {}

    def test_without_parameters_nothing_is_cut(self, client, domain):
        """
        Un eje vacío no puede viajar: no recorta nada pero cuenta como filtro
        puesto, y la tabla arrancaría ofreciendo limpiar un recorte que nadie
        hizo.
        """
        sign_in(client, domain, AccountRole.VIEWER)

        assert self.keywords(client) == {}

    def test_the_cannibalization_table_reads_its_own_axes(self, client, domain):
        """
        Los mismos dos ejes son los que la tabla de consultas **no** entiende, y
        al revés: cada pantalla declara su juego, así que un `band=` acá se
        descarta como cualquier valor de fuera.
        """
        sign_in(client, domain, AccountRole.VIEWER)

        response = client.get(
            '/seo/cannibalization',
            {'severity': 'severe,high', 'rotation': 'rotating', 'band': 'top-3'},
            HTTP_X_INERTIA='true',
        )

        assert response.json()['props']['filters'] == {
            'severity': ['SEVERE', 'HIGH'],
            'rotation': ['ROTATING'],
        }
