"""
La exportación de cobertura, en sus dos comportamientos (FR-058).

Por debajo del umbral el archivo sale en la misma respuesta. Por encima **no
puede**: un sitio de cientos de miles de URLs no entra en una respuesta HTTP, y
transmitirlo igual deja a quien lo pidió mirando una descarga que no arranca. Lo
que se verifica acá es que el corte exista de verdad —que el grande se encole y
lo diga— y que el archivo encolado se lleve exactamente el recorte con el que se
pidió: un CSV con otro filtro que el que se estaba mirando es peor que no
exportar nada, porque nadie lo vuelve a revisar.

El umbral se baja a mano en cada prueba. Fabricar cinco mil URLs para cruzarlo
mediría la velocidad de la base y no la decisión, que es lo único que está en
juego.
"""

import csv
import io
from datetime import timedelta

import pytest
from django.conf import settings as django_settings
from django.utils import timezone
from rest_framework.test import APIClient

from apps.accounts.models import ApiKey
from apps.core.catalog import text
from apps.coverage import api as coverage_api
from apps.coverage.models import CoverageExport
from apps.coverage.services import export_path, purge_expired_exports
from apps.jobs.models import Batch, BatchKind, BatchState
from apps.notifications.models import Notification, NotificationKind
from apps.sitemaps.models import CoverageState, Url
from tests.factories import create_account, create_credential, create_domain

pytestmark = pytest.mark.django_db


@pytest.fixture(autouse=True)
def exports_in_tmp(monkeypatch, tmp_path):
    """
    Los archivos caen en el directorio de la prueba, no en el del proyecto.

    Se cambia con `monkeypatch` y no con la fixture `settings`: ésa instala un
    envoltorio de configuración, y si la envoltura queda activa la primera vez
    que Celery lee su propia configuración, el trabajo deja de ejecutarse en el
    momento y la suite intenta hablar con una cola que en las pruebas no existe.
    """
    root = tmp_path / 'exports'
    monkeypatch.setattr(django_settings, 'EXPORT_ROOT', root)
    monkeypatch.setattr(django_settings, 'EXPORT_RETENTION_DAYS', 7)
    return root


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


@pytest.fixture
def domain(account):
    domain = create_domain(account)
    indexed = Url.objects.create(domain=domain, loc='https://ejemplo.test/uno', in_sitemap=True)
    indexed.coverage_state = CoverageState.INDEXED
    indexed.last_checked_at = timezone.now()
    indexed.save()

    Url.objects.create(domain=domain, loc='https://ejemplo.test/dos', in_sitemap=True)
    return domain


@pytest.fixture
def api(account):
    client = APIClient()
    _, plain_text = ApiKey.issue(account=account, name='pipeline')
    client.credentials(HTTP_AUTHORIZATION=f'Api-Key {plain_text}')
    return client


@pytest.fixture
def small(monkeypatch):
    """Baja el umbral para que cualquier recorte ya cuente como grande."""
    monkeypatch.setattr(coverage_api, 'EXPORT_THRESHOLD', 0)


@pytest.fixture
def worker(monkeypatch):
    """
    Ejecuta el trabajo encolado en el momento, como haría un trabajador.

    La suite no levanta Redis ni un trabajador —probar la cola no es probar el
    trabajo—, así que lo que se sustituye es el encolado. Que la petición
    **encole** y no ejecute se verifica aparte, mirando el estado del lote.
    """
    from apps.coverage.tasks import build_export

    monkeypatch.setattr('apps.coverage.tasks.build_export.delay', build_export)


@pytest.fixture
def queue(monkeypatch) -> list:
    """Retiene lo encolado sin ejecutarlo, para poder mirar el lote recién creado."""
    enqueued: list = []
    monkeypatch.setattr('apps.coverage.tasks.build_export.delay', enqueued.append)
    return enqueued


def rows_of(export: CoverageExport) -> list[list[str]]:
    content = export_path(export).read_text(encoding='utf-8')
    return [row for row in csv.reader(io.StringIO(content)) if row]


def test_under_the_threshold_the_file_still_comes_in_the_same_response(api, domain):
    response = api.get(f'/api/v1/domains/{domain.id}/coverage/export')

    assert response.status_code == 200
    assert 'attachment' in response['Content-Disposition']
    assert not Batch.objects.filter(kind=BatchKind.COVERAGE_EXPORT).exists()


def test_over_the_threshold_the_export_is_queued_and_says_so(api, domain, small, queue):
    """
    `202` y no `200`: lo que ocurrió es que se encoló, no que terminó.

    El lote vuelve en `QUEUED` y sin archivo: es lo que separa «encolado» de
    «hecho». Si la petición ya hubiera escrito el CSV, el estado al volver sería
    terminal y la respuesta estaría describiendo otra cosa.
    """
    response = api.get(f'/api/v1/domains/{domain.id}/coverage/export')

    assert response.status_code == 202
    assert response.json()['kind'] == BatchKind.COVERAGE_EXPORT
    assert response.json()['state'] == BatchState.QUEUED

    export = CoverageExport.objects.get()
    assert queue == [str(export.id)]
    assert not export_path(export).exists()


def test_the_worker_writes_the_file_and_closes_the_batch(api, domain, small, worker):
    api.get(f'/api/v1/domains/{domain.id}/coverage/export')

    export = CoverageExport.objects.get()
    assert export.batch.state == BatchState.COMPLETED
    assert export.row_count == 2
    # El avance se anota mientras se escribe, no sólo al cerrar: una barra que
    # salta de cero al total no distingue un trabajo que avanza de uno colgado.
    assert export.batch.processed_items == 2
    assert export_path(export).exists()


def test_a_batch_that_is_no_longer_queued_is_not_written_twice(api, domain, small, queue):
    """
    Celery reconoce las tareas al terminar, así que una que muere vuelve.

    Sin la condición de seguir en cola, el segundo intento reescribiría un
    archivo que alguien ya puede estar bajando.
    """
    from apps.coverage.tasks import build_export

    api.get(f'/api/v1/domains/{domain.id}/coverage/export')
    export = CoverageExport.objects.get()
    Batch.objects.filter(id=export.batch_id).update(state=BatchState.RUNNING)

    assert build_export(str(export.id)) == {'skipped': 'el lote ya no está en cola'}
    assert not export_path(export).exists()


def test_the_queued_file_carries_the_same_filter_as_the_screen(api, domain, small, worker):
    response = api.get(f'/api/v1/domains/{domain.id}/coverage/export?group=with-data')

    assert response.status_code == 202
    export = CoverageExport.objects.get()

    # El recorte queda guardado además de aplicado: sin él, media hora después
    # nadie puede saber qué contiene el archivo que está bajando.
    assert export.filters == {'group': 'with-data'}
    assert export.row_count == 1

    rows = rows_of(export)
    assert rows[0][0] == 'url'
    assert [row[0] for row in rows[1:]] == ['https://ejemplo.test/uno']


def test_the_file_keeps_the_date_and_the_time_zone_of_every_row(api, domain, small, worker):
    api.get(f'/api/v1/domains/{domain.id}/coverage/export?group=with-data')

    row = rows_of(CoverageExport.objects.get())[1]

    assert row[1] == CoverageState.INDEXED
    # La etiqueta legible sale del catálogo, en el idioma de la cuenta; el
    # código de al lado no cambia con el idioma y es el que sirve para filtrar.
    assert row[2] == text('coverage.INDEXED.label', domain.account.language)
    assert row[3]
    assert row[8] == django_settings.DISPLAY_TIMEZONE


def test_the_finished_export_leaves_a_notice_that_points_at_its_batch(api, domain, small, worker):
    api.get(f'/api/v1/domains/{domain.id}/coverage/export')

    export = CoverageExport.objects.get()
    notice = Notification.objects.get(kind=NotificationKind.EXPORT_READY)

    assert notice.action_path == f'/search-console/batches/{export.batch_id}'
    assert notice.text_key == 'EXPORT_READY'
    assert notice.params['rows'] == export.row_count


def test_the_notice_is_given_even_with_the_domain_notices_turned_off(api, domain, small, worker):
    """
    El interruptor del dominio apaga el trabajo automático, no lo que se pidió.

    Los otros avisos informan trabajo que la plataforma hizo sola. Éste contesta
    algo que la persona pidió hace un rato, y apagarlo dejaría el archivo listo
    sin que nadie se entere.
    """
    domain.notifications_enabled = False
    domain.save()

    api.get(f'/api/v1/domains/{domain.id}/coverage/export')

    assert Notification.objects.filter(kind=NotificationKind.EXPORT_READY).exists()


def test_the_export_is_offered_even_without_access_to_the_property(api, domain, small, worker):
    """
    Exportar no le pregunta nada a Google: lee lo que ya está guardado.

    Un dominio que perdió el acceso conserva su historial, y negarle el archivo
    justo cuando alguien necesita revisar qué pasó sería quitarle el dato por un
    motivo que no tiene que ver con el dato.
    """
    from apps.domains.models import AccessState

    domain.access_state = AccessState.ACCESS_LOST
    domain.save()

    response = api.get(f'/api/v1/domains/{domain.id}/coverage/export')

    assert response.status_code == 202
    assert CoverageExport.objects.get().batch.state == BatchState.COMPLETED


# --- La descarga ------------------------------------------------------------


def test_the_file_is_served_to_its_owner(client, api, account, domain, small, worker):
    api.get(f'/api/v1/domains/{domain.id}/coverage/export')
    export = CoverageExport.objects.get()

    client.force_login(account)
    response = client.get(f'/search-console/exports/{export.id}')

    assert response.status_code == 200
    assert 'attachment' in response['Content-Disposition']
    assert b'https://ejemplo.test/uno' in b''.join(response.streaming_content)


def test_the_file_is_shared_with_another_company_profile(client, api, domain, small, worker):
    api.get(f'/api/v1/domains/{domain.id}/coverage/export')
    export = CoverageExport.objects.get()

    client.force_login(create_account(email='otra@ejemplo.test'))
    response = client.get(f'/search-console/exports/{export.id}')

    assert response.status_code == 200


def test_an_expired_file_is_not_served_and_the_purge_deletes_it(
    client, api, account, domain, small, worker
):
    api.get(f'/api/v1/domains/{domain.id}/coverage/export')
    export = CoverageExport.objects.get()
    export.expires_at = timezone.now() - timedelta(days=1)
    export.save(update_fields=['expires_at'])

    client.force_login(account)
    assert client.get(f'/search-console/exports/{export.id}').status_code == 404

    assert purge_expired_exports() == 1
    assert not export_path(export).exists()
    # La fila se conserva: es la constancia de qué se pidió, y permite que la
    # pantalla diga «venció» en vez de dar un 404 sin explicación.
    assert CoverageExport.objects.filter(id=export.id).exists()


# --- La pantalla ------------------------------------------------------------


def test_the_endpoint_still_queues_the_export_with_the_filter_it_receives(
    client, account, domain, worker
):
    """
    El endpoint sigue aceptando filtros, aunque la pantalla ya no se los mande.

    Este test se llamaba «...with the filter it is showing» y describía a la
    pantalla. Dejó de ser cierto cuando la tabla pasó a filtrar en el navegador:
    el recorte que alguien mira ya no existe del lado del servidor. Lo que sigue
    siendo cierto —y lo que se fija acá— es que el endpoint honra los filtros
    que recibe, que es de lo que depende la API v1.
    """
    client.force_login(account)

    response = client.post(
        f'/search-console/domains/{domain.id}/coverage/export', {'group': 'with-data'}
    )

    assert response.status_code == 302
    assert response['Location'] == f'/search-console/domains/{domain.id}/coverage?group=with-data'
    assert CoverageExport.objects.get().filters == {'group': 'with-data'}


def test_the_screen_exports_the_whole_domain(client, account, domain, worker):
    """
    Sin filtros en el cuerpo, la exportación es del dominio entero.

    Es lo que manda la pantalla desde que la tabla filtra en el navegador, y es
    lo que el diálogo de confirmación promete: si acá quedara un filtro colado,
    el archivo no sería el que se anunció.
    """
    client.force_login(account)

    response = client.post(f'/search-console/domains/{domain.id}/coverage/export', {})

    assert response.status_code == 302
    assert response['Location'] == f'/search-console/domains/{domain.id}/coverage'
    assert CoverageExport.objects.get().filters == {}


def test_the_screen_shows_the_last_export_with_its_batch(client, account, domain, worker):
    client.force_login(account)
    client.post(f'/search-console/domains/{domain.id}/coverage/export', {})

    props = client.get(
        f'/search-console/domains/{domain.id}/coverage', HTTP_X_INERTIA='true'
    ).json()['props']
    latest = props['export']['latest']

    assert latest['batch']['state'] == BatchState.COMPLETED
    assert latest['row_count'] == 2
    assert latest['download_path'] == f'/search-console/exports/{latest["id"]}'
    assert latest['expired'] is False


def test_an_unknown_state_is_never_written_with_a_date(api, domain, small, worker):
    """Una URL sin consultar sale sin fecha, no con la de hoy (R-A, R-B)."""
    api.get(f'/api/v1/domains/{domain.id}/coverage/export')

    rows = rows_of(CoverageExport.objects.get())[1:]
    without_data = next(row for row in rows if row[0].endswith('/dos'))

    assert without_data[1] == CoverageState.UNKNOWN
    assert without_data[3] == ''
