"""
Avisos dentro de la aplicación.

Reemplazan al correo, que no existe. Lo que se fija acá es lo único que hace que
la campana sirva: **el mismo hecho no se avisa dos veces**. Un número que crece
solo, con repeticiones de lo mismo, se ignora en tres días, y a partir de ahí el
producto pierde su único canal para decir que algo se rompió.

Lo segundo es que el interruptor del dominio mande de verdad. Estuvo en la ficha
mucho tiempo prometiendo avisos que nadie generaba; ahora que existen, apagarlo
tiene que callarlos.
"""

import pytest
from django.utils import timezone

from apps.core.tables import slug
from apps.credentials.models import CredentialStatus
from apps.domains.models import AccessState
from apps.jobs.models import Batch, BatchKind, BatchOrigin, BatchState
from apps.notifications import services
from apps.notifications.models import Notification, NotificationKind, NotificationLevel
from apps.notifications.views import NOTIFICATIONS
from tests.factories import create_account, create_credential, create_domain

pytestmark = pytest.mark.django_db


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


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


def make_batch(domain, **extra) -> Batch:
    defaults = {
        'domain': domain,
        'kind': BatchKind.URL_INSPECTION,
        'origin': BatchOrigin.SCHEDULED,
        'state': BatchState.COMPLETED,
        'total_items': 100,
        'processed_items': 100,
        'started_at': timezone.now(),
        'finished_at': timezone.now(),
    }
    return Batch.objects.create(**(defaults | extra))


class TestDeduplication:
    def test_the_same_fact_is_only_notified_once(self, account, domain):
        """
        Es la regla que sostiene todo lo demás. Una tarea reintentada, o dos
        trabajadores sobre el mismo lote, no pueden dejar dos avisos iguales.
        """
        batch = make_batch(domain)

        services.batch_finished(batch)
        services.batch_finished(batch)

        assert Notification.objects.filter(account=account).count() == 1

    def test_two_different_batches_are_two_facts(self, account, domain):
        services.batch_finished(make_batch(domain))
        services.batch_finished(make_batch(domain))

        assert Notification.objects.filter(account=account).count() == 2

    def test_notify_returns_nothing_when_it_was_already_said(self, account):
        first = services.notify(
            account,
            kind=NotificationKind.BATCH_FINISHED,
            text_key='BATCH_COMPLETED',
            dedupe_key='same',
        )
        second = services.notify(
            account,
            kind=NotificationKind.BATCH_FINISHED,
            text_key='BATCH_COMPLETED',
            dedupe_key='same',
        )

        assert first is not None
        assert second is None


class TestWhatGetsNotified:
    def test_a_failed_batch_asks_for_action(self, account, domain):
        services.batch_finished(make_batch(domain, state=BatchState.FAILED, processed_items=0))

        notification = Notification.objects.get(account=account)

        assert notification.kind == NotificationKind.BATCH_FAILED
        assert notification.level == NotificationLevel.ACTION

    def test_a_partial_batch_is_not_a_problem(self, account, domain):
        """
        Un lote cortado por cupo no falló: lo que quedó entra en el ciclo
        siguiente sin que nadie haga nada (R-C). Pedirle una acción a la persona
        sería pedirle algo que no existe.
        """
        services.batch_finished(
            make_batch(domain, state=BatchState.PARTIAL, total_items=2000, processed_items=412)
        )

        notification = Notification.objects.get(account=account)

        assert notification.level == NotificationLevel.INFO
        # Lo que queda guardado es la cuenta, no la oración: el número entra en
        # la frase recién cuando la pantalla la arma, en el idioma de quien lee.
        assert notification.text_key == 'BATCH_PARTIAL'
        assert notification.params['pending'] == 1588

    def test_an_empty_batch_says_nothing(self, account, domain):
        """Un lote que no tocó nada no es una novedad."""
        services.batch_finished(make_batch(domain, total_items=0, processed_items=0))

        assert Notification.objects.filter(account=account).count() == 0

    def test_turning_the_switch_off_silences_the_domain(self, account, domain):
        """Es lo que esa perilla dice que hace, y durante mucho tiempo no la leyó nadie."""
        domain.notifications_enabled = False
        domain.save(update_fields=['notifications_enabled'])

        services.batch_finished(make_batch(domain))

        assert Notification.objects.filter(account=account).count() == 0

    def test_losing_access_asks_for_action_and_points_at_the_domain(self, account, domain):
        services.access_lost(domain)

        notification = Notification.objects.get(account=account)

        assert notification.level == NotificationLevel.ACTION
        assert notification.action_path == f'/search-console/domains/{domain.id}'

    def test_a_broken_credential_is_an_account_level_notice(self, account, domain):
        """Sin credencial no se puede consultar ninguna propiedad, así que no cuelga de ninguna."""
        credential = account.credentials.first()

        services.credential_invalid(account, credential)

        notification = Notification.objects.get(account=account)

        assert notification.domain is None
        assert notification.action_path == '/search-console/settings'

    def test_a_coverage_drop_reports_what_google_said(self, account, domain):
        batch = make_batch(domain)

        services.coverage_drop(domain, count=12, batch=batch)

        notification = Notification.objects.get(
            account=account, kind=NotificationKind.COVERAGE_DROP
        )

        assert notification.text_key == 'COVERAGE_DROP'
        assert notification.params['count'] == 12

    def test_an_inspection_that_loses_indexing_leaves_its_notification(self, account, domain):
        """
        El enganche que faltaba.

        `coverage_drop()` estuvo escrito un tiempo sin que nadie lo llamara, así
        que el aviso más valioso de los cinco no se generaba nunca y no había
        forma de notarlo: ninguna prueba fallaba y la campana simplemente no
        sonaba. Este test cubre el camino entero, desde la respuesta de Google
        hasta el aviso.
        """
        from apps.coverage.services import inspect_domain
        from apps.sitemaps.models import CoverageState, Url
        from tests.doubles import FakeClient, response

        # La URL estaba indexada; Google ahora contesta que la rastreó y no la
        # incluyó. Ése es exactamente el cambio que hay que avisar.
        url = Url.objects.create(
            domain=domain,
            loc='https://ejemplo.test/nota',
            in_sitemap=True,
            coverage_state=CoverageState.INDEXED,
            last_checked_at=timezone.now(),
        )

        inspect_domain(
            domain,
            urls=[url],
            client=FakeClient(inspect_url=response('url_inspection_rastreada_sin_indexar')),
        )

        notification = Notification.objects.get(
            account=account, kind=NotificationKind.COVERAGE_DROP
        )

        assert notification.level == NotificationLevel.ACTION
        assert notification.params['count'] == 1

    def test_the_notice_leads_to_the_batch_and_not_to_the_coverage_table(self, account, domain):
        """
        A dónde lleva el aviso es lo que decide si se puede atender.

        Llevaba a la tabla de cobertura, que muestra **el último** estado de cada
        URL: para cuando alguien abre el aviso, el recorrido siguiente ya pisó
        ese estado y la dirección que cayó figura indexada otra vez. El aviso
        aterrizaba entre miles de filas correctas sin decir cuál mirar.

        El lote no se mueve: guarda lo que él vio, el día que lo vio.
        """
        batch = make_batch(domain)

        services.coverage_drop(domain, count=12, batch=batch)

        notification = Notification.objects.get(
            account=account, kind=NotificationKind.COVERAGE_DROP
        )

        assert notification.action_path == f'/search-console/batches/{batch.id}'

    def test_the_batch_records_which_urls_fell_and_not_just_how_many(self, account, domain):
        from apps.coverage.services import inspect_domain
        from apps.sitemaps.models import CoverageState, Url
        from tests.doubles import FakeClient, response

        url = Url.objects.create(
            domain=domain,
            loc='https://ejemplo.test/nota',
            in_sitemap=True,
            coverage_state=CoverageState.INDEXED,
            last_checked_at=timezone.now(),
        )

        batch = inspect_domain(
            domain,
            urls=[url],
            client=FakeClient(inspect_url=response('url_inspection_rastreada_sin_indexar')),
        )

        assert batch.summary['lost_indexing'] == 1
        assert batch.summary['lost_indexing_urls'] == [
            {'loc': 'https://ejemplo.test/nota', 'state': CoverageState.CRAWLED_NOT_INDEXED}
        ]

    def test_the_recorded_address_survives_the_url_leaving_the_inventory(self, account, domain):
        """
        Por qué se guarda el texto de la dirección y no una clave hacia `Url`.

        Un sitemap que deja de declararla la borra del inventario, y con una
        clave foránea el aviso quedaría diciendo que doce direcciones se cayeron
        del índice sin poder nombrar una sola. La dirección es la prueba: viaja
        congelada, como en `IndexingRequest`.
        """
        from apps.coverage.services import inspect_domain
        from apps.sitemaps.models import CoverageState, Url
        from tests.doubles import FakeClient, response

        url = Url.objects.create(
            domain=domain,
            loc='https://ejemplo.test/se-va',
            in_sitemap=True,
            coverage_state=CoverageState.INDEXED,
            last_checked_at=timezone.now(),
        )

        batch = inspect_domain(
            domain,
            urls=[url],
            client=FakeClient(inspect_url=response('url_inspection_rastreada_sin_indexar')),
        )

        url.delete()
        batch.refresh_from_db()

        assert batch.summary['lost_indexing_urls'][0]['loc'] == 'https://ejemplo.test/se-va'

    def test_a_url_that_starts_failing_to_download_leaves_its_own_notice(self, account, domain):
        """
        Va aparte de la caída de indexación porque se arregla en otro lado: esto
        pasa en el servidor del sitio, no en el índice de Google (T116).
        """
        from apps.coverage.services import inspect_domain
        from apps.sitemaps.models import CoverageState, Url
        from tests.doubles import FakeClient, response

        url = Url.objects.create(
            domain=domain,
            loc='https://ejemplo.test/caida',
            in_sitemap=True,
            coverage_state=CoverageState.INDEXED,
            last_checked_at=timezone.now(),
        )

        inspect_domain(
            domain,
            urls=[url],
            client=FakeClient(inspect_url=response('url_inspection_error_de_servidor')),
        )

        notice = Notification.objects.get(account=account, kind=NotificationKind.FETCH_ERRORS)

        assert notice.level == NotificationLevel.ACTION
        # Lleva a las URLs de las que habla, no al listado entero (RT-09).
        assert notice.action_path.endswith(f'?state={slug(CoverageState.FETCH_ERROR)}')
        # Y no afirma la causa: puede ser un corte, un error del sitio o un
        # bloqueo, y decirlo sin saberlo manda a mirar el lugar equivocado. La
        # frase que lo dice así vive en el catálogo, bajo esta clave, y hay un
        # test que comprueba que ninguna promete indexación.
        assert notice.text_key == 'FETCH_ERRORS'

    def test_a_url_newly_blocked_by_robots_leaves_its_own_notice(self, account, domain):
        from apps.coverage.services import inspect_domain
        from apps.sitemaps.models import CoverageState, Url
        from tests.doubles import FakeClient, response

        url = Url.objects.create(
            domain=domain,
            loc='https://ejemplo.test/seccion',
            in_sitemap=True,
            coverage_state=CoverageState.INDEXED,
            last_checked_at=timezone.now(),
        )

        inspect_domain(
            domain,
            urls=[url],
            client=FakeClient(inspect_url=response('url_inspection_bloqueada_por_robots')),
        )

        notice = Notification.objects.get(account=account, kind=NotificationKind.CRAWL_BLOCKED)

        assert notice.action_path.endswith(f'?state={slug(CoverageState.BLOCKED_ROBOTS)}')
        # El bloqueo puede ser deliberado: se informa, no se trata como falla.
        # Va como aviso y no como acción, y su frase lo dice en el catálogo.
        assert notice.text_key == 'CRAWL_BLOCKED'

    def test_a_url_that_already_failed_is_not_news_again(self, account, domain):
        """
        Se cuentan transiciones, no estados.

        Una URL que viene con error desde la semana pasada no es una novedad, y
        volver a contarla cada día convertiría el aviso en un número que sube
        solo y que nadie mira.
        """
        from apps.coverage.services import inspect_domain
        from apps.sitemaps.models import CoverageState, Url
        from tests.doubles import FakeClient, response

        url = Url.objects.create(
            domain=domain,
            loc='https://ejemplo.test/rota',
            in_sitemap=True,
            coverage_state=CoverageState.FETCH_ERROR,
            last_checked_at=timezone.now(),
        )

        inspect_domain(
            domain,
            urls=[url],
            client=FakeClient(inspect_url=response('url_inspection_error_de_servidor')),
        )

        assert not Notification.objects.filter(kind=NotificationKind.FETCH_ERRORS).exists()

    def test_an_inspection_without_losses_says_nothing(self, account, domain):
        """
        Una URL que nunca estuvo indexada y sigue sin estarlo no cambió de
        situación: avisarlo llenaría la campana de no-noticias.
        """
        from apps.coverage.services import inspect_domain
        from apps.sitemaps.models import CoverageState, Url
        from tests.doubles import FakeClient, response

        url = Url.objects.create(
            domain=domain,
            loc='https://ejemplo.test/otra',
            in_sitemap=True,
            coverage_state=CoverageState.UNKNOWN,
        )

        inspect_domain(
            domain,
            urls=[url],
            client=FakeClient(inspect_url=response('url_inspection_rastreada_sin_indexar')),
        )

        assert not Notification.objects.filter(kind=NotificationKind.COVERAGE_DROP).exists()


class TestTheScreen:
    def test_the_unread_count_travels_to_every_page(self, client, account, domain):
        """
        El número vive en el menú, que está en todas las pantallas. Va con las
        props compartidas para que ninguna vista pueda olvidárselo.
        """
        services.batch_finished(make_batch(domain))

        # Cualquier pantalla con sesión sirve: lo que se defiende es que el
        # contador viaje a **todas**. Se mira la conexión desde que el listado de
        # dominios dejó de tener dirección; el tablero no sirve para esto porque
        # redirige al recorrido mientras la cuenta no esté configurada.
        props = client.get('/search-console/settings', HTTP_X_INERTIA='true').json()['props']

        assert props['notifications']['unread'] == 1

    def test_what_asks_for_an_action_is_counted_apart(self, client, account, domain):
        """
        «12 sin leer» no dice si adentro hay algo roto.

        Por volumen manda el aviso informativo —hay uno por lote, por dominio y
        por día—, así que el total no alcanza para decidir si hay que hacer algo.
        El conteo va con las props compartidas para que el contador del menú y la
        primera línea de la pantalla digan lo mismo.
        """
        services.batch_finished(make_batch(domain))
        services.access_lost(domain)

        # Cualquier pantalla con sesión sirve: lo que se defiende es que el
        # contador viaje a **todas**. Se mira la conexión desde que el listado de
        # dominios dejó de tener dirección; el tablero no sirve para esto porque
        # redirige al recorrido mientras la cuenta no esté configurada.
        props = client.get('/search-console/settings', HTTP_X_INERTIA='true').json()['props']

        assert props['notifications']['unread'] == 2
        assert props['notifications']['unread_action'] == 1

    def test_the_action_count_is_of_the_account_and_not_of_a_page(self, client, account, domain):
        """
        Derivarlo de la tanda que se listó daría una cifra falsa en cuanto haya
        una segunda, y falsa justo hacia abajo: diría que hay menos por resolver
        de lo que hay.

        Va como prop de la pantalla y no sólo compartida porque en **esta**
        pantalla la prop `notifications` es la tabla paginada, que tapa a la
        compartida del mismo nombre. La compartida sigue existiendo para el
        contador del menú de las otras trece.
        """
        Notification.objects.bulk_create(
            Notification(
                account=account,
                kind=NotificationKind.BATCH_FAILED,
                level=NotificationLevel.ACTION,
                text_key='BATCH_FAILED',
                params={'hostname': f'sitio-{number}.test'},
                dedupe_key=f'bulk:{number}',
            )
            for number in range(60)
        )

        props = client.get('/search-console/notifications', HTTP_X_INERTIA='true').json()['props']

        # La primera tanda, no la cuenta de la cuenta. Sale de la especificación
        # y no de un número escrito acá: el tamaño lo decide la vista.
        assert len(props['notifications']['rows']) == NOTIFICATIONS.per_page
        assert props['unread'] == 60
        assert props['unread_action'] == 60

    def test_reading_the_one_that_asked_for_something_lowers_both_counts(
        self, client, account, domain
    ):
        services.batch_finished(make_batch(domain))
        pending = services.access_lost(domain)

        client.post(f'/search-console/notifications/{pending.id}/read')
        # Cualquier pantalla con sesión sirve: lo que se defiende es que el
        # contador viaje a **todas**. Se mira la conexión desde que el listado de
        # dominios dejó de tener dirección; el tablero no sirve para esto porque
        # redirige al recorrido mientras la cuenta no esté configurada.
        props = client.get('/search-console/settings', HTTP_X_INERTIA='true').json()['props']

        assert props['notifications']['unread'] == 1
        assert props['notifications']['unread_action'] == 0

    def test_opening_the_list_does_not_mark_anything_read(self, client, account, domain):
        """
        Marcar leído es una acción. Vaciar el número al entrar le sacaría a
        quien pasó de largo la única señal de que había algo pendiente.
        """
        services.batch_finished(make_batch(domain))

        client.get('/search-console/notifications', HTTP_X_INERTIA='true')

        assert services.unread_count(account) == 1

    def test_marking_one_as_read_lowers_the_count(self, client, account, domain):
        services.batch_finished(make_batch(domain))
        notification = Notification.objects.get(account=account)

        client.post(f'/search-console/notifications/{notification.id}/read')

        assert services.unread_count(account) == 0

    def test_marking_all_as_read_does_not_delete_them(self, client, account, domain):
        """Leído y borrado son cosas distintas: se sigue pudiendo volver a leerlos."""
        services.batch_finished(make_batch(domain))
        services.batch_finished(make_batch(domain))

        client.post('/search-console/notifications/read-all')

        assert services.unread_count(account) == 0
        assert Notification.objects.filter(account=account).count() == 2

    def test_the_unread_filter_lives_in_the_address(self, client, account, domain):
        services.batch_finished(make_batch(domain))
        read = services.batch_finished(make_batch(domain))
        services.mark_read(account, read.id)

        props = client.get('/search-console/notifications?unread=1', HTTP_X_INERTIA='true').json()[
            'props'
        ]

        assert props['only_unread'] is True
        assert len(props['notifications']['rows']) == 1

    def test_the_unread_cut_survives_paging(self, client, account, domain):
        """
        El recorte no es un filtro declarado, así que el servidor tiene que
        meterlo a mano en la dirección saneada. Sin eso, la página dos de «sin
        leer» devolvía la página dos de todo: nada fallaba, y la persona veía
        avisos ya leídos en una lista que decía mostrar los pendientes.
        """
        Notification.objects.bulk_create(
            Notification(
                account=account,
                kind=NotificationKind.BATCH_FINISHED,
                text_key='BATCH_COMPLETED',
                params={'hostname': f'sitio-{number}.test'},
                dedupe_key=f'bulk:{number}',
                read_at=timezone.now() if number % 2 else None,
            )
            for number in range(120)
        )

        props = client.get(
            '/search-console/notifications?unread=1&page=2', HTTP_X_INERTIA='true'
        ).json()['props']
        page = props['notifications']

        assert page['page'] == 2
        assert page['query']['unread'] == '1'
        assert all(row['read_at'] is None for row in page['rows'])

    def test_notifications_created_for_another_profile_are_shared(self, client, account, domain):
        other = create_account(email='other@ejemplo.test')
        services.notify(
            other,
            kind=NotificationKind.BATCH_FINISHED,
            text_key='BATCH_COMPLETED',
            dedupe_key='x',
        )

        props = client.get('/search-console/notifications', HTTP_X_INERTIA='true').json()['props']

        assert len(props['notifications']['rows']) == 1

    def test_a_shared_notification_can_be_marked_read(self, client, account):
        other = create_account(email='other@ejemplo.test')
        theirs = services.notify(
            other,
            kind=NotificationKind.BATCH_FINISHED,
            text_key='BATCH_COMPLETED',
            dedupe_key='x',
        )

        client.post(f'/search-console/notifications/{theirs.id}/read')
        theirs.refresh_from_db()

        assert theirs.read_at is not None

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


class TestTheListGrowsInsteadOfPaginating:
    """
    Los avisos no se paginan: la tanda siguiente se **agrega** al final.

    Acá se mira hacia abajo buscando desde cuándo pasa algo, y paginar obliga a
    recordar qué había en la página anterior.

    Lo que se comprueba es el contrato que hace posible el «cargar más»: sin
    `mergeProps` la tanda nueva **reemplaza** a la anterior en vez de sumarse, y
    sin `scrollProps` el cliente no sabe si queda algo por traer. Las dos cosas
    las pone el servidor y ninguna se ve en las props.
    """

    def _page(self, client, url='/search-console/notifications'):
        return client.get(url, HTTP_X_INERTIA='true').json()

    def test_the_next_batch_is_appended_and_does_not_replace(self, client, account, domain):
        for index in range(3):
            services.notify(
                account,
                kind=NotificationKind.BATCH_FINISHED,
                text_key='BATCH_COMPLETED',
                dedupe_key=str(index),
            )

        assert self._page(client)['mergeProps'] == ['notifications.rows']

    def test_the_client_is_told_whether_there_is_more_to_bring(self, client, account, domain):
        services.notify(
            account,
            kind=NotificationKind.BATCH_FINISHED,
            text_key='BATCH_COMPLETED',
            dedupe_key='1',
        )

        scroll = self._page(client)['scrollProps']['notifications']

        assert scroll['pageName'] == 'page'
        assert scroll['currentPage'] == 1
        # Con una sola tanda no queda nada por traer, y el botón no se dibuja.
        assert scroll['nextPage'] is None
        assert scroll['previousPage'] is None
