"""
Las tres pantallas del primer día: ingreso, conexión con Google y recorrido.

Lo que se defiende acá son las cuatro reglas que este grupo puede romper sin que
nada falle, y que ningún test de Django alcanza porque viven después de las props:

1. **Qué paso está abierto sale de la dirección.** Era estado de React, y por eso
   el botón Atrás no volvía al paso anterior ni se podía enlazar un paso.
2. **Los cuatro estados de comprobación conservan su palabra, su dibujo y su
   familia.** `BLOCKED` es neutro porque no falló nada y `UNCONFIRMED` es
   «todavía no preguntamos», y agruparlos con lo que sí está mal manda a
   insistir sobre algo que no depende de esa persona.
3. **Del archivo de clave se ven el nombre y el tamaño, y nada más** (RT-06).
   Ahora hay un solo componente que lo toca, así que hay un solo sitio donde
   comprobarlo.
4. **Las direcciones de Google se escriben una sola vez.** Estaban cuatro veces,
   y Google renombra pantallas de su consola sin avisar.

Las piezas que leen `usePage()` —la ficha de la credencial, las filas del
acordeón— no se pueden dibujar sin pantalla, así que de ésas se ejercita la regla
y no el marcado: es la regla la que se rompe en silencio.

Todo se pide de una sola vez: Node tarda más en arrancar que en contestar.
"""

import re

import pytest

from tests.frontend.harness import call, needs_frontend, phrase, render, resolve

GOOGLE_LINK = '@/components/GoogleConsoleLink'
KEY_FILE_FIELD = '@/components/KeyFileField'
PROPERTY_LIST = '@/components/AccessiblePropertyList'
SERVICE_ACCOUNT = '@/components/ServiceAccountAddress'
STEP_LIST = '@/components/StepList'
WIZARD = '@/pages/Onboarding/Wizard'

#: Las piezas cuyas props no viajan en JSON se arman en TypeScript.
FIXTURES = './fixtures'

pytestmark = needs_frontend

#: Los siete códigos, en el orden en que los manda el servidor.
CODES = [
    'GOOGLE_PROJECT',
    'ENABLE_API',
    'SERVICE_ACCOUNT_KEY',
    'AUTHORIZE_PROPERTY',
    'ADD_DOMAIN',
    'ADD_SITEMAP',
    'FIRST_BATCH',
]

CHECK_STATES = ['PASSED', 'MISSING', 'BLOCKED', 'UNCONFIRMED']


def step(state: str, *, completed: bool = False) -> dict:
    return {
        'code': 'ENABLE_API',
        'position': 2,
        'title': 'Habilitar la API de Search Console',
        'state': state,
        'completed': completed,
        'completed_at': None,
    }


PROPERTIES = [
    {'property_uri': 'sc-domain:ejemplo.test', 'permission': 'siteOwner'},
    {'property_uri': 'https://otro.test/', 'permission': 'siteFullUser'},
]


def open_step(address: str, current: str = 'ADD_DOMAIN', *, finished: bool = False) -> dict:
    """Qué paso abre la pantalla ante esa dirección y ese estado del recorrido."""
    return call(WIZARD, 'openStepCode', address, CODES, current, finished)


def href(address: str, code: str = 'ADD_DOMAIN') -> dict:
    return call(WIZARD, 'stepHref', address, code)


REQUESTS = {
    'open_from_the_address': open_step(
        '/search-console/onboarding?step=ADD_SITEMAP', 'GOOGLE_PROJECT'
    ),
    'open_without_a_parameter': open_step('/search-console/onboarding'),
    'open_with_an_invented_step': open_step('/search-console/onboarding?step=inventado'),
    'open_when_finished': open_step('/search-console/onboarding', 'FIRST_BATCH', finished=True),
    'open_when_finished_but_asked_for_a_step': open_step(
        '/search-console/onboarding?step=ADD_DOMAIN', 'FIRST_BATCH', finished=True
    ),
    'open_asking_for_the_closing_unfinished': open_step(
        '/search-console/onboarding?step=COMPLETION'
    ),
    'href_from_a_bare_address': href('/search-console/onboarding'),
    'href_replaces_the_step': href('/search-console/onboarding?step=GOOGLE_PROJECT'),
    'href_keeps_the_rest': href('/search-console/onboarding?desde=aviso'),
    **{
        f'presentation_{state}': call(STEP_LIST, 'stepPresentation', step(state))
        for state in CHECK_STATES
    },
    'presentation_of_a_completed_step_that_fails_today': call(
        STEP_LIST, 'stepPresentation', step('MISSING', completed=True)
    ),
    'presentation_of_an_unknown_state': call(STEP_LIST, 'stepPresentation', step('SOMETHING_NEW')),
    'key_file_empty': render(
        KEY_FILE_FIELD,
        'KeyFileField',
        name='key_file',
        label='Archivo de clave (.json)',
        file=None,
    ),
    'key_file_chosen': render(FIXTURES, 'ChosenKeyFile'),
    'size_in_bytes': call(KEY_FILE_FIELD, 'formatKeyFileSize', 800),
    'size_in_kilobytes': call(KEY_FILE_FIELD, 'formatKeyFileSize', 2458),
    # El separador decimal depende del idioma, así que la cifra esperada se le
    # pide al mismo formateador en vez de escribirse a mano.
    'number_800': call('@/lib/format', 'formatNumber', 800),
    'number_2_4': call('@/lib/format', 'formatNumber', 2.4),
    'properties': render(PROPERTY_LIST, 'AccessiblePropertyList', properties=PROPERTIES),
    'properties_with_warning': render(
        PROPERTY_LIST, 'AccessiblePropertyList', properties=PROPERTIES, showWarning=True
    ),
    'properties_all_owned': render(
        PROPERTY_LIST,
        'AccessiblePropertyList',
        properties=[PROPERTIES[0]],
        showWarning=True,
    ),
    'service_account': render(
        SERVICE_ACCOUNT,
        'ServiceAccountAddress',
        clientEmail='tools-patiospools@mi-proyecto-123456.iam.gserviceaccount.com',
    ),
    'url_project': call(GOOGLE_LINK, 'googleConsoleUrl', 'project', 'mi-proyecto-123456'),
    'url_enable_api': call(GOOGLE_LINK, 'googleConsoleUrl', 'enable-api', 'mi-proyecto-123456'),
    'url_enable_api_without_project': call(GOOGLE_LINK, 'googleConsoleUrl', 'enable-api', ''),
    'url_service_accounts': call(
        GOOGLE_LINK, 'googleConsoleUrl', 'service-accounts', 'mi-proyecto-123456'
    ),
    'url_search_console': call(
        GOOGLE_LINK, 'googleConsoleUrl', 'search-console', 'mi-proyecto-123456'
    ),
    'google_link': render(
        GOOGLE_LINK, 'GoogleConsoleLink', screen='project', children='Abrir Google Cloud'
    ),
}


@pytest.fixture(scope='module')
def screen() -> dict:
    return resolve(REQUESTS)


def text(markup: str) -> str:
    """El marcado sin etiquetas: lo que queda es lo que se lee."""
    return re.sub(r'<[^>]+>', '', markup)


def icons(markup: str) -> list[str]:
    return re.findall(r'lucide lucide-([a-z0-9-]+)', markup)


def tone(markup: str) -> list[str]:
    return re.findall(r'data-tone="([a-z]+)"', markup)


class TestTheOpenStepLivesInTheAddress:
    """
    Es la regla de la que cuelga todo lo demás del recorrido: sin ella, el botón
    Atrás no vuelve al paso anterior, no se puede enlazar «mirá el paso 4» y
    comprobar un paso tiene que arrastrar a mano qué estaba abierto.
    """

    def test_the_address_decides_which_step_is_open(self, screen):
        assert screen['open_from_the_address'] == 'ADD_SITEMAP'

    def test_without_a_parameter_it_opens_where_the_server_says(self, screen):
        """
        El paso vigente lo calcula el servidor y saltea los bloqueados: abrir uno
        que no se puede resolver sería pedirle a alguien algo que todavía no
        depende de él.
        """
        assert screen['open_without_a_parameter'] == 'ADD_DOMAIN'

    def test_a_step_that_does_not_exist_is_ignored_in_silence(self, screen):
        """Casi siempre es un enlace de otra versión de la pantalla."""
        assert screen['open_with_an_invented_step'] == 'ADD_DOMAIN'

    def test_with_the_tour_finished_it_opens_the_closing(self, screen):
        """
        Ya no queda ningún paso por resolver. Abrir el último dejaría una
        pantalla cuya acción principal sería volver a comprobar algo que ya está.
        """
        assert screen['open_when_finished'] == 'COMPLETION'

    def test_the_closing_does_not_win_over_an_explicit_step(self, screen):
        """Se puede volver a mirar cualquier paso después de terminar."""
        assert screen['open_when_finished_but_asked_for_a_step'] == 'ADD_DOMAIN'

    def test_the_closing_does_not_exist_before_finishing(self, screen):
        """Un enlace al cierre con el recorrido a medias no abre una pantalla vacía."""
        assert screen['open_asking_for_the_closing_unfinished'] == 'ADD_DOMAIN'

    def test_the_address_of_a_step_replaces_the_one_that_was_there(self, screen):
        assert screen['href_from_a_bare_address'] == '/search-console/onboarding?step=ADD_DOMAIN'
        assert screen['href_replaces_the_step'].count('step=') == 1
        assert 'step=ADD_DOMAIN' in screen['href_replaces_the_step']

    def test_opening_another_step_does_not_lose_the_rest_of_the_address(self, screen):
        assert 'desde=aviso' in screen['href_keeps_the_rest']
        assert 'step=ADD_DOMAIN' in screen['href_keeps_the_rest']


class TestTheFourCheckStatesStaySeparate:
    """
    Son cuatro y no dos. «Bloqueado» y «sin confirmar» no son pendientes con otro
    nombre, y mostrarlos como tales manda a insistir sobre un paso que no depende
    de esa persona.
    """

    @pytest.mark.parametrize('state', CHECK_STATES)
    def test_every_state_has_its_own_word(self, screen, state):
        assert screen[f'presentation_{state}']['word']

    def test_no_two_states_share_a_word(self, screen):
        words = [screen[f'presentation_{state}']['word'] for state in CHECK_STATES]

        assert len(set(words)) == len(words), f'Dos estados dicen lo mismo: {words}.'

    def test_a_blocked_step_is_neutral_and_never_a_failure(self, screen):
        """No falló nada: falta un paso previo, que es otra cosa."""
        assert screen['presentation_BLOCKED']['tone'] == 'neutral'

    def test_an_unconfirmed_step_is_the_family_of_what_we_did_not_ask(self, screen):
        """
        RT-03 aplicado a un paso: Google no contestó, así que no sabemos. Teñirlo
        de ámbar lo agruparía con lo que sí está mal, y eso sería afirmar algo
        que nadie comprobó.
        """
        assert screen['presentation_UNCONFIRMED']['tone'] == 'unknown'

    def test_only_a_passed_step_is_positive(self, screen):
        positives = [
            state for state in CHECK_STATES if screen[f'presentation_{state}']['tone'] == 'positive'
        ]

        assert positives == ['PASSED']

    def test_what_the_server_gave_as_done_stays_done(self, screen):
        """
        Lo cumplido lo dice el progreso y no la comprobación viva: un paso ya
        hecho que hoy vuelve a fallar sigue estando hecho, porque borrarlo
        convertiría una incidencia pasajera en trabajo perdido (FR-017).
        """
        assert screen['presentation_of_a_completed_step_that_fails_today']['tone'] == 'positive'

    def test_a_state_that_is_not_in_the_map_is_drawn_as_pending(self, screen):
        """Nunca en blanco: una fila sin palabra de estado no se distingue de ninguna."""
        assert screen['presentation_of_an_unknown_state']['word']


class TestOnlyTheNameAndTheSizeOfTheKeyFile:
    """
    RT-06 con un solo sitio donde comprobarse: `KeyFileField` es el único
    componente del producto que toca el archivo de clave. Antes eran dos, así que
    la regla dependía de que las dos copias se acordaran de cumplirla.
    """

    def test_the_chosen_file_shows_its_name_and_its_size(self, screen):
        """
        Alcanza para darse cuenta de que se eligió otro: una clave real pesa unos
        dos kilobytes.
        """
        readable = text(screen['key_file_chosen'])

        assert 'mi-proyecto-123456-a1b2c3d4e5f6.json' in readable
        assert 'KB' in readable

    def test_not_one_byte_of_what_is_inside_reaches_the_screen(self, screen):
        """Ni entero, ni recortado, ni ofuscado."""
        markup = screen['key_file_chosen']

        assert 'PRIVATE KEY' not in markup
        assert 'MIIsinValorReal' not in markup
        assert 'service_account' not in markup

    def test_the_real_field_stays_visible_inside_the_drop_zone(self, screen):
        """
        Arrastrar es un agregado. Al revés —zona grande, campo escondido detrás
        de CSS— deja el único camino accesible detrás de un truco, y ningún paso
        puede depender de arrastrar y soltar (RT-15).
        """
        field = re.search(r'<input[^>]*type="file"[^>]*>', screen['key_file_empty'])

        assert field, 'La zona de carga no rindió ningún campo de archivo.'
        assert not re.search(r'sr-only|\bhidden\b|opacity-0', field.group(0)), (
            'El campo de archivo quedó escondido detrás de CSS.'
        )

    def test_the_invitation_to_drag_is_not_read_aloud(self, screen):
        """Para quien usa un lector de pantalla es ruido sobre algo que no puede hacer."""
        invitation = re.escape(phrase('keyFile.drop'))

        assert re.search(rf'aria-hidden="true"[^>]*>{invitation}', screen['key_file_empty'])

    def test_the_encryption_is_always_said(self, screen):
        """Es lo que explica por qué no la vamos a volver a mostrar."""
        assert 'Guardamos la clave cifrada' in text(screen['key_file_empty'])

    def test_the_size_uses_the_unit_that_lets_you_compare(self, screen):
        # El separador decimal lo pone `Intl` con el idioma activo —«2.4» en
        # inglés, «2,4» en español—, así que la cifra se arma con el mismo
        # formateador en lugar de escribirse a mano.
        assert screen['size_in_bytes'] == phrase('keyFile.bytes', value=screen['number_800'])
        assert screen['size_in_kilobytes'] == phrase(
            'keyFile.kilobytes', value=screen['number_2_4']
        )


class TestThePropertiesSayTheirPermission:
    """
    No son intercambiables: con cualquiera que no sea propietario la inspección
    de URLs devuelve error y Google no explica por qué. Una lista sin el permiso
    al lado se lee como «esto ya funciona» cuando puede no funcionar.
    """

    def test_each_property_carries_its_permission_in_words(self, screen):
        readable = text(screen['properties'])

        assert 'sc-domain:ejemplo.test' in readable
        assert phrase('permission.siteOwner') in readable
        assert phrase('permission.siteFullUser') in readable

    def test_only_the_owner_permission_is_presented_as_enough(self, screen):
        assert tone(screen['properties']) == ['positive', 'attention']

    def test_two_permissions_never_share_a_drawing(self, screen):
        """En escala de grises, dos permisos con el mismo icono son el mismo permiso."""
        drawn = icons(screen['properties'])

        assert len(set(drawn)) == len(drawn), f'Dos permisos comparten dibujo: {drawn}.'

    def test_it_is_a_real_list(self, screen):
        """
        Un lector de pantalla anuncia cuántas propiedades hay antes de leer la
        primera, que es la única forma de saber si la conexión alcanza el sitio
        entero o una parte.
        """
        assert '<ul' in screen['properties'] and '<li' in screen['properties']

    def test_the_consequence_is_explained_only_where_it_can_be_acted_on(self, screen):
        assert phrase('property.notOwner.one') in text(screen['properties_with_warning'])
        assert phrase('property.notOwner.consequence') not in text(screen['properties'])

    def test_with_everything_owned_there_is_nothing_to_warn_about(self, screen):
        assert phrase('property.notOwner.consequence') not in text(screen['properties_all_owned'])

    def test_a_property_uri_is_never_translated(self, screen):
        """Un traductor que le cambie una letra deja pegando un identificador que no existe."""
        assert 'translate="no"' in screen['properties']


class TestTheAddressToAuthorizeIsReadyToPaste:
    def test_the_address_comes_with_its_copy_button(self, screen):
        readable = text(screen['service_account'])

        assert 'tools-patiospools@mi-proyecto-123456.iam.gserviceaccount.com' in readable
        assert phrase('serviceAccount.copy') in readable

    def test_the_permission_is_said_with_all_its_letters(self, screen):
        """
        Es la parte del paso que más se equivoca: con un permiso menor los
        sitemaps funcionan igual y la inspección de URLs no, y Google no avisa.
        """
        readable = text(screen['service_account'])

        # El énfasis del catálogo se marca con asteriscos y `RichText` los
        # convierte en negrita, así que en el texto plano ya no están.
        assert phrase('serviceAccount.where').replace('*', '') in readable

    def test_it_draws_no_box_of_its_own(self, screen):
        """El borde es de la `Card` que la contiene, no de la pieza."""
        assert 'rounded-lg border p-4' not in screen['service_account']


class TestTheGoogleAddressesAreWrittenOnce:
    """
    Estaban cuatro veces —el mapa del recorrido, tres constantes en
    Configuración, el mapa de errores y dos copias de la misma función—. Google
    renombra pantallas de su consola sin avisar, y con cuatro copias la que se
    actualiza es la que alguien miró ese día.
    """

    def test_the_project_travels_where_the_screen_accepts_it(self, screen):
        assert 'project=mi-proyecto-123456' in screen['url_enable_api']
        assert 'project=mi-proyecto-123456' in screen['url_service_accounts']

    def test_search_console_does_not_know_about_projects(self, screen):
        """No es parte de Google Cloud: pegarle un proyecto sería una dirección inventada."""
        assert screen['url_search_console'] == 'https://search.google.com/search-console'

    def test_creating_a_project_cannot_carry_one(self, screen):
        """Es lo que se hace justamente cuando todavía no hay ninguno."""
        assert 'project=' not in screen['url_project']

    def test_without_a_project_the_address_still_works(self, screen):
        assert screen['url_enable_api_without_project'].endswith('searchconsole.googleapis.com')

    def test_the_link_says_that_it_leaves_the_application(self, screen):
        """
        Quien está a mitad de un paso no puede perder la pantalla que le explica
        cómo hacerlo, y quien usa un lector de pantalla tiene que enterarse antes
        de apretar.
        """
        markup = screen['google_link']

        assert 'target="_blank"' in markup
        assert 'rel="noreferrer noopener"' in markup
        assert phrase('common.opensInNewTab') in text(markup)
