"""
Las dos pantallas por las que se entra sin haber elegido entrar: V0 y V13.

Lo que se defiende acá no es el dibujo sino las tres reglas que la primera
pantalla puede romper sin que nada falle: **qué está abierto vive en la
dirección**, **el día de un aviso se corta en la zona de la cuenta**, y **la
frase que acompaña a una cifra no dice más de lo que la cifra sabe**.

Ninguna de las tres se puede comprobar leyendo el archivo con una expresión
regular: la primera es una función sobre una querystring con otras claves
adentro, la segunda depende de la zona horaria y la tercera es la oración que
sale, no el `if` que la elige.

Van por `call` y no por `render` a propósito. Las dos vistas y sus piezas leen
`usePage()` —la zona de presentación, el estado de la cuenta, el contador del
menú—, y un render estático no tiene de dónde sacar esas props: lo que se puede
ejercitar sin pantalla es la regla, y es la regla la que se rompe en silencio.
El dibujo de las piezas que estas vistas componen —`NoticeItem`, `MetricCard`,
`StatusBadge`, `PageIntro`— ya está defendido en `test_molecules.py` y en
`test_design_system.py`.

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

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

import pytest

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

DASHBOARD = '@/pages/Dashboard'
NOTIFICATIONS = '@/pages/Notifications'
SECTION_CARDS = '@/components/section-cards'

pytestmark = needs_frontend

#: La zona de la cuenta en las pruebas. Está tres horas detrás de UTC, que es lo
#: que hace visible el error de cortar el día en UTC: entre las 21 y la
#: medianoche local, el instante ya pertenece al día siguiente en UTC.
ZONE = 'America/Argentina/Buenos_Aires'


def at(when: str) -> str:
    """Un instante en la zona de la cuenta, escrito como lo emite el servidor."""
    return datetime.fromisoformat(when).replace(tzinfo=ZoneInfo(ZONE)).isoformat()


def day_in_zone(moment: datetime) -> str:
    return moment.astimezone(ZoneInfo(ZONE)).date().isoformat()


#: Tres avisos: dos del mismo día y uno del anterior, en el orden en que los
#: manda el servidor —del más nuevo al más viejo—.
ROWS = [
    {'id': 'a', 'created_at': at('2026-08-19T09:00:00')},
    {'id': 'b', 'created_at': at('2026-08-19T08:00:00')},
    {'id': 'c', 'created_at': at('2026-08-18T23:30:00')},
]

COVERAGE = {
    'total': 120,
    'with_data': 92,
    'without_data': 28,
    'indexed': 62,
    'by_state': {'INDEXED': 62, 'CRAWLED_NOT_INDEXED': 30},
    'domains': [],
    'domains_with_urls': 1,
    'frozen_domains': 0,
    'last_checked_at': at('2026-08-19T07:00:00'),
}

NOTHING_QUERIED = {**COVERAGE, 'with_data': 0, 'without_data': 120, 'indexed': 0, 'by_state': {}}

#: Una cuenta con la conexión caída **y** un dominio esperando autorización, que
#: es el estado que el owner miró y del que salió la regla de no repetir.
ATTENTION = {
    'credential': {'has_credential': True, 'status': 'INVALID', 'error_code': 'INVALID_GRANT'},
    'domains': [
        {
            'id': 'd1',
            'hostname': 'ejemplo.test',
            'access_state': 'AWAITING_ACCESS',
            'access_state_label': 'Esperando acceso',
            'access_checked_at': at('2026-08-18T10:00:00'),
        }
    ],
    'failed_batches': [],
}

QUOTA = {
    'date': '2026-08-19',
    'limit_total': 2000,
    'manual_reserve': 200,
    'used': 420,
    'used_automatic': 412,
    'used_manual': 8,
    'domains': [],
}

REQUESTS = {
    'panel_open': call(DASHBOARD, 'openPanel', '/?panel=coverage'),
    'panel_closed': call(DASHBOARD, 'openPanel', '/'),
    'panel_unknown': call(DASHBOARD, 'openPanel', '/?panel=inventado'),
    'panel_empty': call(DASHBOARD, 'openPanel', '/?panel='),
    'panel_among_others': call(DASHBOARD, 'openPanel', '/?days=90&panel=quota'),
    'href_keeps_the_range': call(DASHBOARD, 'panelHref', '/?days=90', 'quota'),
    'href_replaces_the_panel': call(DASHBOARD, 'panelHref', '/?panel=coverage', 'quota'),
    'href_from_a_bare_address': call(DASHBOARD, 'panelHref', '/', 'activity'),
    'groups': call(NOTIFICATIONS, 'groupByDay', ROWS, ZONE),
    'groups_out_of_order': call(
        NOTIFICATIONS,
        'groupByDay',
        [ROWS[0], ROWS[2], ROWS[1]],
        ZONE,
    ),
    'groups_in_utc': call(NOTIFICATIONS, 'groupByDay', ROWS, 'UTC'),
    'heading_of_an_old_day': call(NOTIFICATIONS, 'dayHeading', '2026-08-14', ZONE),
    'heading_of_today': call(
        NOTIFICATIONS, 'dayHeading', day_in_zone(datetime.now(ZoneInfo(ZONE))), ZONE
    ),
    'heading_of_yesterday': call(
        NOTIFICATIONS,
        'dayHeading',
        day_in_zone(datetime.now(ZoneInfo(ZONE)) - timedelta(days=1)),
        ZONE,
    ),
    'headline_one_domain': call(DASHBOARD, 'attentionHeadline', ATTENTION),
    'headline_many': call(
        DASHBOARD,
        'attentionHeadline',
        {
            **ATTENTION,
            'domains': [ATTENTION['domains'][0], {**ATTENTION['domains'][0], 'id': 'd2'}],
            'failed_batches': [{'id': 'b1'}],
        },
    ),
    'headline_only_the_credential': call(
        DASHBOARD,
        'attentionHeadline',
        {'credential': {'has_credential': True}, 'domains': [], 'failed_batches': []},
    ),
    'coverage_value': call(SECTION_CARDS, 'coverageValue', COVERAGE),
    'coverage_value_without_data': call(SECTION_CARDS, 'coverageValue', NOTHING_QUERIED),
    'coverage_note': call(SECTION_CARDS, 'coverageNote', COVERAGE),
    'coverage_note_without_data': call(SECTION_CARDS, 'coverageNote', NOTHING_QUERIED),
    'coverage_note_all_indexed': call(SECTION_CARDS, 'coverageNote', {**COVERAGE, 'indexed': 92}),
    'quota_note': call(SECTION_CARDS, 'quotaNote', QUOTA, True),
    'quota_note_without_budget': call(SECTION_CARDS, 'quotaNote', QUOTA, False),
    # Cómo se escribe el día lo decide `Intl` con el idioma activo, así que la
    # prueba se lo pregunta al mismo código en vez de escribir una fecha suelta
    # que sólo vale en un idioma.
    'quota_day': call(SECTION_CARDS, 'dayLabel', QUOTA['date']),
}


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


class TestWhatIsOpenLivesInTheAddress:
    """
    El panel de un aspecto se abre desde la dirección y no desde el estado de
    React. Es lo que hace que se pueda enlazar, que el botón Atrás lo cierre y
    que recargar no lo pierda (RT-09).
    """

    def test_the_open_aspect_is_read_from_the_address(self, screen):
        assert screen['panel_open'] == 'coverage'

    def test_without_the_parameter_nothing_is_open(self, screen):
        assert screen['panel_closed'] is None

    def test_a_name_that_does_not_exist_reads_as_nothing_open(self, screen):
        """
        Casi siempre es un enlace de otra versión de la vista. Abrir un panel
        vacío sería peor que no abrir ninguno: no hay nada que mirar adentro y
        para volver al tablero hay que cerrarlo.
        """
        assert screen['panel_unknown'] is None
        assert screen['panel_empty'] is None

    def test_the_aspect_survives_the_other_keys_of_the_address(self, screen):
        assert screen['panel_among_others'] == 'quota'

    def test_opening_a_panel_does_not_reset_the_chart_range(self, screen):
        """
        El rango del gráfico viaja como `?days=`. Armar la dirección desde la
        ruta le devolvería los treinta días por omisión a alguien que acaba de
        elegir noventa, y sin que nada avisara.
        """
        assert 'days=90' in screen['href_keeps_the_range']
        assert 'panel=quota' in screen['href_keeps_the_range']

    def test_two_aspects_never_stack_in_the_address(self, screen):
        """Un solo panel abierto por definición: la clave se pisa, no se agrega."""
        assert screen['href_replaces_the_panel'].count('panel=') == 1
        assert 'panel=quota' in screen['href_replaces_the_panel']

    def test_from_a_bare_address_it_still_opens(self, screen):
        assert screen['href_from_a_bare_address'] == '/?panel=activity'


class TestTheListIsCutIntoDays:
    """
    Agrupar por día es la forma barata de hacer leíble una lista larga sin pelear
    con el orden del servidor, que ofrece uno solo y no deja cambiarlo.
    """

    def test_the_groups_come_out_in_the_order_the_server_sent(self, screen):
        assert [group['day'] for group in screen['groups']] == ['2026-08-19', '2026-08-18']
        assert [item['id'] for item in screen['groups'][0]['items']] == ['a', 'b']

    def test_two_days_apart_are_not_merged_into_one(self, screen):
        """
        Juntar filas no contiguas del mismo día reordenaría la lista por su
        cuenta, y el orden es del servidor. Si vuelve un día que ya pasó, se
        muestra donde vino.
        """
        assert [group['day'] for group in screen['groups_out_of_order']] == [
            '2026-08-19',
            '2026-08-18',
            '2026-08-19',
        ]

    def test_the_day_is_cut_in_the_accounts_zone_and_not_in_utc(self, screen):
        """
        El aviso de las 23:30 del 18 es del 18 para quien lo mira. En UTC ya es
        del 19, y aparecería bajo «Hoy» un aviso de ayer: el encabezado del grupo
        contradiría la fecha de su propia fila.
        """
        assert len(screen['groups']) == 2
        assert len(screen['groups_in_utc']) == 1

    def test_the_two_relative_headings_are_today_and_yesterday(self, screen):
        assert screen['heading_of_today'] == phrase('time.today')
        assert screen['heading_of_yesterday'] == phrase('time.yesterday')

    def test_an_older_day_is_named_and_not_counted_in_days_ago(self, screen):
        """
        «hace 4 días» obliga a contar con los dedos; el nombre del día ubica solo.

        Cómo se escribe esa fecha lo decide `Intl.DateTimeFormat` con el idioma
        activo, así que no se compara contra una cadena: lo que se defiende es
        que el encabezado nombre el día y el mes, y no una cuenta de días.
        """
        heading = screen['heading_of_an_old_day']

        assert '14' in heading
        assert heading not in (phrase('time.today'), phrase('time.yesterday'))


class TestTheHealthCardDoesNotRepeatTheFrameworkNotice:
    """
    El aviso de nivel cuenta vive en el armazón, arriba de todo y en todas las
    pantallas, con su botón. El tablero no lo vuelve a decir: un ítem «Conexión
    caída» debajo de un aviso «Falta conectar Google» es el mismo hecho dos veces
    con dos redacciones, y la segunda no agrega ni un dato, ni una acción, ni un
    destino.

    Y hay algo peor que la redundancia: con la conexión caída el tablero se
    convertía en un informe de incidentes, y la primera cifra aparecía recién a
    los 800 px. Esta pantalla se mira todos los días y casi siempre está sana; el
    caso que hay que optimizar es «no pasa nada».
    """

    def test_the_headline_never_mentions_the_account_connection(self, screen):
        headline = screen['headline_one_domain']

        assert headline == phrase(
            'dashboard.attention.headline.one', items=phrase('dashboard.attention.domains.one')
        )
        assert 'Google' not in headline

    def test_with_only_the_connection_down_there_is_nothing_to_report(self, screen):
        """
        De los dominios no hay nada que reprochar, así que la tarjeta va en
        verde. Lo de la conexión ya está dicho doce centímetros más arriba.
        """
        assert screen['headline_only_the_credential'] == phrase('dashboard.attention.allGood')

    def test_the_headline_is_one_sentence_even_with_several_families(self, screen):
        """
        Acumular una frase por familia producía dos problemas de dos naturalezas
        pegados con un punto en el medio.
        """
        headline = screen['headline_many']
        items = (
            phrase('dashboard.attention.domains.other', total=2)
            + phrase('dashboard.attention.and')
            + phrase('dashboard.attention.batches.one')
        )

        assert headline == phrase('dashboard.attention.headline.other', items=items)
        assert '.' not in headline


class TestTheSentenceNextToAFigureSaysNoMore:
    """
    R-A y R-B en la frase que acompaña a la cifra grande, que es donde más fácil
    se cuela una afirmación que nadie comprobó.
    """

    def test_the_figure_is_a_pair_of_counts_and_never_a_percentage(self, screen):
        """
        Un porcentaje de indexación calculado sobre lo que se alcanzó a consultar
        es la afirmación falsa más fácil de cometer en un tablero, donde los
        números se leen sueltos y grandes.
        """
        assert screen['coverage_value'] == phrase('dashboard.coverage.value', indexed=62, total=120)
        assert '%' not in screen['coverage_value']

    def test_without_a_single_query_there_is_no_figure_at_all(self, screen):
        """
        «0 de 120» diría que Google no indexó ninguna. Lo único cierto es que
        todavía no le preguntamos por ninguna (R-B), así que no hay cifra.
        """
        assert screen['coverage_value_without_data'] == '—'

        assert screen['coverage_note_without_data'] == phrase('dashboard.coverage.note.none')

    def test_the_note_names_the_half_of_the_rest_that_the_badge_does_not(self, screen):
        """
        Con «62 de 120» adelante, lo peligroso es que las 58 que faltan se lean
        todas como malas. Son dos cosas distintas: 30 que Google miró y no indexó
        —esto— y 28 que todavía no consultamos, que van en el badge con su reloj.
        """
        note = screen['coverage_note']

        assert phrase('dashboard.coverage.note.notIndexed.other', total=30) in note

    def test_the_unqueried_ones_are_not_written_twice(self, screen):
        """
        El badge de al lado ya dice cuántas están sin consultar. Repetir el 28 en
        la bajada es el mismo dato escrito dos veces en la misma tarjeta, que es
        justo lo que hacía la pantalla anterior con cinco cifras.
        """
        assert '28' not in screen['coverage_note']

    def test_the_three_counts_close_the_total(self, screen):
        """62 indexadas + 30 con reporte sin indexar + 28 sin consultar = 120."""
        indexed, total = COVERAGE['indexed'], COVERAGE['total']
        not_indexed = COVERAGE['with_data'] - indexed

        assert indexed + not_indexed + COVERAGE['without_data'] == total
        assert str(not_indexed) in screen['coverage_note']

    def test_when_everything_queried_is_indexed_it_says_so_without_a_zero(self, screen):
        note = screen['coverage_note_all_indexed']

        assert note == phrase('dashboard.coverage.note.allIndexed.other', total=92)

    def test_the_card_never_uses_our_own_bookkeeping_vocabulary(self, screen):
        """
        «Con dato» describe nuestra contabilidad —a cuántas nos contestó Google—
        y no le dice nada a quien mira. El owner preguntó «¿qué dato, cuál dato,
        dónde veo esos datos?», y tenía razón: no era una cifra, era jerga.

        Se comprueba en los dos idiomas: la jerga se cuela al traducir, cuando
        `with_data` se copia tal cual del nombre de la prop en vez de decirse
        con las palabras de quien mira.
        """
        for name in ('coverage_value', 'coverage_note', 'coverage_note_without_data'):
            assert 'con dato' not in screen[name]
            assert 'with data' not in screen[name].lower()

    def test_the_quota_always_names_its_day(self, screen):
        """Un saldo sin día se lee como permanente, y el cupo se renueva cada mañana."""
        assert screen['quota_day'] in screen['quota_note']
        assert screen['quota_day'] in screen['quota_note_without_budget']

    def test_the_quota_note_separates_the_two_pockets(self, screen):
        """
        El ciclo automático no puede tocar la reserva manual, y esa garantía sólo
        sirve si se ve.
        """
        note = screen['quota_note']

        assert note == phrase(
            'dashboard.quota.note', automatic=412, manual=8, day=screen['quota_day']
        )
