"""
Las secciones del generador, dibujadas en Node con el catálogo real.

Lo que se defiende es lo que ve la persona: que las áreas detectadas arranquen
marcadas y las de poca confianza se vean atenuadas, que la pileta no se ofrezca
sin pileta, que sin áreas no se avance, que un contenedor dibuje sólo los
campos que corresponden y ninguno de los que se contestan solos, que el
resultado vaya al lado del original con sus tres acciones, que una fila v1 no
tenga «Adjust», y que los cuatro estados se distingan por silueta y tono (RT-04).
"""

import re

import pytest

from apps.ai import catalog, markup
from tests.frontend.harness import call, needs_frontend, phrase, render, resolve

AREAS = '@/pages/Ai/ImageGenerator/AreasSection'
CONTAINER = '@/pages/Ai/ImageGenerator/AreaContainer'
SUMMARY = '@/pages/Ai/ImageGenerator/AnswerSummary'
NOTES = '@/pages/Ai/ImageGenerator/NotesSection'
DEBUG = '@/pages/Ai/ImageGenerator/DebugSection'
GUARDS = '@/pages/Ai/ImageGenerator/GuardsSection'
MARKUP = '@/pages/Ai/ImageGenerator/PhotoMarkup'
LABELS = '@/pages/Ai/ImageGenerator/labels'
CONDITIONS = '@/pages/Ai/ImageGenerator/conditions'
ICONS = '@/pages/Ai/ImageGenerator/optionIcons'
FIXTURES = './fixtures'
BADGE = '@/components/GenerationStateBadge'

pytestmark = needs_frontend

CATALOG = catalog.catalog_props('/static/')
SCHEMAS = {schema['service']: schema for schema in CATALOG['services']}


def schemas(*services) -> list[dict]:
    return [SCHEMAS[service] for service in services]


def generation(**extra) -> dict:
    base = {
        'id': 'g1',
        'kind': '',
        'origin': 'TOOLS_APP',
        'state': 'COMPLETED',
        'error_code': None,
        'is_v2': True,
        'analysis_id': 'a1',
        'areas': [
            {'area': 'driveway', 'service': 'concrete_pour', 'answers': {'turf_strips': False}}
        ],
        'answers': {},
        'notes': '',
        'prompt': '## Driveway',
        'requested_by': None,
        'has_source_image': True,
        'result_url': '/media/ai/r1.jpg',
        'source_url': '/media/ai/sources/a1.jpg',
        'created_at': '2026-09-18T10:00:00+00:00',
        'started_at': '2026-09-18T10:00:01+00:00',
        'finished_at': '2026-09-18T10:00:40+00:00',
        'duration_seconds': 39,
    }
    return {**base, **extra}


MARKUP_CONFIG = {
    'default_colors': markup.default_color_props(),
    'max_note_length': 600,
}
SAVED_MARKUP = {
    'image': '/media/ai/markups/g1.png',
    'note': 'Red: a paver walkway.',
}

V1 = generation(
    id='old',
    kind='POOL',
    is_v2=False,
    analysis_id=None,
    areas=[],
    answers={'pool_shape': 'kidney'},
    source_url=None,
    has_source_image=False,
)

REQUESTS = {
    'areas_detected': render(
        AREAS,
        'AreasSection',
        areas=CATALOG['areas'],
        detected=['driveway', 'walkway', 'pool'],
        confidence={'driveway': 0.91, 'walkway': 0.32},
        poolPresent=True,
        selected=['driveway', 'walkway', 'pool'],
    ),
    'areas_without_pool': render(
        AREAS,
        'AreasSection',
        areas=CATALOG['areas'],
        detected=['driveway'],
        confidence={'driveway': 0.91},
        poolPresent=False,
        selected=['driveway'],
    ),
    'areas_none_selected': render(
        AREAS,
        'AreasSection',
        areas=CATALOG['areas'],
        detected=[],
        confidence={},
        poolPresent=False,
        selected=[],
    ),
    'entryway_services': call(CONDITIONS, 'servicesFor', CATALOG, 'entryway', True),
    'container_without_service': render(
        CONTAINER,
        'AreaContainer',
        areas=['entryway'],
        selected=['entryway'],
        services=schemas('paver', 'concrete_overlay'),
        service='',
        answers={},
    ),
    'container_travertine': render(
        CONTAINER,
        'AreaContainer',
        areas=['driveway'],
        selected=['driveway'],
        services=schemas('paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay'),
        service='paver',
        answers={'material': 'travertine', 'color': 'ivory'},
        wholeArea=True,
    ),
    'container_left_to_the_notes': render(
        CONTAINER,
        'AreaContainer',
        areas=['patio_deck'],
        selected=['patio_deck'],
        services=schemas('paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay'),
        service='paver',
        answers={'material': 'travertine', 'color': 'ivory'},
        wholeArea=False,
    ),
    'container_brick_after_change': render(
        CONTAINER,
        'AreaContainer',
        areas=['driveway'],
        selected=['driveway'],
        services=schemas('paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay'),
        service='paver',
        answers={'material': 'brick', 'color': 'ivory'},
    ),
    'container_french_mix': render(
        CONTAINER,
        'AreaContainer',
        areas=['driveway'],
        selected=['driveway'],
        services=schemas('paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay'),
        service='paver',
        answers={'material': 'travertine', 'color': 'ivory', 'size': 'french_pattern_mix'},
    ),
    # La misma caja, con y sin la vereda de la pileta en el pedido: es lo único
    # que decide si la pregunta del coping se dibuja.
    'container_with_pool_deck': render(
        CONTAINER,
        'AreaContainer',
        areas=['driveway'],
        selected=['driveway', 'pool_deck'],
        services=schemas('paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay'),
        service='paver',
        answers={
            'material': 'travertine',
            'color': 'ivory',
            'size': '24x24',
            'pattern': 'large_grid',
        },
    ),
    'container_pour_off': render(
        CONTAINER,
        'AreaContainer',
        areas=['driveway'],
        selected=['driveway'],
        services=schemas('paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay'),
        service='concrete_pour',
        answers={'turf_strips': False},
    ),
    'container_pour_on': render(
        CONTAINER,
        'AreaContainer',
        areas=['driveway'],
        selected=['driveway'],
        services=schemas('paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay'),
        service='concrete_pour',
        answers={'turf_strips': True},
    ),
    'container_overlay': render(
        CONTAINER,
        'AreaContainer',
        areas=['entryway'],
        selected=['entryway'],
        services=schemas('paver', 'concrete_overlay'),
        service='concrete_overlay',
        answers={},
    ),
    'container_overlay_regular': render(
        CONTAINER,
        'AreaContainer',
        areas=['entryway'],
        selected=['entryway'],
        services=schemas('paver', 'concrete_overlay'),
        service='concrete_overlay',
        answers={'shape': 'regular'},
    ),
    'icon_names': call(ICONS, 'optionIconNames'),
    'container_with_errors': render(
        CONTAINER,
        'AreaContainer',
        areas=['driveway'],
        selected=['driveway'],
        services=schemas('paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay'),
        service='concrete_pour',
        answers={},
        errors={'turf_strips': 'Pick an option.'},
    ),
    'result_completed': render(
        FIXTURES, 'GenerationResult', generation=generation(), adjustable=True
    ),
    'result_v1': render(FIXTURES, 'GenerationResult', generation=V1, adjustable=False),
    'result_failed': render(
        FIXTURES,
        'GenerationResult',
        generation=generation(state='FAILED', error_code='PROVIDER_REJECTED', result_url=None),
        adjustable=True,
    ),
    'result_running': render(
        FIXTURES,
        'GenerationResult',
        generation=generation(state='RUNNING', result_url=None, finished_at=None),
        adjustable=True,
    ),
    'href_v2': call(FIXTURES, 'hrefOf', generation()),
    'href_v1': call(FIXTURES, 'hrefOf', V1),
    'summary': render(
        SUMMARY,
        'AnswerSummary',
        areas=[
            {'area': 'driveway', 'service': 'concrete_pour', 'answers': {'turf_strips': False}},
            {
                'area': 'pool',
                'service': 'pool_renovation',
                'answers': {
                    'plaster_color': 'capri',
                    'waterline_color': 'blues',
                    'waterline_size': '6x6',
                },
            },
        ],
        catalog=CATALOG,
        notes='',
        guards={},
        prompt='## Driveway',
        canSeePrompt=True,
    ),
    'summary_with_notes': render(
        SUMMARY,
        'AnswerSummary',
        areas=[
            {
                'area': 'driveway',
                'service': 'concrete_pour',
                'answers': {'turf_strips': False},
                'whole_area': False,
            }
        ],
        catalog=CATALOG,
        notes='Leave the mailbox alone.',
        guards={'coverage': False, 'objects': True},
        prompt='## Driveway',
        canSeePrompt=True,
    ),
    'summary_with_marks': render(
        SUMMARY,
        'AnswerSummary',
        areas=[{'area': 'driveway', 'service': 'concrete_pour', 'answers': {'turf_strips': False}}],
        catalog=CATALOG,
        notes='',
        guards={},
        markup=SAVED_MARKUP,
        photoUrl='/media/ai/sources/a1.jpg',
        prompt='## Driveway',
        canSeePrompt=True,
    ),
    'markup_idle': render(
        MARKUP,
        'PhotoMarkup',
        photoUrl='/media/ai/sources/a1.jpg',
        photoAlt='The photo',
        marks=None,
        editing=False,
        config=MARKUP_CONFIG,
        saved=None,
    ),
    'markup_saved': render(
        MARKUP,
        'PhotoMarkup',
        photoUrl='/media/ai/sources/a1.jpg',
        photoAlt='The photo',
        marks=None,
        editing=False,
        config=MARKUP_CONFIG,
        saved=SAVED_MARKUP,
    ),
    'summary_without_the_prompt': render(
        SUMMARY,
        'AnswerSummary',
        areas=[{'area': 'driveway', 'service': 'concrete_pour', 'answers': {'turf_strips': False}}],
        catalog=CATALOG,
        notes='',
        guards={},
        prompt='',
        canSeePrompt=False,
    ),
    'container_grouped': render(
        CONTAINER,
        'AreaContainer',
        areas=['driveway', 'entryway', 'walkway'],
        selected=['driveway', 'entryway', 'walkway'],
        services=schemas('paver', 'concrete_overlay'),
        service='paver',
        answers={'material': 'brick', 'color': 'red_terracotta'},
    ),
    'areas_grouped': render(
        AREAS,
        'AreasSection',
        areas=CATALOG['areas'],
        detected=['driveway', 'entryway', 'walkway'],
        confidence={},
        poolPresent=False,
        selected=['driveway', 'entryway', 'walkway'],
        groupable=True,
        grouped=True,
    ),
    'areas_apart': render(
        AREAS,
        'AreasSection',
        areas=CATALOG['areas'],
        detected=['driveway', 'entryway', 'walkway'],
        confidence={},
        poolPresent=False,
        selected=['driveway', 'entryway', 'walkway'],
        groupable=True,
        grouped=False,
    ),
    'notes_closed': render(NOTES, 'NotesSection', notes='', maxLength=600, complete=True),
    'notes_open': render(
        NOTES,
        'NotesSection',
        notes='Leave the mailbox alone.',
        maxLength=600,
        complete=True,
    ),
    'notes_incomplete': render(NOTES, 'NotesSection', notes='', maxLength=600, complete=False),
    'debug': render(DEBUG, 'DebugSection', prompt='## Driveway'),
    'guards_raised': render(GUARDS, 'GuardsSection', guards=CATALOG['guards'], lowered={}),
    'guards_lowered': render(
        GUARDS,
        'GuardsSection',
        guards=CATALOG['guards'],
        lowered={'coverage': False},
    ),
    'unknown_generation_error': call(LABELS, 'generationErrorText', 'SOMETHING_NEW'),
    'unknown_analysis_error': call(LABELS, 'analysisErrorText', 'SOMETHING_NEW'),
    'malformed_analysis_error': call(LABELS, 'analysisErrorText', 'MALFORMED_RESPONSE'),
    'badge_queued': render(BADGE, 'GenerationStateBadge', state='QUEUED'),
    'badge_running': render(BADGE, 'GenerationStateBadge', state='RUNNING'),
    'badge_completed': render(BADGE, 'GenerationStateBadge', state='COMPLETED'),
    'badge_failed': render(BADGE, 'GenerationStateBadge', state='FAILED'),
}


@pytest.fixture(scope='module')
def results():
    return resolve(REQUESTS)


def tag_with(markup: str, attribute: str) -> str:
    """El elemento entero (su etiqueta de apertura) que lleva ese atributo."""
    match = re.search(rf'<[^>]*\b{re.escape(attribute)}[^>]*>', markup)
    assert match, f'No hay ningún elemento con {attribute}'
    return match.group(0)


class TestAreas:
    def test_only_the_areas_inside_are_drawn(self, results):
        """
        La sección lista lo que está adentro, como tarjetas, y nada más. Las
        seis áreas con «No detectada» al lado obligaban a leer cinco negativas
        para encontrar la que sí estaba; lo que falta se agrega con el «+».
        """
        markup = results['areas_detected']

        assert 'data-area="driveway"' in markup
        assert 'data-area="walkway"' in markup
        assert 'data-area="patio_deck"' not in markup
        assert 'type="checkbox"' not in markup
        assert 'aria-checked' not in markup

    def test_every_area_carries_its_color_and_a_way_out(self, results):
        """
        La franja de la izquierda es la misma del punto de esa área sobre la
        foto: esa correspondencia es todo el mensaje. Y sacarla es una acción de
        su propia tarjeta, no una casilla.
        """
        markup = results['areas_detected']

        assert 'var(--area-driveway)' in markup
        assert phrase('ai.selection.remove', area=phrase('ai.areas.driveway')) in markup

    def test_a_low_confidence_area_is_dimmed_but_still_inside(self, results):
        markup = results['areas_detected']

        assert 'data-low-confidence="true"' in tag_with(markup, 'data-area="walkway"')
        assert 'text-muted-foreground' in tag_with(markup, 'data-area="walkway"')
        assert 'data-low-confidence' not in tag_with(markup, 'data-area="driveway"')
        assert phrase('ai.selection.lowConfidence', score=32) in markup

    def test_the_pool_is_offered_only_with_a_detected_pool(self, results):
        """Sin pileta, no está adentro ni se puede agregar desde el «+» (R41)."""
        assert 'data-area="pool"' in results['areas_detected']
        assert 'data-area="pool"' not in results['areas_without_pool']
        assert phrase('ai.areas.pool') not in results['areas_without_pool']

    def test_with_nothing_inside_the_empty_state_says_so(self, results):
        markup = results['areas_none_selected']

        assert phrase('ai.selection.empty.title') in markup
        assert phrase('ai.selection.noneSelected') in markup
        assert phrase('ai.selection.noneSelected') not in results['areas_detected']


class TestContainer:
    def test_each_area_offers_only_its_services(self, results):
        assert [s['service'] for s in results['entryway_services']] == ['paver', 'concrete_overlay']

    def test_without_a_service_only_its_cards_are_drawn(self, results):
        """
        El servicio se elige con tarjetas con foto, una por servicio del área, y
        hasta que se elija uno no hay ninguna pregunta.
        """
        markup = results['container_without_service']

        assert phrase('ai.services.paver') in markup
        assert phrase('ai.services.concrete_overlay') in markup
        assert '/static/ai/paver/service.jpg' in markup
        assert 'id="field-entryway-material"' not in markup
        assert 'id="field-entryway-grout_color"' not in markup
        # Sin servicio no hay sección, y sin sección no hay nada que ceder.
        assert 'id="field-entryway-whole-area"' not in markup

    def test_the_whole_area_switch_says_who_decides_what_already_exists(self, results):
        """
        Encendido, el área se rehace entera; apagado, lo que ya existe lo
        deciden las notas y las marcas. La línea dice lo que pasa ahora.
        """
        whole = results['container_travertine']
        left = results['container_left_to_the_notes']

        assert 'aria-checked="true"' in tag_with(whole, 'id="field-driveway-whole-area"')
        assert phrase('ai.containers.wholeArea.on') in whole
        assert phrase('ai.containers.wholeArea.off') not in whole
        assert 'aria-checked="false"' in tag_with(left, 'id="field-patio_deck-whole-area"')
        assert phrase('ai.containers.wholeArea.off') in left

    def test_the_color_is_offered_by_material_and_dropped_when_it_changes(self, results):
        with_travertine = results['container_travertine']
        after_change = results['container_brick_after_change']

        # Con travertino, el color elegido queda puesto: el selector no pide nada.
        # (El texto de la opción elegida lo pone Radix al montar, no en el marcado.)
        assert 'data-placeholder=""' not in tag_with(with_travertine, 'id="field-driveway-color"')
        # Con ladrillo, «ivory» ya no aplica: el selector vuelve a pedir un color.
        assert 'data-placeholder=""' in tag_with(after_change, 'id="field-driveway-color"')

    def test_the_layout_appears_with_turf_strips(self, results):
        assert 'id="field-driveway-layout"' not in results['container_pour_off']
        assert 'id="field-driveway-layout"' in results['container_pour_on']
        assert 'role="switch"' in results['container_pour_on']

    def test_the_shape_is_two_buttons_with_the_irregular_one_on(self, results):
        """
        Un grupo de radios con un botón por forma, cada uno con su ícono: sin
        tocar nada, el prendido es Irregular, que es lo que el generador hacía
        antes de la pregunta.
        """
        markup = results['container_overlay']
        group = tag_with(markup, 'id="field-entryway-shape"')

        assert 'role="radiogroup"' in group
        assert phrase('ai.fields.concrete_overlay.shape.title') in markup
        assert phrase('ai.options.concrete_overlay.shape.regular.label') in markup
        assert phrase('ai.options.concrete_overlay.shape.irregular.description') in markup
        checked = re.findall(r'<button[^>]*role="radio"[^>]*aria-checked="true"[^>]*>', markup)
        assert any('value="irregular"' in button for button in checked)
        assert not any('value="regular"' in button for button in checked)
        # Los dos íconos, dibujados en línea.
        assert markup.count('M3 9h18M3 15h18') == 1
        assert markup.count('M3 10l5-1 3-6') == 1

    def test_the_shape_comes_before_the_stone_and_the_grout_after(self, results):
        markup = results['container_overlay']

        shape = markup.index('id="field-entryway-shape"')
        stone = markup.index('id="field-entryway-stone_color"')
        grout = markup.index('id="field-entryway-grout_color"')
        assert shape < stone < grout

    def test_the_stone_cards_show_the_chosen_shape(self, results):
        irregular = results['container_overlay']
        regular = results['container_overlay_regular']

        assert '/static/ai/concrete_overlay/stone_color/irregular/gray_escape.jpg' in irregular
        assert '/stone_color/regular/' not in irregular
        assert '/static/ai/concrete_overlay/stone_color/regular/gray_escape.jpg' in regular
        assert '/stone_color/irregular/' not in regular

    def test_every_icon_the_catalog_names_can_be_drawn(self, results):
        named = {
            option.icon
            for schema in catalog.SCHEMAS
            for field in schema.fields
            for option in field.options
            if option.icon
        }

        assert named
        assert named <= set(results['icon_names'])

    def test_a_field_that_answers_itself_draws_no_control(self, results):
        assert 'id="field-driveway-pattern"' not in results['container_french_mix']
        assert 'id="field-driveway-size"' in results['container_french_mix']

    def test_the_coping_is_drawn_only_when_the_request_has_the_pool_deck(self, results):
        """
        No lo decide la caja sino el pedido: la entrada de autos pregunta por el
        coping porque además se pidió la vereda de la pileta.
        """
        assert 'id="field-driveway-coping"' not in results['container_travertine']
        assert 'id="field-driveway-coping"' in results['container_with_pool_deck']
        # Los tres estilos que se ofrecen y ninguno de los retirados, sin
        # ninguno marcado de entrada.
        markup = results['container_with_pool_deck']
        for code in ('bullnose', 'tumbled', 'infinity_edge'):
            assert f'id="field-driveway-coping-{code}"' in markup, code
            assert 'data-state="checked"' not in tag_with(
                markup, f'id="field-driveway-coping-{code}"'
            ), code
        for code in ('keep', 'none', 'pool_coping_cap', 'straight_cut', 'match_field_pavers'):
            assert f'id="field-driveway-coping-{code}"' not in markup, code

    def test_cards_carry_their_photo_and_their_label(self, results):
        markup = results['container_travertine']

        assert '/static/ai/paver/material/travertine.jpg' in markup
        assert phrase('ai.options.paver.material.travertine.label') in markup
        assert 'role="radio"' in markup

    def test_an_error_is_glued_to_its_field(self, results):
        markup = results['container_with_errors']

        assert 'Pick an option.' in markup
        assert 'aria-invalid="true"' in tag_with(markup, 'id="field-driveway-turf_strips"')


class TestResult:
    def test_the_result_sits_next_to_the_original_with_its_three_actions(self, results):
        markup = results['result_completed']

        assert 'md:grid-cols-2' in markup
        assert 'src="/media/ai/sources/a1.jpg"' in markup
        assert 'src="/media/ai/r1.jpg"' in markup
        assert 'download="design.jpg"' in markup
        assert phrase('ai.result.download') in markup
        assert phrase('ai.result.adjust') in markup
        assert phrase('ai.result.another') in markup
        assert 'href="/ai/image-generator?analysis=a1"' in markup

    def test_a_v1_row_has_no_adjust_and_no_original(self, results):
        markup = results['result_v1']

        assert phrase('ai.result.adjust') not in markup
        assert phrase('ai.result.download') in markup
        assert 'md:grid-cols-2' not in markup

    def test_a_failure_says_why_and_offers_to_try_again(self, results):
        markup = results['result_failed']

        assert phrase('ai.error.PROVIDER_REJECTED') in markup
        assert phrase('ai.failed.tryAgain') in markup

    def test_while_it_runs_there_is_a_phrase_and_a_status_region(self, results):
        markup = results['result_running']

        assert 'role="status"' in markup
        assert phrase('ai.loader.step1') in markup

    def test_a_row_opens_with_its_analysis_when_it_has_one(self, results):
        assert results['href_v2'] == '/ai/image-generator?analysis=a1&generation=g1'
        assert results['href_v1'] == '/ai/image-generator?generation=old'


class TestSummary:
    def test_it_lists_each_area_with_its_service_and_answers_by_label(self, results):
        markup = results['summary']

        assert phrase('ai.areas.driveway') in markup
        assert phrase('ai.services.concrete_pour') in markup
        assert phrase('ai.containers.toggle.no') in markup
        assert phrase('ai.fields.pool_renovation.plaster_color.title') in markup
        assert phrase('ai.options.pool_renovation.plaster_color.capri.label') in markup
        assert phrase('ai.summary.output') in markup

    def test_the_notes_are_part_of_what_was_asked(self, results):
        """
        Sin ellas el resumen no explicaría por qué el resultado hizo algo que
        ninguna respuesta pide.
        """
        assert 'Leave the mailbox alone.' in results['summary_with_notes']
        assert phrase('ai.notes.title') in results['summary_with_notes']
        assert phrase('ai.notes.title') not in results['summary']

    def test_each_area_says_whether_it_went_whole(self, results):
        """
        Dos pedidos con las mismas respuestas y el interruptor distinto mandan
        prompts distintos: el resumen tiene que dejarlo ver. Un pedido anterior
        al interruptor no lo trae, y se lee encendido, que es lo que pidió.
        """
        title = phrase('ai.containers.wholeArea.title')
        whole = results['summary']
        left = results['summary_with_notes']

        assert title in whole
        assert title in left
        assert phrase('ai.containers.toggle.yes') in whole.split(title, 1)[1]
        assert phrase('ai.containers.toggle.no') in left.split(title, 1)[1]

    def test_only_the_guards_that_ruled_are_listed(self, results):
        """
        Un guard apagado, listado con la frase de lo que pasa sin él, se leía
        como una regla vigente. Y un pedido anterior a los guards no dice cuáles
        rigieron, así que no lista ninguno.
        """
        markup = results['summary_with_notes']

        assert phrase('ai.guards.objects.title') in markup
        assert phrase('ai.guards.objects.on') in markup
        assert phrase('ai.guards.coverage.title') not in markup
        assert phrase('ai.guards.coverage.off') not in markup
        assert phrase('ai.guards.title') not in results['summary']

    def test_without_the_role_the_prompt_has_no_button_to_open_it(self, results):
        """
        R53: el prompt no llegó, así que un botón que promete abrirlo mentiría.
        Lo que se pidió se sigue viendo: lo que se esconde es el prompt.
        """
        markup = results['summary_without_the_prompt']

        assert phrase('ai.summary.output') not in markup
        assert phrase('ai.services.concrete_pour') in markup


class TestGrouping:
    def test_the_switch_lives_with_the_areas_and_not_with_a_service(self, results):
        """
        Lo que junta son áreas. En la caja de un servicio se leía como un ajuste
        de esa área, que es justo lo que no es.
        """
        assert phrase('ai.containers.group') in results['areas_grouped']
        assert phrase('ai.containers.group') not in results['container_grouped']
        assert phrase('ai.containers.group') not in results['container_travertine']

    def test_the_switch_only_shows_when_there_is_something_to_group(self, results):
        """Un control que no puede cambiar nada sólo hace preguntarse qué hace."""
        assert phrase('ai.containers.group') not in results['areas_detected']

    def test_the_switch_says_whether_they_are_together(self, results):
        assert 'aria-checked="true"' in tag_with(results['areas_grouped'], 'id="group-areas"')
        assert 'aria-checked="false"' in tag_with(results['areas_apart'], 'id="group-areas"')

    def test_the_grouped_container_asks_once_for_the_three(self, results):
        markup = results['container_grouped']

        assert 'id="field-driveway-entryway-walkway-material"' in markup
        assert 'id="field-driveway-material"' not in markup

    def test_it_only_offers_what_the_three_areas_share(self, results):
        """La entrada de la casa no lleva césped, así que el grupo tampoco lo ofrece."""
        markup = results['container_grouped']

        assert phrase('ai.services.paver') in markup
        assert phrase('ai.services.artificial_turf') not in markup


class TestMarkup:
    """El editor de marcas sobre la foto (D5 de `ai-image-annotation-layer`)."""

    def test_the_controls_are_always_mounted_and_inert_while_hidden(self, results):
        """
        La barra y la nota viven detrás de la foto: se montan una vez y nunca se
        rearman. Escondidas, el tabulador no puede entrar en ellas.
        """
        markup = results['markup_idle']

        assert 'data-markup="idle"' in markup
        assert 'inert' in tag_with(markup, 'role="toolbar"')
        assert '<textarea' in markup
        assert 'inert' in tag_with(markup, 'class="photo-markup-note')

    def test_it_starts_with_three_recent_colors_and_a_custom_one(self, results):
        """Sin nada recordado, los recientes son los de siempre; el cuarto abre el selector."""
        markup = results['markup_idle']

        # El primero es el color en uso.
        assert phrase('ai.markup.color.current', color='#EF4444') in markup
        for hex_color in ('#FACC15', '#3B82F6'):
            assert phrase('ai.markup.color.swatch', color=hex_color) in markup
        assert phrase('ai.markup.color.custom') in markup
        # Tooltips de shadcn, no los del navegador.
        assert 'title=' not in tag_with(markup, 'role="toolbar"')

    def test_save_is_off_without_marks_and_without_a_note(self, results):
        """Un color no significa nada hasta que alguien dice qué va ahí."""
        markup = results['markup_idle']
        save = re.search(rf'<button[^>]*>(?:(?!</button>).)*{phrase("common.save")}', markup)

        assert save and 'disabled' in save.group(0)
        assert phrase('ai.markup.note.paintFirst') in markup

    def test_saved_marks_leave_the_note_in_sight_read_only(self, results):
        markup = results['markup_saved']

        assert phrase('ai.markup.saved.title') in markup
        assert 'Red: a paver walkway.' in markup
        assert phrase('ai.markup.editAgain') in markup
        assert phrase('ai.markup.remove') in markup
        assert phrase('ai.markup.saved.title') not in results['markup_idle']

    def test_the_summary_shows_the_marks_as_they_were_sent(self, results):
        markup = results['summary_with_marks']

        assert phrase('ai.markup.saved.title') in markup
        assert 'Red: a paver walkway.' in markup
        assert '/media/ai/markups/g1.png' in markup
        assert phrase('ai.markup.saved.title') not in results['summary']


class TestNotes:
    def test_it_starts_behind_a_button_with_nothing_to_fill_in(self, results):
        """El caso normal es no escribir nada: un campo a la vista se lee como obligatorio."""
        markup = results['notes_closed']

        assert phrase('ai.notes.add') in markup
        assert '<textarea' not in markup

    def test_written_notes_come_back_open_with_their_limits_in_sight(self, results):
        markup = results['notes_open']

        assert '<textarea' in markup
        assert 'Leave the mailbox alone.' in markup
        assert phrase('ai.notes.help') in markup
        # Lo que la nota no va a conseguir se lee en la tarjeta de guards, que
        # es donde además se baja lo que se puede bajar.
        assert phrase('ai.guards.fixed.people') not in markup
        # El tope lo impone el campo y no un aviso después de escribir de más.
        assert 'maxLength="600"' in tag_with(markup, 'data-slot="textarea"')

    def test_the_button_that_generates_closes_the_form_even_without_notes(self, results):
        """
        Es la última tarjeta del recorrido: la acción del formulario entero vive
        en su pie, se escriban notas o no.
        """
        markup = results['notes_closed']

        assert phrase('ai.generator.submit') in markup
        assert 'type="submit"' in markup

    def test_a_request_that_is_missing_something_says_what_before_being_pressed(self, results):
        """Un botón apagado sin explicación se lee como una pantalla rota."""
        markup = results['notes_incomplete']

        assert phrase('ai.generator.incomplete') in markup
        assert 'disabled' in tag_with(markup, 'type="submit"')


class TestGuards:
    def test_the_line_underneath_describes_the_state_it_is_in(self, results):
        """
        Un interruptor que dice qué pasaría al tocarlo se contradice con su
        propia posición: la descripción cuenta lo que está pasando ahora.
        """
        raised = results['guards_raised']
        lowered = results['guards_lowered']

        assert phrase('ai.guards.coverage.title') in raised
        assert phrase('ai.guards.coverage.on') in raised
        assert phrase('ai.guards.coverage.off') not in raised
        assert phrase('ai.guards.coverage.off') in lowered
        assert phrase('ai.guards.coverage.on') not in lowered

    def test_a_lowered_guard_is_the_only_switch_that_moves(self, results):
        """Cada guard gobierna su línea: bajar uno no toca el de al lado."""
        markup = results['guards_lowered']

        assert 'aria-checked="false"' in tag_with(markup, 'id="guard-coverage"')
        assert 'aria-checked="true"' in tag_with(markup, 'id="guard-objects"')

    def test_what_no_switch_lifts_is_in_the_same_card(self, results):
        """
        La pregunta que contestan es la misma —«¿qué no va a hacer?»— y en dos
        lugares obliga a buscar la lista completa.
        """
        markup = results['guards_raised']

        assert phrase('ai.guards.fixed.people') in markup
        assert phrase('ai.guards.fixed.realistic') in markup
        # Y no tienen interruptor: son tres renglones, no tres controles.
        assert markup.count('data-slot="switch"') == len(CATALOG['guards'])


class TestDebug:
    def test_the_prompt_is_not_on_the_way_to_generating(self, results):
        """
        No es un paso de verificación: lo que se aprueba son las respuestas. Con
        el acordeón cerrado, el texto ni siquiera está en el documento.
        """
        markup = results['debug']

        assert phrase('ai.debug.show') in markup
        assert '## Driveway' not in markup
        # El cajón se llama «Debug» y el prompt es lo que hay adentro: mañana
        # entra otra cosa al lado sin volver a ser una tarjeta del formulario.
        assert phrase('ai.debug.prompt.title') not in markup


class TestErrors:
    def test_an_unknown_error_code_falls_back_instead_of_breaking(self, results):
        """Los códigos nacen en el servidor; un despliegue puede adelantarse al catálogo."""
        assert results['unknown_generation_error'] == phrase('ai.error.UNEXPECTED')
        assert results['unknown_analysis_error'] == phrase('ai.analysis.error.UNEXPECTED')
        assert results['malformed_analysis_error'] == phrase('ai.analysis.error.MALFORMED_RESPONSE')


def tone(markup: str) -> str:
    match = re.search(r'data-tone="([a-z]+)"', markup)
    assert match, markup
    return match.group(1)


class TestBadges:
    def test_the_four_states_carry_four_tones_and_their_word(self, results):
        assert tone(results['badge_queued']) == 'neutral'
        assert tone(results['badge_running']) == 'neutral'
        assert tone(results['badge_completed']) == 'positive'
        assert tone(results['badge_failed']) == 'critical'
        assert phrase('ai.state.QUEUED.label') in results['badge_queued']
        assert phrase('ai.state.RUNNING.label') in results['badge_running']
        assert phrase('ai.state.COMPLETED.label') in results['badge_completed']
        assert phrase('ai.state.FAILED.label') in results['badge_failed']

    def test_only_the_running_state_moves(self, results):
        assert 'animate-spin' in results['badge_running']
        assert 'animate-spin' not in results['badge_queued']

    def test_the_movement_does_not_stop_with_reduced_motion(self, results):
        """
        El indicador de progreso gira siempre: es la excepción del contrato.

        Con `motion-reduce:animate-none` se apagaban todos los indicadores del
        producto a la vez —basta con tener los efectos de animación apagados en
        el sistema operativo— y la pantalla entera parecía trabada. Un ícono
        quieto arriba de un botón deshabilitado no se lee como «esperá» sino
        como «se colgó».
        """
        assert 'animate-spin' in results['badge_running']
        assert 'motion-reduce' not in results['badge_running']


class TestPreselection:
    """
    Lo detectado tiene que quedar marcado, y el momento importa.

    Quien sube una foto ve la página con el análisis todavía en cola: ahí no hay
    áreas, y el sondeo que después las trae no remonta el recorrido —la clave es
    el análisis y su id no cambia entre «en cola» y «listo»—. Sembrar el
    borrador sólo al montar dejaba las tres áreas reconocidas sin marcar.

    De un dibujo estático no sale nada, porque lo que falla es cuándo corre el
    estado: lo que se verifica es que la siembra esté atada al análisis que
    termina y no únicamente al montaje.
    """

    def source(self) -> str:
        from tests.frontend.harness import HERE

        path = HERE.parent.parent / 'frontend' / 'pages' / 'Ai' / 'ImageGenerator' / 'Index.tsx'
        return path.read_text(encoding='utf-8')

    def test_the_draft_is_seeded_again_when_the_analysis_finishes(self):
        body = self.source()
        effects = re.findall(r'useEffect\(\(\) => \{(.*?)\n  \}, \[', body, re.S)

        assert any('draftFromAnalysis' in effect and 'COMPLETED' in effect for effect in effects), (
            'Nadie vuelve a sembrar el borrador cuando el análisis termina.'
        )

    def test_the_seeding_happens_once_and_not_on_every_poll(self):
        """
        El sondeo trae el análisis cada pocos segundos. Sin guarda, cada vuelta
        pisaría lo que la persona acaba de marcar o desmarcar.
        """
        body = self.source()

        assert 'seeded' in body, 'La siembra no tiene guarda.'
        assert re.search(r'if \(seeded \|\|', body), 'La guarda no corta antes de sembrar.'
