"""
Paginado, orden y filtrado del servidor (RT-09).

Tres cosas se verifican acá, y la primera es la que justifica que exista una
lista blanca: `order_by` lleva el nombre de campo tal cual a la base, así que
aceptar el que manda el navegador es dejar que cualquiera recorra el modelo y
sus relaciones eligiendo por dónde ordenar. Las otras dos son las que hacen que
la tabla se pueda usar: que una página inexistente devuelva filas en vez de un
error, y que el filtro no se caiga al pasar de página.
"""

import pytest
from django.db import connection
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext

from apps.core.tables import PER_PAGE, Filter, TableSpec, slug, table_props
from apps.domains.models import AccessState, Domain
from tests.factories import create_account, create_domain

pytestmark = pytest.mark.django_db

#: Una tabla de ejemplo con la forma de la de V4: se ordena por nombre o por
#: estado, se filtra por estado con valores enumerados y por texto libre.
DOMAINS = TableSpec(
    sortable={
        'hostname': ('hostname',),
        'access': ('access_state', 'hostname'),
    },
    default_sort='hostname',
    filters=(
        Filter(param='access_state', lookup='access_state', choices=tuple(AccessState.values)),
        Filter(param='q', lookup='hostname__icontains'),
    ),
)


def _row(domain: Domain) -> dict:
    return {'hostname': domain.hostname, 'access_state': domain.access_state}


def request_table(**params) -> dict:
    """Pide la tabla como la pediría el navegador, con los parámetros en la dirección."""
    request = RequestFactory().get('/search-console/domains', params)
    return table_props(request, Domain.objects.all(), DOMAINS, serialize=_row)


def request_prefixed(prefix: str, **params) -> dict:
    """La misma tabla, pero como una de las dos listas de una misma página."""
    request = RequestFactory().get('/api-keys', params)
    return table_props(request, Domain.objects.all(), DOMAINS, serialize=_row, prefix=prefix)


@pytest.fixture
def account():
    return create_account()


def test_the_declared_sort_is_applied(account):
    create_domain(account, hostname='zeta.test')
    create_domain(account, hostname='alfa.test')

    ascending = request_table(sort='hostname')
    descending = request_table(sort='-hostname')

    assert [f['hostname'] for f in ascending['rows']] == ['alfa.test', 'zeta.test']
    assert [f['hostname'] for f in descending['rows']] == ['zeta.test', 'alfa.test']
    assert descending['sort'] == '-hostname'


def test_the_descending_sort_reverses_the_whole_ordering(account):
    """
    El desempate también se da vuelta.

    `access` ordena por estado y después por nombre. Si el `-` sólo alcanzara al
    primer campo, dos dominios en el mismo estado quedarían siempre en el mismo
    lugar y el usuario vería un orden que no pidió.
    """
    create_domain(account, hostname='alfa.test', access_state=AccessState.OPERATIONAL)
    create_domain(account, hostname='beta.test', access_state=AccessState.OPERATIONAL)

    result = request_table(sort='-access')

    assert [f['hostname'] for f in result['rows']] == ['beta.test', 'alfa.test']


def test_a_field_outside_the_whitelist_is_ignored(account):
    """
    `daily_inspection_budget` existe en el modelo, pero la tabla no lo declara.

    Que el campo exista es justamente lo que hace peligroso confiar en el
    nombre: la consulta no fallaría, ordenaría. Acá se comprueba que la clave se
    descarta, que manda el orden por omisión y que el nombre no aparece en la
    cláusula que llega a la base.
    """
    create_domain(account, hostname='zeta.test', daily_inspection_budget=10)
    create_domain(account, hostname='alfa.test', daily_inspection_budget=9000)

    with CaptureQueriesContext(connection) as queries:
        result = request_table(sort='-daily_inspection_budget')

    assert result['sort'] == 'hostname'
    assert [f['hostname'] for f in result['rows']] == ['alfa.test', 'zeta.test']

    orderings = [
        c['sql'].split('ORDER BY')[-1] for c in queries.captured_queries if 'ORDER BY' in c['sql']
    ]
    assert orderings, 'La consulta tendría que haber salido ordenada'
    assert not any('daily_inspection_budget' in ordering for ordering in orderings)


def test_a_sort_by_a_relation_does_not_reach_the_database(account):
    """
    El caso que más importa: `account__password` es un `order_by` válido.

    Sin lista blanca, la querystring alcanzaría para ordenar por la contraseña
    de la cuenta —o por cualquier columna de cualquier tabla relacionada— y
    deducir su contenido comparando el orden de las filas.
    """
    create_domain(account, hostname='alfa.test')

    with CaptureQueriesContext(connection) as queries:
        result = request_table(sort='account__password')

    assert result['sort'] == 'hostname'
    assert not any('password' in c['sql'] for c in queries.captured_queries)


def test_a_page_out_of_range_returns_the_last_one(account):
    """
    Pedir la página 900 de una tabla que tiene 2 casi nunca es un ataque.

    Suele ser un enlace guardado, o un filtro que acaba de achicar el total
    mientras alguien miraba el final. Devolver un error ahí es castigar al
    usuario por algo que hicimos nosotros.
    """
    for number in range(PER_PAGE + 3):
        create_domain(account, hostname=f'sitio-{number:03d}.test')

    result = request_table(page='900')

    assert result['pages'] == 2
    assert result['page'] == 2
    assert len(result['rows']) == 3
    assert result['count'] == PER_PAGE + 3


def test_a_page_that_is_not_a_number_returns_the_first_one(account):
    create_domain(account, hostname='alfa.test')

    result = request_table(page='penúltima')

    assert result['page'] == 1
    assert len(result['rows']) == 1


def test_the_filters_survive_the_page_change(account):
    """
    El filtro vive en la dirección, así que la página siguiente lo sigue llevando.

    Es lo que hace que la vista sea enlazable y que recargar no la deje sin
    filtro: si el estado viviera en el componente, el segundo clic mostraría el
    conjunto entero sin avisar.
    """
    for number in range(PER_PAGE + 5):
        create_domain(
            account,
            hostname=f'con-acceso-{number:03d}.test',
            access_state=AccessState.OPERATIONAL,
        )
    create_domain(account, hostname='sin-acceso.test', access_state=AccessState.ACCESS_LOST)

    second = request_table(access_state=slug(AccessState.OPERATIONAL), page='2')

    assert second['count'] == PER_PAGE + 5
    assert second['page'] == 2
    assert all(row['hostname'].startswith('con-acceso') for row in second['rows'])
    # Lo que vuelve es la forma pública, que es la que el control compara contra
    # la opción que dibuja. Devolver el valor de base dejaría a los dos hablando
    # idiomas distintos y ninguna opción se vería marcada.
    assert second['filters']['access_state'] == 'operational'
    # La querystring que vuelve es la que el frontend usa para armar cada
    # enlace: si el filtro no estuviera acá, se perdería en el clic siguiente.
    assert second['query'] == {'access_state': 'operational', 'page': 2}


def test_the_sort_and_the_filter_coexist_in_the_querystring(account):
    create_domain(account, hostname='alfa.test', access_state=AccessState.OPERATIONAL)

    result = request_table(access_state=slug(AccessState.OPERATIONAL), sort='-hostname')

    assert result['query'] == {'access_state': 'operational', 'sort': '-hostname'}


def test_the_public_value_is_not_the_one_stored(account):
    """
    En la base son `TextChoices` de Django —`ACCESS_LOST`— y en la dirección eso
    se lee como una variable escapada de un archivo de código. La traducción vive
    en la capa de tablas y no en los modelos: cambiarlos obligaría a migrar diez
    tablas para arreglar un detalle de presentación.
    """
    assert slug(AccessState.ACCESS_LOST) == 'access-lost'

    create_domain(account, hostname='caido.test', access_state=AccessState.ACCESS_LOST)

    assert request_table(access_state='access-lost')['count'] == 1

    # El valor de base ya no entra por la dirección: el juego sigue cerrado y
    # sólo se acepta lo que el mapa declara, así que se descarta como cualquier
    # otro valor inventado.
    assert request_table(access_state='ACCESS_LOST')['filters']['access_state'] == ''


def test_a_filter_value_outside_the_choices_is_discarded(account):
    create_domain(account, hostname='alfa.test', access_state=AccessState.OPERATIONAL)

    result = request_table(access_state='DERRETIDO')

    assert result['count'] == 1
    assert result['filters']['access_state'] == ''
    assert 'access_state' not in result['query']


def test_a_multiple_filter_uses_or_inside_the_same_axis(account):
    multiple = TableSpec(
        sortable={'hostname': ('hostname',)},
        default_sort='hostname',
        filters=(
            Filter(
                param='access_state',
                lookup='access_state',
                choices=tuple(AccessState.values),
                multiple=True,
            ),
        ),
    )
    create_domain(account, hostname='operativo.test', access_state=AccessState.OPERATIONAL)
    create_domain(account, hostname='caido.test', access_state=AccessState.ACCESS_LOST)
    create_domain(account, hostname='pendiente.test', access_state=AccessState.AWAITING_ACCESS)
    request = RequestFactory().get(
        '/search-console/domains',
        {'access_state': 'operational,access-lost,valor-inventado'},
    )

    result = table_props(request, Domain.objects.all(), multiple, serialize=_row)

    assert [row['hostname'] for row in result['rows']] == ['caido.test', 'operativo.test']
    assert result['filters']['access_state'] == 'operational,access-lost'
    assert result['query']['access_state'] == 'operational,access-lost'


def test_a_free_text_filter_is_applied_by_the_declared_field(account):
    create_domain(account, hostname='tienda.test')
    create_domain(account, hostname='blog.test')

    result = request_table(q='tien')

    assert [row['hostname'] for row in result['rows']] == ['tienda.test']
    assert result['query'] == {'q': 'tien'}


class TestTwoListsInOnePage:
    """
    El prefijo, que es lo que hace que dos listas convivan.

    Sin él las dos leen `page` y `sort` de la misma querystring: pasar a la
    página 2 de una manda a la otra a su página 2, y ordenar una desordena las
    dos. Con `prefix='revoked_'` esa lista lee y escribe `revoked_page` y
    `revoked_sort`, y arrastra en sus enlaces la posición de la otra, que es la
    otra mitad de convivir: si no la arrastrara, no se pisarían la clave pero se
    la borrarían.
    """

    def test_each_list_reads_its_own_page(self, account):
        for number in range(PER_PAGE + 3):
            create_domain(account, hostname=f'sitio-{number:03d}.test')

        active = request_prefixed('active_', active_page='2', revoked_page='1')
        revoked = request_prefixed('revoked_', active_page='2', revoked_page='1')

        assert active['page'] == 2
        assert revoked['page'] == 1

    def test_each_list_reads_its_own_sort(self, account):
        create_domain(account, hostname='zeta.test')
        create_domain(account, hostname='alfa.test')

        active = request_prefixed('active_', active_sort='-hostname', revoked_sort='hostname')
        revoked = request_prefixed('revoked_', active_sort='-hostname', revoked_sort='hostname')

        assert [row['hostname'] for row in active['rows']] == ['zeta.test', 'alfa.test']
        assert [row['hostname'] for row in revoked['rows']] == ['alfa.test', 'zeta.test']

    def test_a_bare_page_does_not_move_a_prefixed_list(self, account):
        """
        Un `page` sin prefijo en una página con dos listas no se puede atribuir a
        ninguna. Es lo que quedaba de un enlace viejo, y moverse por él sería
        adivinar cuál de las dos se quiso mover.
        """
        for number in range(PER_PAGE + 3):
            create_domain(account, hostname=f'sitio-{number:03d}.test')

        result = request_prefixed('active_', page='2')

        assert result['page'] == 1

    def test_the_querystring_of_one_list_carries_the_position_of_the_other(self, account):
        for number in range(PER_PAGE + 3):
            create_domain(account, hostname=f'sitio-{number:03d}.test')

        active = request_prefixed('active_', active_page='2', revoked_sort='-hostname')

        assert active['query'] == {'active_page': 2, 'revoked_sort': '-hostname'}

    def test_a_list_does_not_carry_its_own_position_twice(self, account):
        create_domain(account, hostname='alfa.test')

        result = request_prefixed('active_', active_sort='-hostname')

        assert result['query'] == {'active_sort': '-hostname'}

    def test_the_position_of_a_neighbour_is_checked_before_it_travels(self, account):
        """
        Es valor ajeno que vuelve a una dirección: este módulo no reemite nada
        que no haya mirado, y lo que no tiene forma de posición no es la posición
        de nadie. Que la clave de orden esté declarada lo comprueba la lista a la
        que pertenece, que es la única que tiene su lista blanca.
        """
        create_domain(account, hostname='alfa.test')

        result = request_prefixed(
            'active_',
            revoked_page='penúltima',
            revoked_sort='<script>alert(1)</script>',
            unrelated='x',
        )

        assert result['query'] == {}

    def test_a_list_without_a_prefix_behaves_exactly_as_before(self, account):
        """
        Las seis tablas de hoy no pasan prefijo y no se enteran de que existe:
        siguen leyendo `page` y `sort`, y siguen sin arrastrar nada ajeno.
        """
        create_domain(account, hostname='zeta.test')
        create_domain(account, hostname='alfa.test')

        result = request_table(sort='-hostname', revoked_page='3')

        assert result['sort'] == '-hostname'
        assert result['query'] == {'sort': '-hostname'}


def test_an_empty_table_declares_one_page_and_no_rows(account):
    """
    `pages` nunca es cero.

    «Página 1 de 0» es una frase sin sentido, y el frontend tendría que
    defenderse de ella en cada vista.
    """
    result = request_table()

    assert result == {
        'rows': [],
        'count': 0,
        'page': 1,
        'pages': 1,
        'per_page': PER_PAGE,
        'sort': 'hostname',
        'sortable': ['access', 'hostname'],
        'filters': {'access_state': '', 'q': ''},
        'path': '/search-console/domains',
        'query': {},
    }
