"""
La pantalla de conexión con Google, estado por estado.

Esta vista no tiene un contenido fijo: tiene seis estados, y en cada uno manda
una cosa distinta. Lo que se comprueba acá no es que los datos «estén», sino que
lleguen **distinguidos**, porque de esa distinción depende toda la jerarquía de
la pantalla. Si `NO_PROPERTIES` llega igual que `INVALID_KEY`, la pantalla manda
a cambiar una clave que funciona; si una credencial verificada llega sin sus
propiedades, no hay con qué ofrecer el paso siguiente.
"""

import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone

from apps.credentials.models import Credential, CredentialError, CredentialStatus
from apps.jobs.budget import Reservation
from tests.doubles import FakeClient, http_error, response
from tests.factories import (
    TEST_KEY,
    create_account,
    create_credential,
    create_domain,
    key_json,
)

pytestmark = pytest.mark.django_db

#: Los ocho pasos del recorrido, que es de donde sale la única cifra de la guía
#: que esta pantalla sigue necesitando.
THE_WHOLE_TOUR = [
    'GOOGLE_PROJECT',
    'ENABLE_API',
    'ENABLE_INDEXING_API',
    'SERVICE_ACCOUNT_KEY',
    'AUTHORIZE_PROPERTY',
    'ADD_DOMAIN',
    'ADD_SITEMAP',
    'FIRST_BATCH',
]


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


def screen(client) -> dict:
    """Las props que efectivamente recibe la vista."""
    return client.get('/search-console/settings', HTTP_X_INERTIA='true').json()['props']


def with_double(monkeypatch, double) -> None:
    """Hace que la comprobación de la vista responda con Google grabado."""
    import apps.credentials.services as services

    original = services.verify_credential

    def wrapped(credential, *, client=None):
        return original(credential, client=double)

    monkeypatch.setattr('apps.credentials.views.verify_credential', wrapped)


class TestTheStates:
    def test_without_a_credential_the_screen_says_so_and_does_not_invent_one(self, client, account):
        props = screen(client)

        assert props['credential'] is None
        assert props['history'] == []

    def test_the_steps_of_the_guide_no_longer_travel_to_this_screen(self, client, account):
        """
        Los pasos se escriben una vez en el servidor y ahora se **dibujan** una
        vez, en el recorrido guiado.

        Esta pantalla los recortaba a cuatro y los dibujaba con un tercer
        componente adentro de un acordeón, así que una sola fuente en el servidor
        producía tres formas en la pantalla. Lo que queda es la invitación a ir
        al recorrido, y de la guía sólo viaja cuántos pasos son: sin eso, la
        invitación tendría que escribir el número a mano y quedaría vieja el día
        que aparezca un paso más.
        """
        props = screen(client)

        assert 'guide' not in props
        assert props['tour'] == {'steps_total': len(THE_WHOLE_TOUR)}

    def test_uploaded_without_being_checked_arrives_without_a_failure_reason(self, client, account):
        """
        Sin comprobar no es lo mismo que rota.

        La pantalla ofrece comprobarla; si esto llegara con un código de error,
        ofrecería cambiarla.
        """
        create_credential(account, status=CredentialStatus.UNVERIFIED)

        credential = screen(client)['credential']

        assert credential['status'] == CredentialStatus.UNVERIFIED
        assert credential['last_error_code'] is None
        assert credential['last_checked_at'] is None
        assert credential['is_usable'] is False

    def test_pending_authorization_arrives_distinguished_from_a_broken_key(self, client, account):
        """
        Es el estado esperado justo después de subir la clave (SC-002).

        La clave funciona: lo que falta es agregarla en Search Console. Sin el
        código, la pantalla no puede tratarlo como paso pendiente y lo pinta de
        rojo como a cualquier falla.
        """
        create_credential(
            account,
            status=CredentialStatus.INVALID,
            error=CredentialError.NO_PROPERTIES,
            checked_at=timezone.now(),
        )

        credential = screen(client)['credential']

        assert credential['last_error_code'] == CredentialError.NO_PROPERTIES
        assert credential['accessible_properties'] == []

    def test_verified_brings_the_properties_it_reaches(self, client, account):
        """
        Sin la lista no hay con qué ofrecer el paso siguiente.

        Es lo que permite decir «alcanza tres propiedades» en vez de «la
        credencial es válida», que no le confirma a nadie que su sitio esté
        entre ellas.
        """
        create_credential(
            account,
            properties=[{'property_uri': 'sc-domain:ejemplo.test', 'permission': 'siteOwner'}],
            checked_at=timezone.now(),
        )

        credential = screen(client)['credential']

        assert credential['is_usable'] is True
        assert credential['accessible_properties'] == [
            {'property_uri': 'sc-domain:ejemplo.test', 'permission': 'siteOwner'}
        ]

    def test_a_revoked_credential_arrives_with_its_state(self, client, account):
        """Revocada no se comprueba: se reemplaza, y la pantalla necesita saberlo."""
        create_credential(account, status=CredentialStatus.REVOKED)

        assert screen(client)['credential']['status'] == CredentialStatus.REVOKED

    def test_the_history_does_not_include_the_credential_in_use(self, client, account):
        create_credential(account, status=CredentialStatus.INVALID, assign=False)
        create_credential(account)

        props = screen(client)

        assert len(props['history']) == 1
        assert props['history'][0]['id'] != props['credential']['id']


class TestTheVerification:
    @pytest.mark.parametrize(
        ('fixture', 'code'),
        [
            ('error_api_no_habilitada', CredentialError.API_NOT_ENABLED),
            ('error_permiso_denegado', CredentialError.NO_PROPERTIES),
            ('error_google_caido', CredentialError.PROVIDER_UNAVAILABLE),
        ],
    )
    def test_each_reason_arrives_at_the_screen_with_its_code(
        self, client, account, monkeypatch, fixture, code
    ):
        """
        Cinco motivos, cinco textos y cinco acciones (SC-002).

        Un único «no se pudo comprobar la credencial» dejaría a la persona
        probando las cinco cosas, cada una en una pantalla distinta de Google.
        """
        credential = create_credential(account, status=CredentialStatus.UNVERIFIED)
        with_double(monkeypatch, FakeClient(list_sites=http_error(fixture)))

        client.post(f'/search-console/settings/credential/{credential.id}/verify')

        assert screen(client)['credential']['last_error_code'] == code

    def test_a_check_that_passes_leaves_the_properties_with_their_permission(
        self, client, account, monkeypatch
    ):
        """
        El permiso viaja junto a la propiedad porque no son equivalentes.

        Con cualquiera que no sea propietario, la inspección de URLs falla y
        Google no dice por qué: una lista sin el permiso al lado se lee como
        «esto ya funciona» cuando puede no funcionar.
        """
        credential = create_credential(account, status=CredentialStatus.UNVERIFIED)
        with_double(monkeypatch, FakeClient(list_sites=response('sites_list')))

        client.post(f'/search-console/settings/credential/{credential.id}/verify')

        credential_on_screen = screen(client)['credential']
        assert credential_on_screen['status'] == CredentialStatus.VERIFIED
        assert [p['permission'] for p in credential_on_screen['accessible_properties']] == [
            'siteOwner',
            'siteFullUser',
        ]

    def test_google_down_does_not_drop_from_verified_to_unchecked(
        self, client, account, monkeypatch
    ):
        """
        Un corte de red no dice nada de la credencial: conserva su estado.

        Bajarla a «sin comprobar» apaga la cuenta entera —`can_operate` mira ese
        campo— y manda a recargar una clave que no tiene nada roto.
        """
        credential = create_credential(account, checked_at=timezone.now())
        with_double(monkeypatch, FakeClient(list_sites=http_error('error_google_caido')))

        client.post(f'/search-console/settings/credential/{credential.id}/verify')

        credential.refresh_from_db()
        assert credential.status == CredentialStatus.VERIFIED
        assert credential.last_error_code == CredentialError.PROVIDER_UNAVAILABLE

    def test_google_down_does_not_erase_the_properties_we_already_knew(
        self, client, account, monkeypatch
    ):
        """Vaciarlas diría «perdiste el acceso» cuando no pudimos ni preguntar."""
        credential = create_credential(
            account,
            properties=[{'property_uri': 'sc-domain:ejemplo.test', 'permission': 'siteOwner'}],
            checked_at=timezone.now(),
        )
        with_double(monkeypatch, FakeClient(list_sites=http_error('error_google_caido')))

        client.post(f'/search-console/settings/credential/{credential.id}/verify')

        credential.refresh_from_db()
        assert len(credential.accessible_properties) == 1

    def test_losing_the_access_does_empty_the_list(self, client, account, monkeypatch):
        """Acá Google sí contestó: no hay ninguna propiedad que podamos leer."""
        credential = create_credential(
            account,
            properties=[{'property_uri': 'sc-domain:ejemplo.test', 'permission': 'siteOwner'}],
            checked_at=timezone.now(),
        )
        with_double(monkeypatch, FakeClient(list_sites=response('sites_list_vacio')))

        client.post(f'/search-console/settings/credential/{credential.id}/verify')

        credential.refresh_from_db()
        assert credential.accessible_properties == []
        assert credential.last_error_code == CredentialError.NO_PROPERTIES

    def test_without_verification_quota_the_last_check_there_was_is_not_overwritten(
        self, client, account, monkeypatch
    ):
        """
        Quedarnos sin cupo no es un veredicto sobre la clave.

        Guardarlo borraría la fecha de la última comprobación real, que es el
        único dato que la pantalla tiene para decir desde cuándo sabe lo que
        sabe. Como no queda escrito, viaja aparte para poder mostrarlo junto al
        botón que lo disparó (RT-11).
        """
        yesterday = timezone.now() - timezone.timedelta(days=1)
        credential = create_credential(account, checked_at=yesterday)
        monkeypatch.setattr(
            'apps.jobs.budget.reserve_verification',
            lambda account, amount=1: Reservation(
                domain_id='', origin='VERIFICATION', granted=0, requested=amount, date=''
            ),
        )

        client.post(f'/search-console/settings/credential/{credential.id}/verify')

        credential.refresh_from_db()
        assert credential.last_checked_at == yesterday
        assert credential.status == CredentialStatus.VERIFIED
        assert screen(client)['errors']['verify']


class TestTheUpload:
    def test_a_valid_key_ends_up_uploaded_and_checked_in_one_go(self, client, account, monkeypatch):
        """
        Dejarla cargada y sin comprobar obliga a un segundo clic para averiguar
        lo único que importa, que es si sirve.
        """
        with_double(monkeypatch, FakeClient(list_sites=response('sites_list')))

        upload_response = client.post(
            '/search-console/settings/credential',
            {
                'project_id': TEST_KEY['project_id'],
                'key_file': SimpleUploadedFile(
                    'clave.json', key_json(), content_type='application/json'
                ),
            },
        )

        assert upload_response.headers['Location'] == '/search-console/settings'
        credential = screen(client)['credential']
        assert credential['status'] == CredentialStatus.VERIFIED
        assert len(credential['accessible_properties']) == 2

    def test_replacing_the_credential_does_not_touch_any_domain(self, client, account, monkeypatch):
        """
        Es la promesa que sostiene el reemplazo (FR-012).

        La credencial se resuelve por módulo en tiempo de uso, así que cambiarla
        no requiere recorrer ninguna fila de dominios.
        """
        create_credential(account)
        domain = create_domain(account)
        before = (domain.access_state, domain.updated_at)
        with_double(monkeypatch, FakeClient(list_sites=response('sites_list')))

        client.post(
            '/search-console/settings/credential',
            {
                'project_id': TEST_KEY['project_id'],
                'key_file': SimpleUploadedFile(
                    'clave.json', key_json(), content_type='application/json'
                ),
            },
        )

        domain.refresh_from_db()
        assert (domain.access_state, domain.updated_at) == before
        assert Credential.objects.filter(account=account).count() == 2
        assert len(screen(client)['history']) == 1

    def test_an_identifier_from_another_project_returns_to_the_field_that_caused_it(
        self, client, account
    ):
        """El error va junto al campo, no en un aviso flotante (RT-08)."""
        upload_response = client.post(
            '/search-console/settings/credential',
            {
                'project_id': 'otro-proyecto-119203',
                'key_file': SimpleUploadedFile(
                    'clave.json', key_json(), content_type='application/json'
                ),
            },
        )

        assert upload_response.headers['Location'] == '/search-console/settings'
        errors = screen(client)['errors']
        fault = errors['project_id']

        # Los dos identificadores viajan como datos: la frase nombra el que se
        # declaró y el que trae el archivo, que es lo único que deja ver cuál de
        # los dos está mal.
        assert fault['code'] == 'PROJECT_ID_MISMATCH'
        assert fault['params']['declared'] == 'otro-proyecto-119203'
        assert not Credential.objects.filter(account=account).exists()

    def test_without_a_file_the_error_says_the_file_is_missing(self, client, account):
        client.post('/search-console/settings/credential', {'project_id': TEST_KEY['project_id']})

        assert 'key_file' in screen(client)['errors']

    def test_a_rejected_replacement_comes_back_with_its_dialog_still_open(self, client, account):
        """
        El reemplazo se abre desde la dirección, así que cerrarlo al fallar
        mandaría el error a una pantalla donde el campo que lo causó ya no está:
        habría que volver a abrirlo, volver a escribir y adivinar qué pasó.

        Quien manda `replace` es el formulario del diálogo. La carga del estado
        vacío no lo manda, y por eso vuelve a la dirección de siempre.
        """
        create_credential(account)

        upload_response = client.post(
            '/search-console/settings/credential',
            {
                'project_id': 'otro-proyecto-119203',
                'replace': 'file',
                'key_file': SimpleUploadedFile(
                    'clave.json', key_json(), content_type='application/json'
                ),
            },
        )

        assert upload_response.headers['Location'] == '/search-console/settings?replace=file'
        assert 'project_id' in screen(client)['errors']

    def test_a_replacement_that_works_closes_its_dialog(self, client, account, monkeypatch):
        """Lo cierra el servidor: la respuesta buena vuelve sin el parámetro."""
        create_credential(account)
        with_double(monkeypatch, FakeClient(list_sites=response('sites_list')))

        upload_response = client.post(
            '/search-console/settings/credential',
            {
                'project_id': TEST_KEY['project_id'],
                'replace': 'file',
                'key_file': SimpleUploadedFile(
                    'clave.json', key_json(), content_type='application/json'
                ),
            },
        )

        assert upload_response.headers['Location'] == '/search-console/settings'


class TestTheSecrets:
    def test_the_key_material_does_not_travel_to_the_browser(self, client, account):
        """
        R-E y RT-06: ni entero, ni parcial, ni ofuscado.

        Se comprueba sobre el cuerpo completo de la respuesta y no campo por
        campo: lo que hay que garantizar es que no salga por ningún lado, no que
        no salga por los lugares que se nos ocurrieron.

        El ejemplo de la guía sí lleva un bloque que dice «PRIVATE KEY»: es
        inventado, y existe justamente para no tener que mostrar el de nadie.
        """
        create_credential(account)

        body = client.get('/search-console/settings', HTTP_X_INERTIA='true').content.decode()

        assert 'encrypted_key' not in body
        assert TEST_KEY['private_key'] not in body
        assert 'MIIsinValorReal' not in body
        # Lo que sí tiene que estar: con qué reconocer la clave sin poder usarla.
        assert TEST_KEY['client_email'] in body
        assert 'huella-de-prueba' in body

    def test_the_company_sees_the_credential_created_by_another_profile(self, client, account):
        other_account = create_account(email='ajena@ejemplo.test')
        create_credential(other_account)

        assert screen(client)['credential'] is not None

    def test_a_company_credential_can_be_checked_from_another_profile(self, client, account):
        other_account = create_account(email='ajena@ejemplo.test')
        credential = create_credential(other_account, status=CredentialStatus.UNVERIFIED)

        client.post(f'/search-console/settings/credential/{credential.id}/verify')

        credential.refresh_from_db()
        assert credential.status == CredentialStatus.INVALID
