"""
El recorrido guiado visto desde la pantalla.

Lo que ya está probado en `test_onboarding_flow`, `test_onboarding_resume` y
`tests/unit/test_onboarding_progress` es el servicio: que ningún paso se marque
sin que su requisito esté cumplido, que retomar no duplique nada y que omitir sea
idempotente. Nada de eso depende de por dónde se entró, así que no se vuelve a
comprobar acá.

Acá se prueba el eslabón que falta —el que va del botón al servicio— y las tres
cosas que sólo existen en la vista y que ninguna prueba de servicio puede ver:

- que las props lleguen con los cuatro estados distinguidos, porque de esa
  distinción depende que un corte de red no se lea como un error de quien
  configura (RT-08);
- que un dato mal escrito vuelva señalando su campo en vez de caerse, y que un
  requisito todavía incumplido **no** sea un error;
- que omitir salga del recorrido sin restringir nada (FR-018).

Las rutas todavía las monta `tests/urlconfs.py`; cuando `config/urls.py` las
tenga, la marca `urls` de cada prueba se borra y todo sigue igual.
"""

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

from apps.core import routes
from apps.core.errors import QuotaExhausted
from apps.credentials.models import Credential, CredentialError
from apps.onboarding import services
from tests.doubles import FakeClient, http_error, response
from tests.factories import TEST_KEY, create_account, key_json

pytestmark = [pytest.mark.django_db, pytest.mark.urls('tests.urlconfs')]

#: Los ocho, en el orden en que se resuelven. Escritos a mano y no importados de
#: `content`: si el orden cambia sin querer, una prueba que lee la misma
#: constante que la implementación no se entera.
THE_WHOLE_TOUR = [
    'GOOGLE_PROJECT',
    'ENABLE_API',
    # Opcional: se dibuja y se comprueba como los demás, pero que falte no
    # impide terminar el recorrido. Ver `content.REQUIRED_STEP_CODES`.
    '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/onboarding', HTTP_X_INERTIA='true').json()['props']


def step(props: dict, code: str) -> dict:
    return next(p for p in props['onboarding']['steps'] if p['code'] == code)


def with_google(monkeypatch, fake=None, *, http=None) -> None:
    """
    Hace que la comprobación de la vista hable con un Google grabado.

    La vista llama a `services.verify` sin cliente, que en producción es lo
    correcto: quien decide con qué se habla es el servicio. Para la prueba se
    envuelve esa llamada en vez de reimplementarla, así lo que se ejercita sigue
    siendo el camino real de la vista.
    """
    original = services.verify

    def wrapped(account, step_code, data=None, **_):
        return original(account, step_code, data, client=fake, http=http)

    monkeypatch.setattr(services, 'verify', wrapped)


def declare_project(client, project_id=TEST_KEY['project_id']):
    return client.post(
        '/search-console/onboarding/steps/GOOGLE_PROJECT/verify', {'project_id': project_id}
    )


def upload_the_key(client, monkeypatch, fake):
    with_google(monkeypatch, fake)
    return client.post(
        '/search-console/onboarding/steps/SERVICE_ACCOUNT_KEY/verify',
        {'key_file': SimpleUploadedFile('clave.json', key_json(), 'application/json')},
    )


# --- Lo que llega a la pantalla ---------------------------------------------


def test_the_screen_brings_the_whole_tour_with_its_guidance(client, account):
    """
    Los ocho pasos llegan por su código, en orden, con su salida al formulario.

    La guía escrita —el título, el objetivo, la ruta dentro de Google— ya no
    viaja: sale del catálogo por el código del paso, porque el recorrido se lee
    en el idioma de quien lo hace.
    """
    props = screen(client)

    assert [p['code'] for p in props['onboarding']['steps']] == THE_WHOLE_TOUR

    for p in props['onboarding']['steps']:
        assert 'title' not in p, f'{p["code"]} llegó con texto escrito por el servidor'
        assert p['form_path'], f'{p["code"]} no ofrece salida al formulario completo'


def test_each_pending_step_says_what_is_missing_and_where_to_solve_it(client, account):
    """
    Es el requisito de FR-017 y no se cumple con uno de los dos.

    «Falta algo» sin dónde arreglarlo deja a alguien sabiendo que su cuenta no
    anda y buscando la pantalla; la ruta sin el motivo lo manda a rehacer un paso
    que quizá estaba bien.

    Las dos frases salen del mismo código, bajo `stepReason.<CODE>.missing` y
    `.hint`. Que las dos existan para cada código lo comprueba
    `tests/unit/test_i18n.py`; acá se defiende que ningún paso pendiente llegue
    sin motivo.
    """
    props = screen(client)

    for p in props['onboarding']['steps']:
        if p['state'] == 'PASSED':
            continue
        assert p['reason'], f'{p["code"]} no dice qué falta'


def test_the_first_time_there_is_no_completed_step(client, account):
    props = screen(client)['onboarding']

    assert props['completed_steps'] == []
    assert props['current_step'] == 'GOOGLE_PROJECT'
    assert props['should_offer'] is True
    assert props['is_finished'] is False
    assert props['checked_step'] == ''


def test_a_blocked_step_does_not_arrive_as_pending(client, account):
    """
    El paso 2 no es algo que la persona pueda resolver todavía.

    Saber si la API está habilitada exige preguntárselo a Google, y para
    preguntar hace falta la clave del paso 3. Llega como `BLOCKED` y con el paso
    que lo destraba: mostrarlo como pendiente mandaría a insistir sobre algo que
    no depende de quien está mirando la pantalla (RT-07).
    """
    api = step(screen(client), 'ENABLE_API')

    assert api['state'] == 'BLOCKED'
    assert api['blocked_by'] == 'SERVICE_ACCOUNT_KEY'


def test_the_tour_opens_at_the_first_actionable_step(client, account, monkeypatch):
    """Los bloqueados se saltean: dejar el asistente parado ahí sería un callejón."""
    declare_project(client)

    assert screen(client)['onboarding']['current_step'] == 'SERVICE_ACCOUNT_KEY'


def test_the_three_tour_routes_are_published():
    """
    La pantalla no arma direcciones a mano: las resuelve por nombre.

    Las tres viajan por el mismo canal que el resto —el mapa de rutas
    publicadas— y no como props de esta vista. Con un mapa propio, el día que
    una dirección cambie habría que acordarse de dos lugares, y el que se olvida
    es siempre el que no tiene test.
    """
    published = routes.published()

    assert published['onboarding']['uri'] == '/search-console/onboarding'
    assert published['onboarding.dismiss']['uri'] == '/search-console/onboarding/dismiss'
    assert published['onboarding.verify']['uri'] == '/search-console/onboarding/{step}/verify'
    assert published['onboarding.verify']['params'] == ['step']


def test_without_a_session_the_tour_sends_to_the_login(client):
    http_response = client.get('/search-console/onboarding')

    assert http_response.status_code == 302
    assert http_response.headers['Location'].startswith('/login')


# --- Comprobar un paso ------------------------------------------------------


def test_verifying_a_step_completes_it_and_returns_to_the_tour(client, account):
    http_response = declare_project(client)

    assert http_response.status_code == 302
    assert http_response.headers['Location'].startswith('/search-console/onboarding')

    props = screen(client)['onboarding']
    assert props['completed_steps'] == ['GOOGLE_PROJECT']
    assert props['created']['project_id'] == TEST_KEY['project_id']


def test_a_step_that_passes_opens_the_next_one_that_can_be_solved(client, account):
    """
    Qué paso queda abierto es parte de la respuesta y viaja en `?step=`.

    Antes vivía en el estado de React: el botón Atrás no volvía al paso anterior
    y no se podía enlazar «mirá el paso 4». Y el que se abre no es el siguiente
    de la lista sino el siguiente **que se puede resolver**: habilitar la API
    está bloqueada hasta que exista la clave, así que mandar ahí sería pedir algo
    que todavía no depende de esta persona.
    """
    http_response = declare_project(client)

    assert (
        http_response.headers['Location'] == '/search-console/onboarding?step=SERVICE_ACCOUNT_KEY'
    )


def test_a_step_that_does_not_pass_stays_open_where_the_motive_is(client, account):
    """
    Avanzar tras un fallo escondería el motivo justo cuando hay que leerlo, y
    daría por andado un paso que la comprobación no dio por cumplido (FR-017).
    """
    http_response = client.post('/search-console/onboarding/steps/GOOGLE_PROJECT/verify', {})

    assert http_response.headers['Location'] == '/search-console/onboarding?step=GOOGLE_PROJECT'


def test_a_field_error_comes_back_to_the_step_that_can_correct_it(client, account):
    """Un identificador con mayúsculas se corrige en su campo, que está en ese paso."""
    http_response = declare_project(client, 'Mi-Proyecto-123456')

    assert http_response.headers['Location'] == '/search-console/onboarding?step=GOOGLE_PROJECT'


def test_finishing_the_tour_comes_back_without_naming_any_step(client, account, monkeypatch):
    """
    Con los siete cumplidos ya no queda ningún paso por resolver.

    Nombrar uno abriría el último, que lo único que ofrece es volver a comprobar
    algo que ya está. Sin `?step=`, la pantalla abre su cierre, que es donde vive
    lo que sí falta hacer y que pasa en otras pantallas.
    """
    monkeypatch.setattr(
        'apps.onboarding.views.services.verify',
        lambda *args, **kwargs: {
            'is_finished': True,
            'current_step': 'FIRST_BATCH',
            'steps': [{'code': 'FIRST_BATCH', 'state': 'PASSED'}],
        },
    )

    http_response = client.post('/search-console/onboarding/steps/FIRST_BATCH/verify', {})

    assert http_response.headers['Location'] == '/search-console/onboarding'


def test_the_just_verified_step_arrives_marked_only_once(client, account):
    """
    El resultado de una comprobación no es un estado enlazable.

    Es lo que le permite a la pantalla anunciar el veredicto y mover el foco
    hacia él. Si sobreviviera a la recarga, volver al día siguiente anunciaría
    como recién ocurrido el resultado de una comprobación vieja.
    """
    declare_project(client)

    assert screen(client)['onboarding']['checked_step'] == 'GOOGLE_PROJECT'
    assert screen(client)['onboarding']['checked_step'] == ''


def test_a_step_that_does_not_pass_is_not_marked_and_is_not_an_error(client, account):
    """
    Que falte un requisito es el resultado de la operación, no un fallo del pedido.

    Comprobar sin mandar nada es legítimo —lo hace quien ya cargó el dato por el
    formulario completo y sólo quiere que el recorrido lo constate—, y la
    respuesta tiene que ser el paso diciendo qué falta.
    """
    http_response = client.post('/search-console/onboarding/steps/GOOGLE_PROJECT/verify', {})

    assert http_response.status_code == 302

    props = screen(client)
    assert props['onboarding']['completed_steps'] == []
    assert step(props, 'GOOGLE_PROJECT')['state'] == 'MISSING'
    assert props['errors'] == {}


def test_an_identifier_with_uppercase_comes_back_attached_to_its_field(client, account):
    """
    Acá sí hay algo que corregir en lo enviado, y el mensaje va donde se corrige.

    Un error suelto arriba de todo obligaría a recorrer el formulario buscando
    cuál de los campos lo provocó (RT-08).
    """
    declare_project(client, 'Mi-Proyecto-123456')

    props = screen(client)
    assert props['errors']['project_id']['code'] == 'PROJECT_ID_UPPERCASE'
    assert props['onboarding']['completed_steps'] == []


def test_a_step_that_does_not_exist_is_a_malformed_url(client, account):
    assert client.post('/search-console/onboarding/steps/PASO_INVENTADO/verify').status_code == 404


def test_an_error_from_google_arrives_with_its_code(client, account, monkeypatch):
    """
    La pantalla ramifica por código, no leyendo la oración.

    «Se agotó el cupo» y «Google no respondió» se resuelven en lugares distintos,
    y deducir cuál es a partir del texto se rompe al primer cambio de redacción.
    """

    def exhausted(*_args, **_kwargs):
        raise QuotaExhausted()

    monkeypatch.setattr(services, 'verify', exhausted)

    declare_project(client)

    errors = screen(client)['errors']
    assert errors['verify']['code'] == 'QUOTA_EXHAUSTED'


# --- El archivo de la clave -------------------------------------------------


def test_uploading_the_key_from_the_screen_leaves_it_verified(client, account, monkeypatch):
    declare_project(client)
    upload_the_key(client, monkeypatch, FakeClient(list_sites=response('sites_list')))

    props = screen(client)
    assert 'SERVICE_ACCOUNT_KEY' in props['onboarding']['completed_steps']
    assert props['onboarding']['created']['client_email'] == TEST_KEY['client_email']


def test_a_single_response_from_google_resolves_the_steps_that_depend_on_it(
    client, account, monkeypatch
):
    """
    Comprobar la clave contesta también si la API está encendida.

    Devolver sólo el paso comprobado obligaría a pedir el resto en otra vuelta, y
    mientras tanto la pantalla mostraría como pendiente algo que ya está.
    """
    declare_project(client)
    upload_the_key(client, monkeypatch, FakeClient(list_sites=response('sites_list')))

    completed = screen(client)['onboarding']['completed_steps']
    assert 'ENABLE_API' in completed


def test_a_file_that_is_too_large_is_rejected_before_calling_google(client, account, monkeypatch):
    """
    El tope se comprueba en la vista, no después de haber gastado una llamada.

    Es el mismo que el de la carga de credenciales: subir el archivo equivocado
    tiene que fallar igual de rápido y con el mismo texto en las dos pantallas.
    """

    def should_not_be_called(*_args, **_kwargs):
        raise AssertionError('Se llamó al servicio con un archivo que la vista debía rechazar.')

    monkeypatch.setattr(services, 'verify', should_not_be_called)

    client.post(
        '/search-console/onboarding/steps/SERVICE_ACCOUNT_KEY/verify',
        {'key_file': SimpleUploadedFile('foto.json', b'x' * (65 * 1024), 'application/json')},
    )

    fault = screen(client)['errors']['key_file']

    assert fault['code'] == 'KEY_FILE_TOO_BIG'
    # El tamaño viaja como dato: la frase lo nombra para que se vea que lo que se
    # subió pesa treinta veces lo que pesa una clave.
    assert fault['params']['size'] == 65
    assert not Credential.objects.filter(account=account).exists()


def test_google_being_down_arrives_unconfirmed_and_not_as_unmet(client, account, monkeypatch):
    """
    El cuarto estado, que es el que más fácil se colapsa contra «no cumplido».

    Google que no contesta no es algo que haya hecho mal quien está configurando.
    Tratarlo como incumplimiento lo mandaría a bajar otra clave por un problema
    que la clave nueva no arregla, y a la vez le borraría de la lista un paso que
    ya había hecho: por eso el estado se distingue y el progreso no se desmarca.
    """
    declare_project(client)
    upload_the_key(client, monkeypatch, FakeClient(list_sites=response('sites_list')))

    # El corte llega después de que la clave quedara comprobada, que es el caso
    # en el que confundir los dos estados hace más daño.
    Credential.objects.filter(account=account).update(
        last_error_code=CredentialError.PROVIDER_UNAVAILABLE
    )

    props = screen(client)
    assert step(props, 'ENABLE_API')['state'] == 'UNCONFIRMED'
    assert step(props, 'SERVICE_ACCOUNT_KEY')['state'] == 'UNCONFIRMED'
    assert step(props, 'ENABLE_API')['reason'] == 'NO_RESPONSE', (
        'sin confirmar también tiene que decir qué pasa'
    )
    assert 'SERVICE_ACCOUNT_KEY' in props['onboarding']['completed_steps']


def test_an_outage_during_the_verification_does_not_erase_the_progress(
    client, account, monkeypatch
):
    """Que Google no conteste una vez no puede costar lo que ya estaba comprobado."""
    declare_project(client)
    upload_the_key(client, monkeypatch, FakeClient(list_sites=response('sites_list')))
    completed = screen(client)['onboarding']['completed_steps']

    with_google(monkeypatch, FakeClient(list_sites=http_error('error_google_caido')))
    http_response = client.post('/search-console/onboarding/steps/AUTHORIZE_PROPERTY/verify', {})

    assert http_response.status_code == 302
    props = screen(client)
    assert props['onboarding']['completed_steps'] == completed
    assert 'verify' not in props['errors']


# --- Omitir -----------------------------------------------------------------


def test_dismissing_exits_to_the_dashboard(client, account):
    """
    Al tablero de la herramienta y no al listado: es la pantalla de inicio de
    una cuenta en marcha.

    Y tampoco a la raíz, que es la elección de herramienta: quien acaba de
    omitir el recorrido ya eligió con cuál trabajar, y devolverlo ahí lo haría
    elegir de nuevo lo que ya eligió.

    Llega al tablero de verdad y no de vuelta al recorrido: deja de ofrecerse en
    cuanto se omite, o «Omitir» sería un botón que devuelve a la misma pantalla.
    """
    http_response = client.post('/search-console/onboarding/dismiss')

    assert http_response.status_code == 302
    assert http_response.headers['Location'] == '/search-console'
    assert client.get('/search-console', HTTP_X_INERTIA='true').json()['component'] == 'Dashboard'


def test_dismissing_stops_offering_the_tour_without_closing_it(client, account):
    """
    Omitir es un cambio de camino, no una renuncia.

    La pantalla queda accesible por dirección directa y el progreso intacto: el
    recorrido es un atajo, no una llave (FR-018).
    """
    declare_project(client)
    client.post('/search-console/onboarding/dismiss')

    props = screen(client)['onboarding']
    assert props['should_offer'] is False
    assert props['is_dismissed'] is True
    assert props['completed_steps'] == ['GOOGLE_PROJECT']
