"""
Las dos pantallas de lotes no pueden contradecirse a sí mismas (R-C).

La restricción dura dice que un lote interrumpido por cuota es `PARTIAL` y jamás
`COMPLETED`. El corolario, que es el que se defiende acá, es que un `PARTIAL`
tampoco se **dibuja** como un éxito completo: si lo único que lo separa de un
lote terminado es la palabra del badge, la restricción se cumple en la base y se
rompe en la pantalla, que es donde alguien saca la conclusión.

Los tres defectos que se verifican son reales y salen del mismo camino de código:
la sincronización lee los siete sitemaps del dominio y no le queda cupo para
enviar uno. La nota de cupo entra en `summary['errors']` sin tocar `failed_items`
ni `processed_items`, así que el lote cierra en `PARTIAL` con las cifras de uno
terminado —7 de 7, 0 fallidas, 0 pendientes— y la pantalla tiene que decir la
incompletitud igual.

Se ejercita el TypeScript real en Node y no una traducción del arreglo a Python.
Todo lo que hace falta se pide de una sola vez, en el fixture de módulo: Node
tarda más en arrancar que en contestar.
"""

import re

import pytest

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

BATCH_MODULE = '@/lib/batch'
INDEX = '@/pages/Batches/Index'
PROGRESS = '@/components/BatchProgress'
SHOW = '@/pages/Batches/Show'

pytestmark = needs_frontend


def batch(**extra) -> dict:
    """Un lote tal como lo serializa `_row` en `apps/jobs/views.py`."""
    return {
        'id': '0195c0de-0000-7000-8000-000000000001',
        'kind': 'SITEMAP_SYNC',
        'origin': 'SCHEDULED',
        'state': 'PARTIAL',
        'total_items': 7,
        'processed_items': 7,
        'failed_items': 0,
        'pending_items': 0,
        'quota_consumed': 6,
        'reason': None,
        'created_at': '2026-08-18T03:00:00+00:00',
        'started_at': '2026-08-18T03:00:00+00:00',
        'finished_at': '2026-08-18T03:04:00+00:00',
        'duration_seconds': 240,
        'is_terminal': True,
        'summary': {},
    } | extra


#: El lote que cerró sin poder enviar uno de los sitemaps que sí leyó.
CUT_BY_QUOTA = batch(reason='ITEM_ERRORS')

#: El lote que perdió el acceso entre el clic y la ejecución: `_fail` lo cierra
#: en `FAILED` sin haber contado un solo ítem.
NEVER_STARTED = batch(state='FAILED', total_items=0, processed_items=0, reason='INTERRUPTED')


def row(original: dict) -> dict:
    """Una fila de TanStack: la comparación sólo mira `original`."""
    return {'original': original}


#: Un borrador **viejo**: el caso que decide si la regla es «los borradores
#: primero» o «por fecha». Si fuera reciente quedaría arriba por su fecha y el
#: test pasaría sin probar nada.
OLD_DRAFT = batch(
    state='DRAFT',
    created_at='2026-08-01T03:00:00+00:00',
    started_at=None,
    finished_at=None,
)

#: En cola: tampoco arrancó, pero espera su turno, no a una persona.
QUEUED = batch(
    state='QUEUED',
    created_at='2026-08-22T03:00:00+00:00',
    started_at=None,
    finished_at=None,
)

RECENT = batch(state='COMPLETED', started_at='2026-08-24T10:00:00+00:00')
OLD_RUN = batch(state='COMPLETED', started_at='2026-08-20T10:00:00+00:00')

REQUESTS = {
    'partial_bar': render(
        PROGRESS, 'BatchProgress', total=7, processed=7, failed=0, state='PARTIAL'
    ),
    'completed_bar': render(
        PROGRESS, 'BatchProgress', total=7, processed=7, failed=0, state='COMPLETED'
    ),
    'failed_without_items': render(
        PROGRESS, 'BatchProgress', total=0, processed=0, failed=0, state='FAILED'
    ),
    'completed_without_items': render(
        PROGRESS, 'BatchProgress', total=0, processed=0, failed=0, state='COMPLETED'
    ),
    'reason_without_failures': call(BATCH_MODULE, 'batchReasonText', CUT_BY_QUOTA),
    'reason_with_failures': call(
        BATCH_MODULE,
        'batchReasonText',
        batch(reason='ITEM_ERRORS', failed_items=2, processed_items=5),
    ),
    'reason_never_started': call(BATCH_MODULE, 'batchReasonText', NEVER_STARTED),
    'issues_never_started': render(
        SHOW,
        'FailureList',
        failures=[{'item': None, 'reason': 'Tu cuenta perdió el acceso a la propiedad.'}],
        total=1,
        batch=NEVER_STARTED,
    ),
    'issues_one_item': render(
        SHOW,
        'FailureList',
        failures=[{'item': 'https://ejemplo.test/sitemap-2.xml', 'reason': 'Devolvió 404'}],
        total=1,
        batch=batch(failed_items=0),
    ),
    'issues_mixed': render(
        SHOW,
        'FailureList',
        failures=[
            {'item': 'https://ejemplo.test/sitemap-1.xml', 'reason': 'Devolvió 404'},
            {'item': None, 'reason': 'No quedaba cupo para enviar el resto.'},
        ],
        total=2,
        batch=batch(failed_items=1),
    ),
    # El orden de la columna «Inicio». Devuelve el orden ascendente y la tabla
    # invierte para descendente, que es como arranca la pantalla: un resultado
    # positivo es «va al final acá, y arriba en lo que se ve».
    'draft_against_recent': call(INDEX, 'byWhenItStarted', row(OLD_DRAFT), row(RECENT)),
    'draft_against_queued': call(INDEX, 'byWhenItStarted', row(OLD_DRAFT), row(QUEUED)),
    'recent_against_old': call(INDEX, 'byWhenItStarted', row(RECENT), row(OLD_RUN)),
    'queued_against_old': call(INDEX, 'byWhenItStarted', row(QUEUED), row(OLD_RUN)),
}


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


def widths(markup: str) -> list[float]:
    """Cuánto ocupa cada tramo dibujado de la barra, de 0 a 1."""
    return [float(value) for value in re.findall(r'scaleX\(([0-9.]+)\)', markup)]


class TestAPartialBatchIsNotDrawnAsACompletedOne:
    """
    Defecto 1: con `processed == total` y `failed == 0`, la barra quedaba llena,
    sólida, sin rayas y sin hueco.
    """

    def test_no_segment_reaches_the_edge_when_the_word_is_not_completed(self, screen):
        """
        El invariante: sólo «Completado» llega al borde.

        La barra se calcula desde las cifras, y en este caso las cifras no dejan
        hueco. Si el tramo llega al borde igual, la barra afirma «se procesaron
        todas» sobre un lote que no terminó, y lo único que queda para
        desmentirlo es el color del badge, que es exactamente lo que R-C
        prohíbe.
        """
        drawn = widths(screen['partial_bar'])

        assert drawn, 'La barra no dibujó ningún tramo.'
        assert max(drawn) < 1, (
            'Un lote «Parcial» dibujó la barra hasta el borde: en pantalla es '
            'idéntico a uno «Completado» salvo por la palabra del badge (R-C).'
        )

    def test_the_incompleteness_is_also_said_with_words(self, screen):
        """
        Cuando las cifras no dejan hueco, la incompletitud se dice con palabras.

        Un hueco de unos píxeles no lo ve quien mira la pantalla en escala de
        grises ni lo anuncia un lector (RT-04): la oración del pie es la que
        hace que el dato exista para todos.
        """
        assert phrase('batchProgress.incomplete') in screen['partial_bar'], (
            'La barra de un lote que no terminó no dijo en ninguna palabra que '
            'quedó incompleto: la única señal era el color del badge (R-C).'
        )

    def test_a_completed_batch_does_reach_the_edge(self, screen):
        """
        La contracara, para que el arreglo no se pase de largo.

        Si ningún lote llegara al borde, la barra dejaría de distinguir el caso
        que sí terminó y el problema quedaría igual, del otro lado.
        """
        assert max(widths(screen['completed_bar'])) == 1
        assert phrase('batchProgress.incomplete') not in screen['completed_bar']

    def test_a_failed_batch_without_items_does_not_announce_a_clean_ending(self, screen):
        """
        «El lote no tenía ítems para procesar» se lee como éxito.

        Es lo que dibuja hoy un lote que cerró en `FAILED` antes de contar nada
        —el dominio perdió el acceso entre el clic y la ejecución—, y deja la
        pantalla diciendo «Falló» arriba y una frase tranquilizadora abajo.
        """
        assert phrase('batchProgress.empty.completed') not in screen['failed_without_items'], (
            'Un lote que falló explicó su cero ítems con la frase de un lote que terminó limpio.'
        )

    def test_a_completed_batch_without_items_keeps_saying_so(self, screen):
        assert phrase('batchProgress.empty.completed') in screen['completed_without_items']


class TestTheReasonDoesNotContradictTheFigures:
    """Defecto 2: «Fallaron 0 de 7 sitemaps»."""

    def test_it_never_says_that_zero_items_failed(self, screen):
        """
        El invariante: la rama de ítems fallidos exige que haya ítems fallidos.

        El motivo llega como código desde el servidor. Con `ITEM_ERRORS` y
        `failed_items` en cero, la frase se escribía igual y anunciaba un fracaso
        de cero elementos sobre un lote donde no falló ninguno: la nota de cupo
        no es una falla.
        """
        text = screen['reason_without_failures']

        assert phrase('batch.reason.partial.generic') == text, (
            f'Sin ítems fallidos el corte se explica como interrupción, y dijo: «{text}».'
        )

    def test_with_real_failures_it_keeps_counting_them(self, screen):
        """La contracara: el arreglo no puede tapar los fracasos que sí hubo."""
        expected = phrase(
            'batch.reason.partial.itemErrors',
            failed=2,
            total=7,
            unit=phrase('batch.unit.SITEMAP_SYNC'),
        )

        assert expected in screen['reason_with_failures']

    def test_a_batch_that_failed_without_trying_does_not_claim_failed_attempts(self, screen):
        """
        «Fallaron todos los intentos» sobre un lote que no llegó a intentar nada.

        Es el otro lado del mismo defecto: cuando `_fail` cierra el lote porque
        el dominio dejó de estar operativo, no hubo ningún intento que pudiera
        fallar.
        """
        text = screen['reason_never_started']

        assert phrase('batch.reason.failed.allAttempts') not in text, (
            f'Un lote que se cortó antes de empezar dijo que fallaron sus intentos: «{text}».'
        )


class TestTheIssuesBlockIsDecidedRowByRow:
    """Defecto 3: un lote `FAILED` que anuncia «No falló ningún ítem»."""

    def test_a_failed_batch_does_not_announce_that_nothing_failed(self, screen):
        """
        El invariante: lo que decide es `item !== null`, fila por fila.

        `failed_items` es una cifra del lote y no de la fila. Un lote cerrado en
        `FAILED` con una sola anotación sin ítem —el acceso que se cayó— tiene
        `failed_items` en cero, y con esa cifra la pantalla mostraba a la vez el
        badge «Falló» y la frase «No falló ningún ítem».
        """
        assert phrase('batchShow.failures.title.one') not in screen['issues_never_started'], (
            'La ficha de un lote que falló tituló como fallas lo que son notas del lote.'
        )
        assert phrase('batchShow.failures.cutBefore') in screen['issues_never_started']

    def test_a_row_with_an_item_is_titled_as_a_failure_even_if_the_counter_says_zero(self, screen):
        """
        La cifra del lote y la fila pueden no coincidir; manda la fila.

        Decidir con `failed_items` es la única forma de equivocarse: es un total
        del lote y el bloque describe filas.
        """
        assert phrase('batchShow.failures.title.one') in screen['issues_one_item'], (
            'Una fila que es un intento fallido quedó titulada como una nota del lote.'
        )

    def test_a_note_next_to_real_failures_is_not_counted_as_one(self, screen):
        """
        Un corte por cupo listado bajo «Los ítems que fallaron» es un error.

        R-C otra vez: la nota explica hasta dónde llegó el lote y lo que quedó
        sigue mañana. Presentarla como falla convierte trabajo pendiente en algo
        roto.
        """
        markup = screen['issues_mixed']

        assert phrase('batchShow.failures.notesMixed.one') in markup, (
            'La nota de cupo se listó bajo el título de los ítems fallidos sin '
            'ninguna aclaración de que no es una falla.'
        )


class TestABatchThatNeverStartedStillHasAPlaceInTheOrder:
    """
    La columna «Inicio» ordena una tabla donde no todo arrancó.

    Ordenada por la fecha de arranque cruda —que en un borrador es nula—, la
    tabla mandaba al final lo único que espera a una persona. Un borrador que
    nadie mira no se manda nunca, y el trabajo que propuso el recorrido queda ahí
    hasta que alguien baja a buscarlo.

    La pantalla arranca en descendente, así que acá —que es el ascendente— lo que
    tiene que quedar arriba se lee como positivo.
    """

    def test_a_draft_stays_on_top_even_when_it_is_the_oldest_row(self, screen):
        """
        El caso que separa las dos reglas posibles.

        Un borrador reciente queda arriba por su fecha y no prueba nada; éste es
        de tres semanas atrás y compite contra un lote de anteayer.
        """
        assert screen['draft_against_recent'] > 0, 'El borrador quedó debajo de un lote que corrió.'

    def test_a_draft_also_outranks_something_merely_queued(self, screen):
        """
        No arrancaron ninguno de los dos, y no es lo mismo: uno espera su turno y
        el otro espera a una persona. Sólo el segundo se destraba mirándolo.
        """
        assert screen['draft_against_queued'] > 0

    def test_the_rest_keeps_being_ordered_by_when_it_ran(self, screen):
        assert screen['recent_against_old'] > 0

    def test_something_queued_is_placed_by_when_it_was_queued(self, screen):
        """
        Sin fecha de arranque se usa la de creación, que es el mismo criterio con
        el que el servidor arma la lista (`Coalesce('started_at', 'created_at')`).
        Con criterios distintos de cada lado, la tabla se reacomoda sola al
        primer clic en un encabezado.
        """
        assert screen['queued_against_old'] > 0
