"""
Contrato del recorrido guiado.

Lo que más se verifica acá es la distinción entre dos cosas que un cliente tiene
que poder separar: un requisito que todavía no está cumplido —que se contesta con
200 y un `reason` que dice qué falta— y un dato mal enviado —que se contesta con
422 y el campo a corregir—. Colapsarlas haría que «te falta autorizar la
propiedad» llegue como si la petición estuviera mal armada.

`reason` es un **código** y no una oración, igual que el `code` de un error: es
lo que permite que un cliente ramifique sin leer prosa, y que el recorrido se lea
en el idioma de quien lo hace sin que el servidor sepa cuál es.

El otro invariante es que consultar el recorrido no cambie nada por el sólo hecho
de mirarlo: ningún paso puede quedar cumplido por haber sido visitado (FR-017).
"""

import json

import pytest
from rest_framework.test import APIClient

from apps.accounts.models import ApiKey
from apps.credentials.models import CredentialStatus, GoogleProject
from apps.onboarding import content, steps
from apps.onboarding.models import OnboardingProgress
from tests.doubles import FakeClient, response
from tests.factories import TEST_KEY, create_account, create_credential

pytestmark = pytest.mark.django_db


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


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


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


def with_google(monkeypatch, double):
    """Deja el recorrido hablando con el doble en vez de con Google."""
    original = steps.verify_credential
    monkeypatch.setattr(
        steps,
        'verify_credential',
        lambda credential, client=None: original(credential, client=double),
    )


# --- Estado inicial ---------------------------------------------------------


def test_the_tour_lists_every_step_in_order(client):
    data = client.get('/api/v1/onboarding').json()

    assert [p['code'] for p in data['steps']] == list(content.STEP_CODES)
    assert [p['position'] for p in data['steps']] == list(range(1, len(content.STEP_CODES) + 1))
    assert data['current_step'] == 'GOOGLE_PROJECT'
    assert data['should_offer'] is True
    assert data['is_finished'] is False


def test_each_step_says_which_one_it_is_and_whether_it_carries_an_example(client):
    """
    Qué pasos hay y en qué orden sale de `content.py`, la misma lista que mira la
    pantalla de configuración: con dos copias, en algún momento una diría una
    ruta de menú que Google ya cambió y la otra no.

    Lo que **no** viaja es el texto. El título, el objetivo, la ruta dentro de
    Google y el ejemplo salen del catálogo del cliente por el código del paso, y
    por eso el mismo recorrido se lee en inglés o en español sin que el servidor
    sepa cuál es el idioma. Lo que sí decide el servidor es si el paso trae un
    ejemplo para mostrar, porque eso es la forma del paso y no su redacción.
    """
    data = client.get('/api/v1/onboarding').json()

    for stored, published in zip(content.STEPS, data['steps'], strict=True):
        assert published['code'] == stored['code']
        assert published['has_example'] == stored['has_example']
        assert 'title' not in published, 'El paso viajó con texto escrito por el servidor.'

    assert step(data, 'SERVICE_ACCOUNT_KEY')['has_example'] is True
    # El ejemplo del archivo sí viaja: es una copia de lo que Google genera, no
    # una redacción nuestra, y se compara campo por campo contra el archivo real.
    assert data['key_file_example']['client_email'].endswith('.iam.gserviceaccount.com')
    assert 'private_key_id' in data['key_file_highlights']


def test_no_step_starts_out_completed(client):
    data = client.get('/api/v1/onboarding').json()

    assert data['completed_steps'] == []
    assert all(not p['completed'] for p in data['steps'])
    assert all(p['state'] != 'PASSED' for p in data['steps'])


def test_each_pending_step_says_what_is_missing_and_where_to_solve_it(client):
    """
    FR-017: sin las dos cosas, la persona sabe que algo anda mal y no dónde ir.

    El código es una sola cosa, y de él salen las dos frases: el catálogo tiene
    `stepReason.<CODE>.missing` y `stepReason.<CODE>.hint`. Que las dos existan
    para cada código lo comprueba `tests/unit/test_i18n.py`; lo que se defiende
    acá es que ningún paso pendiente llegue sin motivo.
    """
    data = client.get('/api/v1/onboarding').json()

    for published in data['steps']:
        assert published['reason'], f'{published["code"]} no dice qué falta'
        assert published['form_path'], f'{published["code"]} no ofrece el formulario completo'


def test_consulting_the_tour_does_not_ask_google_anything(client, account):
    """
    Mirar la pantalla no puede gastar cuota de nadie.

    La credencial sigue sin comprobar después de la consulta: si el recorrido
    resolviera sus pasos preguntando, abrir el asistente dos veces se llevaría dos
    comprobaciones del límite diario.
    """
    credential = create_credential(account, status=CredentialStatus.UNVERIFIED)

    data = client.get('/api/v1/onboarding').json()

    credential.refresh_from_db()
    assert credential.last_checked_at is None
    assert step(data, 'SERVICE_ACCOUNT_KEY')['state'] == 'MISSING'


def test_a_step_that_cannot_be_checked_yet_says_so_and_does_not_stop_the_tour(client):
    """
    Si la API está encendida sólo se sabe preguntándole a Google, y para preguntar
    hace falta la clave del paso 3. El paso queda `BLOCKED` y el asistente abre
    donde la persona sí puede hacer algo.
    """
    data = client.get('/api/v1/onboarding').json()

    api = step(data, 'ENABLE_API')
    assert api['state'] == 'BLOCKED'
    assert api['blocked_by'] == 'SERVICE_ACCOUNT_KEY'

    client.post(
        '/api/v1/onboarding/steps/GOOGLE_PROJECT/verify',
        {'project_id': TEST_KEY['project_id']},
        format='json',
    )
    after = client.get('/api/v1/onboarding').json()

    assert after['current_step'] == 'SERVICE_ACCOUNT_KEY'
    assert 'ENABLE_API' not in after['completed_steps']


# --- Comprobación de un paso ------------------------------------------------


def test_a_nonexistent_step_responds_404(client):
    api_response = client.post('/api/v1/onboarding/steps/PASO_INVENTADO/verify')

    assert api_response.status_code == 404
    assert api_response.json()['error']['code'] == 'NOT_FOUND'


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

    Un 4xx acá haría que un cliente trate «te falta dar de alta el dominio» como
    «armaste mal la petición», que son dos cosas que se arreglan en lugares
    distintos.
    """
    api_response = client.post('/api/v1/onboarding/steps/ADD_SITEMAP/verify')

    assert api_response.status_code == 200
    data = api_response.json()
    sitemap = step(data, 'ADD_SITEMAP')
    assert sitemap['state'] == 'BLOCKED'
    assert sitemap['completed'] is False
    assert 'ADD_SITEMAP' not in data['completed_steps']
    assert sitemap['reason'] == 'SITEMAP_NEEDS_DOMAIN'


def test_an_invalid_value_responds_422_naming_the_field(client):
    api_response = client.post(
        '/api/v1/onboarding/steps/GOOGLE_PROJECT/verify',
        {'project_id': 'Mi-Proyecto'},
        format='json',
    )

    assert api_response.status_code == 422
    error = api_response.json()['error']
    assert error['code'] == 'VALIDATION_FAILED'
    assert error['details']['field'] == 'project_id'
    # El motivo concreto viaja como código: `VALIDATION_FAILED` no dice nada que
    # se pueda escribir debajo del campo, y «tiene mayúsculas» sí.
    assert error['details']['reason'] == 'PROJECT_ID_UPPERCASE'


def test_the_first_step_is_fulfilled_with_the_project_identifier(client, account):
    api_response = client.post(
        '/api/v1/onboarding/steps/GOOGLE_PROJECT/verify',
        {'project_id': TEST_KEY['project_id']},
        format='json',
    )

    assert api_response.status_code == 200
    data = api_response.json()
    assert data['checked_step'] == 'GOOGLE_PROJECT'
    assert step(data, 'GOOGLE_PROJECT')['state'] == 'PASSED'
    assert step(data, 'GOOGLE_PROJECT')['completed_at']
    assert data['completed_steps'] == ['GOOGLE_PROJECT']
    assert data['created']['project_id'] == TEST_KEY['project_id']
    assert GoogleProject.objects.filter(account=account).count() == 1


def test_checking_one_step_returns_the_whole_tour(client, account, monkeypatch):
    """
    Una sola respuesta de Google resuelve tres pasos a la vez.

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

    data = client.post(
        '/api/v1/onboarding/steps/SERVICE_ACCOUNT_KEY/verify',
        {
            'project_id': TEST_KEY['project_id'],
            'key_file': json.dumps(TEST_KEY),
        },
        format='json',
    ).json()

    assert data['completed_steps'] == [
        'GOOGLE_PROJECT',
        'ENABLE_API',
        'SERVICE_ACCOUNT_KEY',
        'AUTHORIZE_PROPERTY',
    ]
    assert data['current_step'] == 'ADD_DOMAIN'
    assert data['created']['client_email'] == TEST_KEY['client_email']
    assert data['created']['properties'][0]['property_uri'] == 'sc-domain:ejemplo.test'


def test_the_key_material_never_comes_back_in_the_response(client, monkeypatch):
    """
    Lo que sube la persona se cifra y sigue de largo.

    El único bloque PEM que puede aparecer en la respuesta es el del ejemplo de
    `content.py`, que es texto inventado y está justamente para comparar.
    """
    with_google(monkeypatch, FakeClient(list_sites=response('sites_list')))

    data = client.post(
        '/api/v1/onboarding/steps/SERVICE_ACCOUNT_KEY/verify',
        {
            'project_id': TEST_KEY['project_id'],
            'key_file': json.dumps(TEST_KEY),
        },
        format='json',
    ).json()

    body = json.dumps(data)
    assert TEST_KEY['private_key'] not in body
    assert 'MIIsinValorReal' not in body
    assert 'encrypted_key' not in body


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


def test_dismissing_stops_offering_it_and_preserves_the_progress(client, account):
    client.post(
        '/api/v1/onboarding/steps/GOOGLE_PROJECT/verify',
        {'project_id': TEST_KEY['project_id']},
        format='json',
    )

    data = client.post('/api/v1/onboarding/dismiss').json()

    assert data['is_dismissed'] is True
    assert data['dismissed_at']
    assert data['should_offer'] is False
    assert data['completed_steps'] == ['GOOGLE_PROJECT']


def test_the_tour_stays_available_after_dismissing_it(client):
    client.post('/api/v1/onboarding/dismiss')

    data = client.get('/api/v1/onboarding').json()

    assert data['is_dismissed'] is True
    assert len(data['steps']) == len(content.STEP_CODES)
    assert data['current_step'] == 'GOOGLE_PROJECT'


def test_dismissing_twice_preserves_the_first_date(client):
    first = client.post('/api/v1/onboarding/dismiss').json()['dismissed_at']
    second = client.post('/api/v1/onboarding/dismiss').json()['dismissed_at']

    assert first == second


# --- Scope compartido de compañía -------------------------------------------


def test_the_tour_recognizes_the_company_project(client, account):
    foreign = create_account(email='ajena@ejemplo.test')
    GoogleProject.objects.create(account=foreign, project_id='proyecto-ajeno-1')

    data = client.get('/api/v1/onboarding').json()

    assert step(data, 'GOOGLE_PROJECT')['state'] == 'PASSED'
    assert OnboardingProgress.objects.filter(account=account).count() == 1


def test_without_credentials_the_tour_cannot_be_consulted():
    api_response = APIClient().get('/api/v1/onboarding')

    assert api_response.status_code == 401
    assert api_response.json()['error']['code'] == 'UNAUTHENTICATED'
