"""
El listado de dominios (V4): la primera pantalla de una cuenta ya configurada.

Lo que se prueba acá no es que la tabla dibuje filas, sino que la pantalla
permita decidir a qué dominio entrar sin mentir en el camino. Tres invariantes
concentran casi todos los tests:

- **El denominador viaja delante de los conteos** (FR-062, H-07). `indexed` sin
  `known_urls / total_urls` al lado insinúa una cobertura total que no existe, y
  las URLs sin consultar quedan fuera del numerador **y** del denominador (R-B).
- **Cero no es lo mismo que «todavía no»**. Un dominio sin URLs descubiertas
  manda `coverage` nulo y uno sin ningún ciclo manda `known_urls` en cero con su
  total al lado: un reparto en ceros se lee como «ninguna indexada», que es una
  afirmación sobre Google que no podemos hacer.
- **El filtro por estado es una dirección**. `/domains?access_state=access-lost`
  es adonde entraba el aviso `DOMAINS_ACCESS_LOST` de la cuenta (RT-18): si el
  nombre del parámetro cambia, ese enlace deja de aplicar el filtro y nadie se
  entera hasta que alguien lo aprieta.

## Esta pantalla hoy no tiene dirección

La interfaz trabaja contra **un solo sitio**, así que un listado de varios ofrece
una elección que no existe: se le quitó la ruta en `config/urls.py`. La vista y la
pantalla siguen enteras, y estos tests también.

**Quedan salteados, no borrados.** Lo que fijan —el denominador delante de los
conteos, cero contra «todavía no», el filtro como dirección— son reglas de esa
pantalla y siguen siendo ciertas: el día que la cuenta necesite más de un sitio se
repone la ruta y se saca esta marca, y la pantalla vuelve con sus reglas
defendidas. Borrarlos obligaría a volver a descubrirlas.

El aviso de acceso perdido, en cambio, **ya no entra acá**: lleva a la conexión,
que es donde el sitio muestra su estado y ofrece comprobarlo. Eso lo fija
`tests/unit/test_account_notices.py`.
"""

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

from apps.core.tables import slug
from apps.domains.models import AccessState
from apps.sitemaps.models import CoverageState, Url
from tests.factories import create_account, create_credential, create_domain

pytestmark = [
    pytest.mark.django_db,
    pytest.mark.skip(
        reason='El listado de dominios no tiene ruta: la interfaz trabaja contra un solo sitio. '
        'Ver la nota del encabezado y config/urls.py.'
    ),
]


@pytest.fixture
def account(client):
    account = create_account()
    create_credential(account)
    client.force_login(account)
    return account


def props(client, query: str = '') -> dict:
    response = client.get(f'/search-console/domains{query}', HTTP_X_INERTIA='true')
    assert response.status_code == 200
    return response.json()['props']


def row(data: dict, hostname: str) -> dict:
    return next(r for r in data['domains']['rows'] if r['hostname'] == hostname)


def queried_url(domain, loc: str, state: str, *, when=None) -> Url:
    """Una URL con dato de Google. El estado nunca se guarda sin su fecha (R-A)."""
    return Url.objects.create(
        domain=domain,
        loc=loc,
        in_sitemap=True,
        coverage_state=state,
        last_checked_at=when or timezone.now(),
    )


class TestTheTable:
    def test_the_rows_arrive_paginated_by_the_server(self, client, account):
        """
        La tabla es `C-11` y su estado vive en la dirección, no en el navegador.

        Sin `count`, `pages` y `sortable` el frontend no puede dibujar ni el
        contador ni los encabezados ordenables: ofrecería un orden que el
        servidor descarta en silencio.
        """
        create_domain(account)

        data = props(client)['domains']

        assert data['count'] == 1
        assert data['page'] == 1
        assert data['path'] == '/search-console/domains'
        assert set(data['sortable']) == {'access', 'checked', 'coverage', 'domain'}
        assert data['filters'] == {'access_state': ''}

    def test_the_domains_of_another_account_are_not_listed(self, client, account):
        create_domain(account)
        other_account = create_account(email='ajena@ejemplo.test')
        create_domain(other_account, hostname='ajeno.test')

        data = props(client)['domains']

        assert [r['hostname'] for r in data['rows']] == ['ejemplo.test']

    def test_the_unfiltered_total_travels_separately(self, client, account):
        """
        «Esta cuenta no tiene dominios» y «el filtro no encontró ninguno» son dos
        pantallas vacías con dos salidas distintas, y el `count` de la página
        filtrada no alcanza para distinguirlas.
        """
        create_domain(account, access_state=AccessState.OPERATIONAL)

        data = props(client, '?access_state=access-lost')

        assert data['domains']['count'] == 0
        assert data['total'] == 1


class TestTheSorting:
    def test_the_domains_that_ask_for_an_action_go_on_top(self, client, account):
        """
        Quien abre el listado viene a saber qué hacer, no a leer nombres en orden
        alfabético. Lo pendiente y lo roto van primero; lo que ya funciona,
        último.
        """
        create_domain(account, hostname='anda.test', access_state=AccessState.OPERATIONAL)
        create_domain(account, hostname='zeta.test', access_state=AccessState.AWAITING_ACCESS)
        create_domain(account, hostname='beta.test', access_state=AccessState.ACCESS_LOST)

        data = props(client)['domains']

        assert [r['hostname'] for r in data['rows']] == ['zeta.test', 'beta.test', 'anda.test']
        # El orden por omisión no viaja en la querystring: `/domains` tiene que
        # poder enlazarse tal cual y no congelar el criterio de hoy.
        assert 'sort' not in data['query']

    def test_it_can_be_sorted_by_domain(self, client, account):
        create_domain(account, hostname='zeta.test', access_state=AccessState.OPERATIONAL)
        create_domain(account, hostname='alfa.test', access_state=AccessState.AWAITING_ACCESS)

        data = props(client, '?sort=domain')['domains']

        assert [r['hostname'] for r in data['rows']] == ['alfa.test', 'zeta.test']
        assert data['sort'] == 'domain'

    def test_sorting_by_coverage_leaves_on_top_what_we_know_the_least_about(self, client, account):
        """
        Ascendente por URLs con dato: los dominios de los que no sabemos nada
        primero, que es la pregunta con la que se mira esa columna.
        """
        known = create_domain(account, hostname='sabido.test')
        create_domain(account, hostname='ignoto.test')
        queried_url(known, 'https://sabido.test/uno', CoverageState.INDEXED)

        data = props(client, '?sort=coverage')['domains']

        assert [r['hostname'] for r in data['rows']] == ['ignoto.test', 'sabido.test']

    def test_an_undeclared_sort_falls_back_to_the_default_one(self, client, account):
        """
        Una clave que no está en la lista blanca casi nunca es un ataque: es un
        enlace guardado de otra versión de la vista. Se descarta y manda el
        orden por omisión, pero nunca llega al `order_by`.
        """
        create_domain(account)

        data = props(client, '?sort=account__password')['domains']

        # Queda el orden por omisión y no una cadena vacía: la tabla dibuja la
        # flecha sobre la columna que efectivamente ordena, y sin ese dato la
        # pantalla mostraría filas ordenadas sin decir por cuál.
        assert data['sort'] == 'access'
        assert data['count'] == 1


class TestTheStateFilter:
    def test_the_account_notice_enters_with_the_filter_already_applied(self, client, account):
        """
        `/domains?access_state=access-lost` es la dirección del aviso
        `DOMAINS_ACCESS_LOST` (RT-18). Tiene que llegar filtrada y decir que lo
        está: un filtro aplicado que no se ve es una tabla que parece incompleta.
        """
        create_domain(account, hostname='perdido.test', access_state=AccessState.ACCESS_LOST)
        create_domain(account, hostname='anda.test', access_state=AccessState.OPERATIONAL)

        data = props(client, '?access_state=access-lost')['domains']

        assert [r['hostname'] for r in data['rows']] == ['perdido.test']
        # Vuelve en su forma pública, que es la que el control compara contra la
        # opción que dibuja. El valor de base vive sólo en la base.
        assert data['filters']['access_state'] == 'access-lost'
        # Viaja en la querystring saneada, así que cada enlace de la tabla
        # —otra página, otro orden— lo conserva.
        assert data['query']['access_state'] == 'access-lost'

    def test_a_made_up_state_neither_filters_nor_breaks(self, client, account):
        create_domain(account)

        data = props(client, '?access_state=NO_EXISTE')['domains']

        assert data['count'] == 1
        assert data['filters']['access_state'] == ''

    def test_the_five_states_travel_to_build_the_filter(self, client, account):
        """
        Y viajan en su forma pública, que es la que el control pone en la
        dirección. Publicar el valor de base produce enlaces que el filtro
        descarta en silencio: la lista vuelve completa y nada falla.
        """
        create_domain(account)

        values = [e['value'] for e in props(client)['access_states']]

        assert values == [slug(value) for value in AccessState.values]


class TestTheCoverage:
    def test_the_counts_arrive_with_their_denominator(self, client, account):
        """
        H-07: la columna que permite decidir a qué dominio entrar.

        `known_urls` sobre `total_urls` es lo que hace honesto a `indexed`. Sin
        ese par, «1 indexada» sobre un sitio del que se consultó la mitad
        insinúa una cobertura que no existe (R-B, FR-062).
        """
        domain = create_domain(account)
        queried_url(domain, 'https://ejemplo.test/uno', CoverageState.INDEXED)
        queried_url(domain, 'https://ejemplo.test/dos', CoverageState.CRAWLED_NOT_INDEXED)
        Url.objects.create(domain=domain, loc='https://ejemplo.test/tres', in_sitemap=True)
        Url.objects.create(domain=domain, loc='https://ejemplo.test/cuatro', in_sitemap=True)

        coverage = row(props(client), 'ejemplo.test')['coverage']

        assert coverage['total_urls'] == 4
        # Las dos sin consultar quedan fuera del numerador: «todavía no
        # preguntamos» no es «no está indexada».
        assert coverage['known_urls'] == 2
        assert coverage['indexed'] == 1
        assert coverage['not_indexed'] == 1
        assert coverage['last_cycle_at'] is not None

    def test_without_discovered_urls_it_does_not_send_zeros(self, client, account):
        """
        Un cero en la columna de cobertura se lee como «ninguna indexada», que es
        una afirmación sobre Google. Lo que pasa de verdad es que todavía no
        leímos ningún sitemap, y eso se dice con un resumen nulo.
        """
        create_domain(account)

        assert row(props(client), 'ejemplo.test')['coverage'] is None

    def test_without_any_cycle_yet_the_total_still_travels(self, client, account):
        """
        «Sin datos todavía · 4 URLs descubiertas» necesita las dos mitades: el
        total dice que el dominio no está vacío y el cero dice que no preguntamos
        nada. Sin fecha de ciclo no hay reparto que mostrar (R-A).
        """
        domain = create_domain(account)
        Url.objects.create(domain=domain, loc='https://ejemplo.test/uno', in_sitemap=True)

        coverage = row(props(client), 'ejemplo.test')['coverage']

        assert coverage['total_urls'] == 1
        assert coverage['known_urls'] == 0
        assert coverage['indexed'] == 0
        assert coverage['last_cycle_at'] is None

    def test_the_coverage_of_one_domain_does_not_count_the_urls_of_another(self, client, account):
        """
        Los cuatro conteos salen de agregados sobre el mismo join. Si el filtro
        por dominio se escapa, cada fila muestra el sitio entero de la cuenta.
        """
        one = create_domain(account, hostname='uno.test')
        two = create_domain(account, hostname='dos.test')
        queried_url(one, 'https://uno.test/a', CoverageState.INDEXED)
        queried_url(two, 'https://dos.test/a', CoverageState.INDEXED)
        queried_url(two, 'https://dos.test/b', CoverageState.INDEXED)

        data = props(client)

        assert row(data, 'uno.test')['coverage']['total_urls'] == 1
        assert row(data, 'dos.test')['coverage']['total_urls'] == 2

    def test_the_urls_outside_the_sitemap_do_not_enter_the_total(self, client, account):
        """
        El total describe lo que monitoreamos, y lo que se monitorea es lo que el
        sitio declara. Una URL que salió del sitemap conserva su historial pero
        ya no es parte del denominador.
        """
        domain = create_domain(account)
        Url.objects.create(domain=domain, loc='https://ejemplo.test/vieja', in_sitemap=False)
        Url.objects.create(domain=domain, loc='https://ejemplo.test/nueva', in_sitemap=True)

        assert row(props(client), 'ejemplo.test')['coverage']['total_urls'] == 1

    def test_adding_domains_does_not_add_queries(self, client, account):
        """
        El resumen sale de la misma consulta que trae los dominios.

        Con una llamada a `coverage_summary()` por fila, una página de cincuenta
        dominios serían cien viajes a la base para dibujar una pantalla. El
        conteo tiene que ser el mismo con uno que con seis.
        """
        create_domain(account, hostname='uno.test')
        # Una llamada de precalentamiento antes de medir: la primera petición de
        # la sesión arrastra consultas propias —sesión, cuenta— que no dependen
        # de cuántos dominios hay y ensucian la comparación.
        props(client)

        with CaptureQueriesContext(connection) as with_one:
            props(client)

        for number in range(2, 7):
            create_domain(account, hostname=f'dominio{number}.test')
        with CaptureQueriesContext(connection) as with_six:
            props(client)

        assert len(with_six) == len(with_one)


class TestTheCreationConfirmation:
    def test_the_domain_just_created_is_named_in_the_list(self, client, account):
        """
        Al volver del alta, el objeto ya está visible y el mensaje dice qué falta
        ahora (RT-11): el dominio quedó dado de alta y todavía no sirve hasta que
        se lo autorice en Search Console.
        """
        create_domain(account, access_state=AccessState.AWAITING_ACCESS)

        assert props(client, '?created=ejemplo.test')['created'] == 'ejemplo.test'

    def test_a_made_up_creation_is_not_repeated_on_screen(self, client, account):
        """
        El parámetro se rinde como una confirmación nuestra. Sin comprobarlo
        contra la base, un enlace armado a mano pondría la frase que quisiera en
        boca del producto.
        """
        create_domain(account)

        assert props(client, '?created=no-es-mio.test')['created'] is None

    def test_the_creation_of_another_account_is_not_repeated_either(self, client, account):
        create_domain(account)
        other_account = create_account(email='ajena@ejemplo.test')
        create_domain(other_account, hostname='ajeno.test')

        assert props(client, '?created=ajeno.test')['created'] is None


class TestTheAccountState:
    def test_with_a_broken_credential_the_row_keeps_its_state(self, client):
        """
        RT-18: el estado del dominio describe su propiedad en Search Console; el
        de la cuenta, si tenemos con qué consultarla. La fila no se reescribe —
        el dominio sigue autorizado— pero la pantalla recibe las dos mitades
        para no presentarlo como operativo.
        """
        account = create_account(email='rota@ejemplo.test')
        create_credential(account, status='INVALID')
        client.force_login(account)
        create_domain(account, access_state=AccessState.OPERATIONAL)

        data = props(client)

        assert row(data, 'ejemplo.test')['access_state'] == AccessState.OPERATIONAL
        assert data['account']['can_operate'] is False

    def test_without_a_session_it_sends_to_the_login(self, client):
        assert client.get('/search-console/domains').headers['Location'].startswith('/login')
