"""
El trabajo que corre solo.

Dos cosas se verifican acá, y las dos existen porque su ausencia no se nota:

**Que lo que contesta «encolado» encole de verdad.** La API respondía `202`
—«lo tomé, va en camino»— y mientras tanto recorría dos mil URLs dentro de la
misma petición. Nada fallaba: el trabajo se hacía y la respuesta llegaba tarde.
Con un sitio grande, esa respuesta no llega nunca y la afirmación queda además
de lenta, falsa.

**Que el acceso se revalide solo.** Perder el acceso a una propiedad no produce
ningún error: Search Console deja de contestar y el dominio se queda con el
último dato que tuvo. Sin una tarea que pregunte, la pérdida se descubre semanas
después, cuando alguien nota que la cobertura no se mueve.
"""

import pytest

from apps.credentials.models import CredentialStatus
from apps.domains.models import AccessState, Domain
from apps.gsc.errors import GoogleCallError, GoogleErrorCode
from apps.jobs.models import Batch, BatchState
from apps.notifications.models import Notification, NotificationKind
from tests.doubles import FakeClient
from tests.factories import create_account, create_credential, create_domain

pytestmark = pytest.mark.django_db


@pytest.fixture
def account():
    account = create_account()
    create_credential(account, status=CredentialStatus.VERIFIED)
    return account


@pytest.fixture
def domain(account):
    return create_domain(account, access_state=AccessState.OPERATIONAL)


class TestQueueingReallyQueues:
    def test_queueing_an_inspection_does_not_inspect_anything_yet(self, domain, monkeypatch):
        """
        El lote nace en cola y la petición vuelve sin haber llamado a Google.

        Es lo que separa «encolado» de «hecho»: si la petición ya hubiera
        consultado, el estado del lote al volver sería terminal.
        """
        from apps.coverage.services import queue_inspection

        enqueued = []
        monkeypatch.setattr(
            'apps.coverage.tasks.inspect_one_domain.delay',
            lambda *args, **kwargs: enqueued.append(kwargs),
        )

        batch = queue_inspection(domain, limit=50)

        assert batch.state == BatchState.QUEUED
        assert batch.started_at is None
        assert enqueued[0]['batch_id'] == str(batch.id)

    def test_queueing_a_sync_does_not_read_any_sitemap_yet(self, domain, monkeypatch):
        from apps.sitemaps.services import queue_sync

        enqueued = []
        monkeypatch.setattr(
            'apps.sitemaps.tasks.sync_one_domain.delay',
            lambda *args, **kwargs: enqueued.append(kwargs),
        )

        batch = queue_sync(domain)

        assert batch.state == BatchState.QUEUED
        assert batch.started_at is None
        assert enqueued[0]['batch_id'] == str(batch.id)

    def test_the_task_takes_the_batch_that_was_queued(self, domain, monkeypatch):
        """
        Un solo lote, no dos.

        La petición crea el lote para poder devolverlo y la tarea lo toma. Si la
        tarea creara el suyo, quien mira la pantalla estaría sondeando un lote
        que nadie va a ejecutar, para siempre en «en cola».
        """
        from apps.coverage.services import queue_inspection
        from apps.coverage.tasks import inspect_one_domain

        monkeypatch.setattr('apps.coverage.tasks.inspect_one_domain.delay', lambda *a, **k: None)
        batch = queue_inspection(domain, limit=10)

        inspect_one_domain(str(domain.id), limit=10, batch_id=str(batch.id))

        batch.refresh_from_db()
        assert Batch.objects.filter(domain=domain).count() == 1
        assert batch.is_terminal

    def test_a_batch_that_is_no_longer_queued_is_not_run_twice(self, domain, monkeypatch):
        """
        Celery reconoce las tareas al terminar, así que una que muera a mitad
        vuelve a la cola. Sin la condición de que el lote siga encolado, el
        segundo intento gastaría el cupo de nuevo sobre trabajo ya hecho.
        """
        from apps.coverage.services import queue_inspection
        from apps.coverage.tasks import inspect_one_domain

        monkeypatch.setattr('apps.coverage.tasks.inspect_one_domain.delay', lambda *a, **k: None)
        batch = queue_inspection(domain, limit=10)
        inspect_one_domain(str(domain.id), limit=10, batch_id=str(batch.id))

        again = inspect_one_domain(str(domain.id), limit=10, batch_id=str(batch.id))

        assert again == {'skipped': 'el lote ya no está en cola'}
        assert Batch.objects.filter(domain=domain).count() == 1

    def test_a_domain_that_lost_access_closes_the_batch_instead_of_leaving_it_running(
        self, account, monkeypatch
    ):
        """
        Sin esto, el lote se quedaría en «en cola» para siempre y la pantalla
        consultando cada cinco segundos un estado que no va a cambiar (RT-13).
        """
        from apps.coverage.services import queue_inspection
        from apps.coverage.tasks import inspect_one_domain

        domain = create_domain(account, hostname='caido.test', access_state=AccessState.OPERATIONAL)
        monkeypatch.setattr('apps.coverage.tasks.inspect_one_domain.delay', lambda *a, **k: None)
        batch = queue_inspection(domain, limit=10)

        # El acceso se pierde entre el encolado y la ejecución.
        Domain.objects.filter(id=domain.id).update(access_state=AccessState.ACCESS_LOST)

        inspect_one_domain(str(domain.id), limit=10, batch_id=str(batch.id))
        batch.refresh_from_db()

        assert batch.state == BatchState.FAILED
        assert batch.finished_at is not None


class TestAccessRevalidation:
    def test_it_only_asks_about_the_domains_where_the_answer_changes_something(
        self, account, monkeypatch
    ):
        """
        Revocado y suspendido son decisiones administrativas: la máquina de
        estados ni siquiera admite volver a operativo desde ahí, así que
        comprobarlos gastaría una llamada para confirmar lo que ya sabemos.
        """
        from apps.domains.tasks import revalidate_all_access

        monkeypatch.setattr('apps.domains.tasks.revalidate_one_access.delay', lambda *a: None)

        create_domain(account, hostname='opera.test', access_state=AccessState.OPERATIONAL)
        create_domain(account, hostname='espera.test', access_state=AccessState.AWAITING_ACCESS)
        create_domain(account, hostname='revocado.test', access_state=AccessState.ACCESS_REVOKED)
        create_domain(account, hostname='suspendido.test', access_state=AccessState.SUSPENDED)

        assert revalidate_all_access() == {'queued': 2}

    def test_losing_the_access_leaves_its_notification(self, domain, monkeypatch):
        """El camino entero: la tarea pregunta, Google dice que no, y queda el aviso."""
        from apps.domains.services import check_access
        from apps.domains.tasks import revalidate_one_access

        denied = GoogleCallError(
            code=GoogleErrorCode.PERMISSION_DENIED,
            detail='La cuenta de servicio no figura como propietaria.',
        )
        monkeypatch.setattr(
            'apps.domains.tasks.check_access',
            lambda target: check_access(target, client=FakeClient(get_site=denied)),
        )

        result = revalidate_one_access(str(domain.id))
        domain.refresh_from_db()

        assert domain.access_state == AccessState.ACCESS_LOST
        assert result['before'] == AccessState.OPERATIONAL
        assert Notification.objects.filter(
            account=domain.account, kind=NotificationKind.ACCESS_LOST
        ).exists()

    def test_a_domain_that_no_longer_exists_does_not_break_the_task(self, account):
        from apps.domains.tasks import revalidate_one_access

        gone = create_domain(account, hostname='borrado.test')
        domain_id = str(gone.id)
        gone.delete()

        assert revalidate_one_access(domain_id) == {'skipped': 'el dominio ya no existe'}

    def test_the_daily_schedule_includes_it(self):
        """
        La tarea sin su entrada en el programador es una tarea que nadie corre.
        Y va antes que la sincronización: si el acceso se perdió, lo de después
        no tiene nada que hacer sobre ese dominio.
        """
        from config.celery import app

        schedule = app.conf.beat_schedule

        assert schedule['revalidar-acceso']['task'] == 'apps.domains.tasks.revalidate_all_access'
        # `crontab.hour` es un conjunto de horas, no un número: se comparan sus
        # mínimos, que es la hora a la que cada una arranca.
        assert min(schedule['revalidar-acceso']['schedule'].hour) < min(
            schedule['sincronizar-sitemaps']['schedule'].hour
        )
