"""
Las piezas compuestas del rediseño, defendidas por su conducta.

Estas nueve piezas no se ven: las van a consumir las trece vistas, y una regla
que se afloje acá se afloja trece veces. Lo que se fija es lo que ninguna vista
tiene que volver a decidir —dónde va lo destructivo, qué cierra un formulario,
qué parámetro escribe cada tabla— y no el dibujo, que cambia.

Tres de las piezas se dibujan en un portal —el menú desplegable, el panel y el
diálogo—, y un portal no existe fuera del navegador: de un render estático sale
el disparador y nada más. Por eso las reglas de esas tres viven en funciones que
se pueden llamar sin pantalla —`rowActionEntries`, `isOpenFromAddress`,
`addressWithout`— y son ésas las que se ejercitan acá. Es a propósito: son
reglas sobre el orden y sobre la dirección, no sobre píxeles.

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

ADDRESS = '@/components/address'
DANGER_ZONE = '@/components/DangerZone'
FILTER_BAR = '@/components/FilterBar'
FILTER_CHIPS = '@/components/FilterChips'
FILTER_SELECT = '@/components/FilterSelect'
FORM_DIALOG = '@/components/FormDialog'
NOTICE_ITEM = '@/components/NoticeItem'
ROW_ACTIONS = '@/components/RowActions'

#: Las tablas se arman en TypeScript: sus columnas son funciones y no viajan en JSON.
FIXTURES = './fixtures'

pytestmark = needs_frontend

#: Una página de resultados con la forma que devuelve `table_props()`.
COVERAGE_PAGE = {
    'rows': [],
    'count': 1243,
    'page': 1,
    'pages': 1,
    'per_page': 50,
    'sort': '',
    'sortable': [],
    'filters': {'state': 'INDEXED'},
    'path': '/coverage/1',
    'query': {'state': 'INDEXED'},
}

BATCHES_PAGE = {**COVERAGE_PAGE, 'filters': {}, 'path': '/search-console/batches', 'query': {}}

REQUESTS = {
    'row_actions': render(
        ROW_ACTIONS,
        'RowActions',
        label='Acciones de la clave “CI de producción”',
        actions={
            'rename': {'onSelect': None, 'href': '/api-keys/k1/rename'},
            'open': {'href': '/api-keys/k1'},
            'revoke': {
                'href': '/api-keys/k1/revoke',
                'confirm': {
                    'title': '¿Revocar la clave “CI de producción”?',
                    'consequence': 'Todo lo que la use deja de entrar.',
                    'confirmLabel': 'Revocar la clave',
                },
            },
        },
    ),
    # El mapa se escribe en un orden y el menú lo rinde en el suyo: es lo que
    # hace que dos tablas ofrezcan lo mismo en la misma posición.
    'entries_with_reading_items': call(
        ROW_ACTIONS,
        'rowActionEntries',
        {
            'rename': {'href': '/rename'},
            'detail': {'href': '/detail'},
            'revoke': {'href': '/revoke'},
        },
    ),
    'entries_only_destructive': call(ROW_ACTIONS, 'rowActionEntries', {'revoke': {'href': '/x'}}),
    'entries_without_destructive': call(
        ROW_ACTIONS, 'rowActionEntries', {'detail': {'href': '/detail'}}
    ),
    'entries_without_anything_to_do': call(
        ROW_ACTIONS, 'rowActionEntries', {'detail': {}, 'rename': {'label': 'Renombrar'}}
    ),
    'entries_use_the_catalog_label': call(
        ROW_ACTIONS, 'rowActionEntries', {'detail': {'href': '/detail'}}
    ),
    'entries_with_a_label_of_its_own': call(
        ROW_ACTIONS,
        'rowActionEntries',
        {'list': {'href': '/batches', 'label': 'Ver todos los lotes de ejemplo.com'}},
    ),
    'row_actions_without_anything': render(
        ROW_ACTIONS, 'RowActions', label='Acciones de la fila', actions={}
    ),
    'address_without_param': call(
        ADDRESS,
        'addressWithout',
        '/coverage/1?url=https%3A%2F%2Fa.test&state=INDEXED&page=3',
        'url',
    ),
    'address_without_the_only_param': call(ADDRESS, 'addressWithout', '/coverage/1?url=x', 'url'),
    'address_keeps_the_fragment': call(ADDRESS, 'addressWithout', '/coverage/1?url=x#tabla', 'url'),
    'address_of_something_absent': call(ADDRESS, 'addressWithout', '/coverage/1?page=2', 'url'),
    'open_before_sending': call(FORM_DIALOG, 'isOpenFromAddress', '/api-keys?new', 'new'),
    'open_after_an_error': call(FORM_DIALOG, 'isOpenFromAddress', '/api-keys?new=&page=2', 'new'),
    'open_after_success': call(FORM_DIALOG, 'isOpenFromAddress', '/api-keys', 'new'),
    'danger_zone': render(
        DANGER_ZONE,
        'DangerZone',
        title='Zona de peligro',
        description='Lo que se hace acá no se deshace.',
        children='las acciones',
    ),
    'notice_item': render(
        NOTICE_ITEM,
        'NoticeItem',
        badge='Falló',
        title='No pudimos consultar ejemplo.test porque Google devolvió un error de permisos '
        'y la credencial dejó de servir para esa propiedad',
        description='Revisá que la cuenta de servicio siga con acceso a la propiedad.',
        meta='anteayer',
    ),
    'notice_item_muted': render(NOTICE_ITEM, 'NoticeItem', title='Ya lo leíste', tone='muted'),
    'filter_bar': render(
        FILTER_BAR,
        'FilterBar',
        appliedSummary='Mostrando las indexadas',
        clearHref='/coverage/1',
        children='los ejes',
    ),
    'filter_bar_without_filters': render(FILTER_BAR, 'FilterBar', children='los ejes'),
    # Sin recorte puesto: `COVERAGE_PAGE` ya viene con uno.
    'filter_select': render(
        FILTER_SELECT,
        'FilterSelect',
        label='Estado informado por Google',
        param='state',
        table={**COVERAGE_PAGE, 'filters': {}, 'query': {}},
        allLabel='Cualquiera',
        options=[
            {'value': 'INDEXED', 'label': 'Indexada', 'count': 1240},
            {'value': 'BLOCKED_ROBOTS', 'label': 'Bloqueada', 'count': 3},
        ],
    ),
    'filter_select_applied': render(
        FILTER_SELECT,
        'FilterSelect',
        label='Estado informado por Google',
        param='state',
        table=COVERAGE_PAGE,
        allLabel='Cualquiera',
        options=[
            {'value': 'INDEXED', 'label': 'Indexada', 'count': 1240},
            {'value': 'BLOCKED_ROBOTS', 'label': 'Bloqueada', 'count': 3},
        ],
    ),
    # Las tres direcciones que arma el eje. No se pueden leer del marcado: las
    # opciones viven adentro del popover, que sin abrir no se dibuja.
    'href_all': call(FILTER_SELECT, 'optionHref', COVERAGE_PAGE, 'state', ''),
    'href_option': call(FILTER_SELECT, 'optionHref', COVERAGE_PAGE, 'state', 'INDEXED'),
    'href_clearing': call(FILTER_SELECT, 'optionHref', COVERAGE_PAGE, 'state', 'INDEXED', 'group'),
    'filter_chips': render(
        FILTER_CHIPS,
        'FilterChips',
        label='Estado del lote',
        param='state',
        current='FAILED',
        page=BATCHES_PAGE,
        options=[
            {'value': 'FAILED', 'label': 'Falló'},
            {'value': 'COMPLETED', 'label': 'Completado'},
        ],
    ),
    'two_tables': render(FIXTURES, 'TwoTables'),
    'plain_table': render(FIXTURES, 'PlainTable'),
}


@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 links(markup: str) -> list[str]:
    """Los destinos que ofrece el marcado, en orden y ya sin escapar."""
    return [href.replace('&amp;', '&') for href in re.findall(r'href="([^"]*)"', markup)]


class TestTheDestructiveActionIsAlwaysLastAndAlwaysSeparated:
    """
    El orden del menú de una fila no lo decide cada vista.

    Un «Revocar» pegado a «Renombrar» se toca por error, y en una tabla de
    veinte filas se toca el de la fila de al lado. Lo de leer arriba, una línea,
    y lo que destruye al final: siempre igual, en las cinco tablas con acciones.
    """

    def test_the_destructive_one_closes_the_menu_behind_a_separator(self, screen):
        assert [entry['kind'] for entry in screen['entries_with_reading_items']] == [
            'action',
            'action',
            'separator',
            'destructive',
        ]

    def test_the_menu_is_ordered_by_the_registry_and_not_by_the_view(self, screen):
        """
        El mapa se escribió `rename` primero y el menú lo rinde después de
        `detail`, que es lo que declara el registro.

        Ésa es la mitad de la regla que hace que el menú se reconozca sin leerlo:
        dos tablas que ofrecen lo mismo lo ofrecen en la misma posición. La otra
        mitad es que el icono también sale del registro.
        """
        actions = [
            entry['item']['action']
            for entry in screen['entries_with_reading_items']
            if entry['kind'] == 'action'
        ]

        assert actions == ['detail', 'rename']

    def test_an_action_without_anything_to_do_is_not_drawn(self, screen):
        """
        Sin destino y sin manejador no lleva a ningún lado ni hace nada, y una
        entrada inerte es peor que su ausencia: se elige, no pasa nada, y no
        queda claro si falló o si nunca hizo algo (RT-07).

        Es lo que permite que una vista arme el mapa de una sola forma para todas
        sus filas y deje en blanco lo que esa fila no ofrece.
        """
        assert screen['entries_without_anything_to_do'] == []

    def test_a_row_with_nothing_to_offer_draws_no_trigger(self, screen):
        """Un botón que abre un menú vacío es un botón que no hace nada."""
        assert screen['row_actions_without_anything'] == ''

    def test_the_word_comes_from_the_registry_so_every_table_says_the_same(self, screen):
        """
        El rótulo por omisión es del registro y no de la vista: «ver el detalle»
        tiene que decirse igual en las nueve tablas del producto.
        """
        entry = screen['entries_use_the_catalog_label'][0]

        assert entry['item']['label'] == phrase('rowAction.detail')

    def test_a_view_can_override_the_word_when_it_has_to_name_the_object(self, screen):
        """
        La excepción, y por qué existe: «Ver la lista completa» no distingue la
        lista de lotes de un dominio de la de otro. El icono y la posición los
        sigue poniendo el registro.
        """
        entry = screen['entries_with_a_label_of_its_own'][0]

        assert entry['item']['label'] == 'Ver todos los lotes de ejemplo.com'
        assert entry['item']['action'] == 'list'

    def test_a_lonely_destructive_one_does_not_get_a_separator(self, screen):
        """
        Un separador arriba de todo es una línea debajo de la nada: existe para
        marcar que lo que sigue es de otra clase, y con un solo ítem no hay dos
        clases que separar.
        """
        assert [entry['kind'] for entry in screen['entries_only_destructive']] == ['destructive']

    def test_a_menu_without_anything_destructive_has_no_separator(self, screen):
        assert [entry['kind'] for entry in screen['entries_without_destructive']] == ['action']

    def test_the_trigger_says_which_row_it_belongs_to(self, screen):
        """
        Veinte disparadores llamados «Acciones» obligan a recorrer la tabla
        celda por celda para saber cuál es cuál.
        """
        markup = screen['row_actions']

        assert 'Acciones de la clave “CI de producción”' in text(markup)
        assert 'sr-only' in markup, 'El nombre del disparador se dibujó al lado del icono.'


class TestAPanelThatLivesInTheAddress:
    """
    `DetailSheet` y `FormDialog` abren desde un parámetro de la dirección, y de
    ahí sale todo lo demás: se pueden enlazar, el botón Atrás los cierra y
    recargar no los pierde. Cerrarlos es sacar el parámetro y nada más.
    """

    def test_closing_a_panel_only_takes_away_its_own_parameter(self, screen):
        """
        Cerrar el detalle no es volver a cargar la pantalla: el filtro y la
        página siguen donde estaban.
        """
        assert screen['address_without_param'] == '/coverage/1?state=INDEXED&page=3'

    def test_closing_the_last_panel_leaves_a_clean_address(self, screen):
        assert screen['address_without_the_only_param'] == '/coverage/1'

    def test_closing_a_panel_keeps_the_fragment(self, screen):
        assert screen['address_keeps_the_fragment'] == '/coverage/1#tabla'

    def test_closing_something_that_was_not_open_changes_nothing(self, screen):
        assert screen['address_of_something_absent'] == '/coverage/1?page=2'


class TestAFormDoesNotCloseWhenItIsSent:
    """
    La regla entera de `FormDialog`, y la única que no negocia.

    Un formulario que guarda su apertura en React se cierra al enviar, y
    entonces el error del servidor llega a una pantalla donde el campo que lo
    causó ya no está: hay que volver a abrirlo, volver a escribir y adivinar qué
    pasó. Con la apertura en la dirección, el redirect con error vuelve a la
    misma dirección y el formulario sigue abierto.
    """

    def test_it_is_open_while_the_parameter_is_in_the_address(self, screen):
        assert screen['open_before_sending'] is True

    def test_an_error_comes_back_to_the_same_address_and_it_stays_open(self, screen):
        assert screen['open_after_an_error'] is True, (
            'Volvió un error y el formulario se cerró: el mensaje quedó sin campo al lado.'
        )

    def test_the_server_closes_it_by_redirecting_without_the_parameter(self, screen):
        assert screen['open_after_success'] is False


class TestTheDangerousActionsHaveTheirOwnPlaceAndNoBox:
    def test_the_danger_zone_titles_itself_in_destructive_tone(self, screen):
        markup = screen['danger_zone']

        assert '<h2' in markup
        assert 'text-destructive' in markup
        assert 'Zona de peligro' in text(markup)

    def test_the_danger_zone_draws_no_box_of_its_own(self, screen):
        """
        Un marco rojo alrededor de tres acciones se vuelve paisaje a la tercera
        vez que se entra a la pantalla. Lo destructivo lo dice el título y lo
        dice el botón de cada acción.
        """
        markup = screen['danger_zone']

        assert 'rounded-lg border' not in markup
        assert 'rounded-xl border' not in markup


class TestANoticeIsTheSameObjectEverywhere:
    def test_a_notice_does_not_cut_what_it_has_to_say(self, screen):
        """
        La primitiva viene con `line-clamp`. Un aviso cuya única línea es «No
        pudimos consultar…» cortado en seco es un aviso que hay que abrir para
        entender, y en el tablero no hay dónde abrirlo.
        """
        markup = screen['notice_item']

        assert 'line-clamp-none' in markup
        assert 'y la credencial dejó de servir para esa propiedad' in text(markup)
        assert 'line-clamp-1' not in markup
        assert 'line-clamp-2' not in markup

    def test_a_notice_carries_no_border_of_its_own(self, screen):
        """
        Cinco avisos con marco al lado son cinco cajas. La caja es de la `Card`
        o de la `Section` que contiene a la lista.
        """
        assert 'border-transparent' in screen['notice_item']

    def test_something_already_read_still_shows_and_weighs_less(self, screen):
        markup = screen['notice_item_muted']

        assert 'Ya lo leíste' in text(markup)
        assert 'data-variant="muted"' in markup


class TestTheFiltersAreLinksAndNotHandlers:
    def test_the_region_of_filters_is_named_without_drawing_a_heading(self, screen):
        """
        Una región de filtros no lleva encabezado a la vista, pero tiene nombre.

        Antes ese nombre era un `<h2 class="sr-only">`, y un encabezado invisible
        sigue siendo un hijo del flex: no dibujaba nada y cobraba igual su parte
        del `gap`, dejando un hueco arriba de los filtros. Ahora el título viaja
        en un `aria-label` de la sección y no se rinde nada.
        """
        markup = screen['filter_bar']

        assert '<h2' not in markup
        assert 'sr-only' not in markup
        assert f'aria-label="{phrase("table.filters")}"' in markup

    def test_the_way_out_only_appears_when_something_is_applied(self, screen):
        """«Limpiar todo» sin nada que limpiar es un control que no hace nada."""
        assert phrase('table.clearFilters') in text(screen['filter_bar'])
        assert 'Mostrando las indexadas' in text(screen['filter_bar'])
        assert phrase('table.clearFilters') not in text(screen['filter_bar_without_filters'])

    def test_every_option_is_a_real_destination(self, screen):
        """
        El recorte vive en la dirección, así que cada opción tiene dirección
        propia y se abre en otra pestaña, se copia y se guarda. Un manejador de
        clic no tiene nada de eso.

        Se comprueba sobre la función y no sobre el marcado porque las opciones
        viven adentro del popover, que sin abrir no se dibuja.
        """
        assert screen['href_option'] == '/coverage/1?state=INDEXED'

    def test_the_option_without_filter_removes_the_parameter(self, screen):
        """
        «Todas» es la ausencia del parámetro y no un valor vacío: `?state=` deja
        una dirección que dice que hay un recorte puesto cuando no lo hay.
        """
        assert screen['href_all'] == '/coverage/1'

    def test_choosing_an_option_goes_back_to_the_first_page(self, screen):
        """La página 7 de otro conjunto de filas no significa nada."""
        assert 'page=' not in screen['href_option']

    def test_an_axis_can_turn_off_another_that_asks_the_same_thing(self, screen):
        assert 'group=' not in screen['href_clearing']

    def test_the_trigger_says_what_is_applied_without_opening(self, screen):
        """
        Un filtro que sólo se ve al desplegar es un filtro que se olvida puesto,
        y una tabla recortada se lee como el total del sitio.
        """
        assert 'Indexada' in text(screen['filter_select_applied'])

    def test_without_a_filter_the_trigger_says_so_with_its_own_word(self, screen):
        """
        En reposo el disparador dice «Cualquiera» y no queda vacío: un control
        sin texto no dice si está sin usar o si no cargó.
        """
        assert 'Cualquiera' in text(screen['filter_select'])

    def test_the_axis_is_named_outside_the_trigger(self, screen):
        """
        El nombre del eje va arriba, en su etiqueta, y el disparador se queda con
        el valor. Los dos adentro obligan a leer «Estado: Indexada» en cada uno
        de los tres ejes de la fila para saber cuál es cuál.
        """
        assert 'Estado informado por Google' in text(screen['filter_select'])

    def test_the_state_filters_are_the_same_piece_with_the_value_from_outside(self, screen):
        """
        Los tres filtros de estado del producto son hoy tres implementaciones del
        mismo control. `FilterChips` no dibuja nada propio: es el mismo eje con
        el valor vigente puesto a mano, porque no siempre es un filtro declarado
        de la tabla.
        """
        # El que está puesto es el que llegó por `current`, no el de la tabla:
        # `BATCHES_PAGE` no declara ningún filtro y el disparador dice «Falló».
        assert 'Falló' in text(screen['filter_chips'])
        assert 'Estado del lote' in text(screen['filter_chips'])


class TestTwoListsInOnePageDoNotStepOnEachOther:
    """
    `paramPrefix` y su contraparte `table_props(prefix=)`.

    Sin prefijo las dos listas leen `page` y `sort` de la misma querystring: ir a
    la página 2 de una manda a la otra a su página 2, y ordenar una desordena las
    dos. Con prefijo cada una escribe lo suyo **y arrastra la posición de la
    otra**, que es la otra mitad de convivir.
    """

    def test_each_list_writes_its_own_page(self, screen):
        destinations = links(screen['two_tables'])

        assert '/api-keys?active_page=3&revoked_sort=-name' in destinations
        assert '/api-keys?active_page=2&revoked_sort=-name&revoked_page=2' in destinations

    def test_each_list_writes_its_own_sort(self, screen):
        destinations = links(screen['two_tables'])

        assert '/api-keys?revoked_sort=-name&active_sort=name' in destinations
        assert '/api-keys?active_page=2&revoked_sort=uses' in destinations

    def test_moving_one_list_never_writes_the_bare_parameters(self, screen):
        """
        Un solo `page` sin prefijo que sobreviva alcanza para que las dos listas
        vuelvan a pelearse la posición.
        """
        for destination in links(screen['two_tables']):
            assert '?page=' not in destination and '&page=' not in destination
            assert '?sort=' not in destination and '&sort=' not in destination

    def test_ordering_one_list_sends_it_to_its_first_page_and_leaves_the_other(self, screen):
        """
        La página 7 de otro conjunto de filas no significa nada. La de la otra
        lista, en cambio, no se tocó: no se ordenó nada suyo.
        """
        destinations = links(screen['two_tables'])
        ordering_the_active_one = '/api-keys?revoked_sort=-name&active_sort=name'

        assert ordering_the_active_one in destinations
        assert 'active_page' not in ordering_the_active_one

    def test_a_list_without_a_prefix_keeps_writing_what_it_always_wrote(self, screen):
        """
        Las seis tablas de hoy no pasan prefijo, y no se enteran de que existe.
        """
        assert links(screen['plain_table']) == [
            '/api-keys?sort=name',
            '/api-keys?sort=uses',
            '/api-keys',
            '/api-keys?page=3',
        ]


class TestANumericColumnIsNumericInBothPlaces:
    """
    La regla de las columnas numéricas se rompía fila por fila porque estaba
    escrita dos veces —una en la celda y otra en el encabezado— y alcanzaba con
    olvidarse de una. `columnHelper.numeric()` la declara una vez y la tabla la
    aplica en los dos lugares.
    """

    def cells(self, markup: str, tag: str) -> list[str]:
        return re.findall(rf'<{tag}[^>]*>', markup)

    def test_the_header_of_a_numeric_column_is_aligned_with_its_column(self, screen):
        """
        Un encabezado a la izquierda arriba de cifras a la derecha deja el nombre
        lejos de lo que nombra.
        """
        headers = self.cells(screen['plain_table'], 'th')
        numeric = [header for header in headers if 'text-right tabular-nums' in header]

        assert len(numeric) == 1, 'El encabezado de la columna de cifras no se alineó con ella.'
        assert 'text-left' not in numeric[0]

    def test_the_cells_of_a_numeric_column_are_right_aligned_and_tabular(self, screen):
        """
        `tabular-nums` es lo que permite comparar dos cifras mirándolas, sin
        leerlas: sin él, 1.240 y 8 no comparten ninguna columna de dígitos.
        """
        numeric = [
            cell
            for cell in self.cells(screen['plain_table'], 'td')
            if 'text-right tabular-nums' in cell
        ]

        assert len(numeric) == 2, 'Las celdas de cifras no salieron alineadas a la derecha.'

    def test_a_column_that_is_not_numeric_is_left_alone(self, screen):
        headers = self.cells(screen['plain_table'], 'th')

        assert any('text-left' in header for header in headers)
