"""
El catálogo v2: áreas, servicios por área y las dependencias de cada esquema.

Lo que se defiende es que las reglas del owner estén escritas como dato y que el
motor las evalúe igual para los cinco servicios: qué servicio ofrece cada área,
qué opciones quedan vigentes según lo contestado antes, qué campo aparece y
cuándo, y que lo que viaja a la pantalla sean códigos y texto de prompt, nunca
texto para leer.
"""

from pathlib import Path

import pytest
from django.conf import settings

from apps.ai import catalog
from apps.ai.catalog import (
    OP_EQ,
    OP_IN,
    OP_INCLUDES,
    AllOf,
    Area,
    Condition,
    Service,
    evaluate,
)


class TestConditions:
    def test_eq_compares_with_the_value(self):
        assert evaluate(Condition('turf_strips', OP_EQ, True), {'turf_strips': True})
        assert not evaluate(Condition('turf_strips', OP_EQ, True), {'turf_strips': False})
        assert not evaluate(Condition('turf_strips', OP_EQ, True), {})

    def test_in_asks_if_the_answer_is_in_the_list(self):
        condition = Condition('size', OP_IN, ('24x24', '24x36'))

        assert evaluate(condition, {'size': '24x36'})
        assert not evaluate(condition, {'size': '16x24'})
        assert not evaluate(condition, {})

    def test_includes_asks_if_the_list_contains_the_value(self):
        condition = Condition('areas', OP_INCLUDES, 'pool')

        assert evaluate(condition, {'areas': ['driveway', 'pool']})
        assert not evaluate(condition, {'areas': ['driveway']})
        assert not evaluate(condition, {'areas': 'pool'})
        assert not evaluate(condition, {})

    def test_all_of_needs_every_condition(self):
        condition = AllOf(
            (Condition('area', OP_EQ, 'pool'), Condition('pool_present', OP_EQ, True))
        )

        assert evaluate(condition, {'area': 'pool', 'pool_present': True})
        assert not evaluate(condition, {'area': 'pool', 'pool_present': False})
        assert not evaluate(condition, {'area': 'driveway', 'pool_present': True})

    def test_no_condition_is_always_true(self):
        assert evaluate(None, {})

    def test_an_unknown_operator_is_a_programming_error(self):
        with pytest.raises(ValueError):
            evaluate(Condition('x', 'gte', 1), {'x': 2})


class TestServicesForArea:
    """La tabla área → servicios de P3, calculada desde el `show_when` de cada servicio."""

    @pytest.mark.parametrize(
        ('area', 'expected'),
        [
            (Area.PATIO_DECK, ['paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay']),
            (Area.DRIVEWAY, ['paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay']),
            (Area.ENTRYWAY, ['paver', 'concrete_overlay']),
            (Area.WALKWAY, ['paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay']),
            (Area.POOL_DECK, ['paver', 'artificial_turf', 'concrete_pour', 'concrete_overlay']),
        ],
    )
    def test_each_paved_area_offers_what_the_table_says(self, area, expected):
        assert catalog.services_for(area, pool_present=False) == expected
        assert catalog.services_for(area, pool_present=True) == expected

    def test_the_pool_offers_renovation_only_when_a_pool_was_detected(self):
        assert catalog.services_for(Area.POOL, pool_present=True) == ['pool_renovation']
        assert catalog.services_for(Area.POOL, pool_present=False) == []

    def test_only_the_pool_requires_a_detected_pool(self):
        assert catalog.requires_pool_present(Area.POOL)
        assert not any(catalog.requires_pool_present(area) for area in catalog.PAVED_AREAS)

    def test_every_service_is_offered_somewhere(self):
        offered = {
            service for area in Area for service in catalog.services_for(area, pool_present=True)
        }

        assert offered == set(Service.values)

    def test_schema_for_knows_the_five_services(self):
        for service in Service:
            assert catalog.schema_for(service).service == service
        with pytest.raises(KeyError):
            catalog.schema_for('pergola')


def values(options) -> list[str]:
    return [option.value for option in options]


class TestPaverDependencies:
    schema = catalog.schema_for(Service.PAVER)

    def test_colors_follow_the_material(self):
        color = self.schema.field('color')

        assert values(catalog.options_for(color, {'material': 'travertine'})) == [
            'ivory',
            'walnut',
            'noce',
            'country_classic',
            'tahoe',
            'silver',
            'lucida_sandblasted',
            'white_aurora',
        ]
        assert values(catalog.options_for(color, {'material': 'brick'})) == [
            'red_terracotta',
            'brown_earth_tone',
            'charcoal_gray',
            'mixed_tumbled',
        ]
        assert values(catalog.options_for(color, {'material': 'porcelain'})) == [
            'light_gray',
            'dark_gray',
            'beige_sand',
            'white',
        ]
        assert values(catalog.options_for(color, {'material': 'marble'})) == [
            'beige',
            'gray',
            'white',
        ]
        # Sin material no hay color que ofrecer.
        assert catalog.options_for(color, {}) == ()

    def test_a_value_shared_between_materials_is_one_option(self):
        """`white` está en porcelain y en marble: una opción con condición, no dos."""
        color = self.schema.field('color')

        assert values(color.options).count('white') == 1

    def test_sizes_follow_the_material(self):
        """Una carta por material, sin nada compartido: son cuatro listas, no una."""
        size = self.schema.field('size')

        assert values(catalog.options_for(size, {'material': 'travertine'})) == [
            'french_pattern_mix',
            '6x12',
            '12x12',
            '12x24',
            '16x16',
            '16x24',
            '24x24',
        ]
        assert values(catalog.options_for(size, {'material': 'marble'})) == [
            'french_pattern_mix',
            '12x12',
            '12x24',
            '16x24',
            '18x18',
            '24x24',
        ]
        assert values(catalog.options_for(size, {'material': 'porcelain'})) == [
            '12x24',
            '16x32',
            '18x36',
            '24x24',
            '24x36',
            '24x48',
            '32x32',
            '36x36',
        ]
        assert values(catalog.options_for(size, {'material': 'brick'})) == [
            '4x8',
            '4x4',
            '6x6',
            '8x8',
            '8x12',
            '12x12',
        ]

    def test_the_french_pattern_is_tied_to_the_french_mix(self):
        """Con la mezcla, el patrón queda fijo; con otro tamaño, no se ofrece (pregunta 15)."""
        pattern = self.schema.field('pattern')

        assert values(catalog.options_for(pattern, {'size': 'french_pattern_mix'})) == [
            'french_pattern'
        ]
        assert 'french_pattern' not in values(catalog.options_for(pattern, {'size': '6x12'}))

    def test_the_bonds_go_with_every_rectangular_size(self):
        pattern = self.schema.field('pattern')

        for size in ('4x8', '6x12', '8x12', '12x24', '16x24', '16x32', '18x36', '24x36', '24x48'):
            offered = values(catalog.options_for(pattern, {'size': size}))
            assert 'running_bond' in offered, size
            assert 'stack_bond' in offered, size
        for size in ('4x4', '12x12', '24x24', '36x36', 'french_pattern_mix'):
            assert 'running_bond' not in values(catalog.options_for(pattern, {'size': size})), size

    def test_herringbone_and_basketweave_go_with_the_three_two_to_one_sizes(self):
        """
        La lista es la del owner y no el resultado de dividir: 16x32, 18x36 y
        24x48 también dan dos a uno, y quedan afuera igual.
        """
        pattern = self.schema.field('pattern')
        expected = {
            'herringbone_45',
            'herringbone_90',
            'basketweave_standard',
            'basketweave_boxed',
        }

        for size in ('4x8', '6x12', '12x24'):
            assert expected <= set(values(catalog.options_for(pattern, {'size': size}))), size
        for size in ('16x32', '18x36', '24x48', '8x12', '16x24', '24x24'):
            offered = set(values(catalog.options_for(pattern, {'size': size})))
            assert not (expected & offered), size

    def test_the_grid_and_the_diagonal_go_with_every_square_size(self):
        pattern = self.schema.field('pattern')

        for size in ('4x4', '6x6', '8x8', '12x12', '16x16', '18x18', '24x24', '32x32', '36x36'):
            offered = values(catalog.options_for(pattern, {'size': size}))
            assert 'large_grid' in offered, size
            assert 'diagonal_diamond' in offered, size
        for size in ('4x8', '6x12', '12x24', '24x36', 'french_pattern_mix'):
            assert 'large_grid' not in values(catalog.options_for(pattern, {'size': size})), size

    def test_the_border_courses_are_brick_four_by_eight_and_nothing_else(self):
        pattern = self.schema.field('pattern')
        brick = {'material': 'brick', 'size': '4x8'}

        offered = values(catalog.options_for(pattern, brick))
        assert 'sailor_course' in offered
        assert 'soldier_course' in offered
        # El rótulo dice para qué son: es una hilada de borde, no el patrón del
        # campo entero.
        assert 'decorative border' in pattern.option('sailor_course').label
        for context in ({'material': 'brick', 'size': '8x12'}, {'size': '4x8'}):
            assert 'sailor_course' not in values(catalog.options_for(pattern, context)), context

    def test_turf_strips_go_with_any_size_that_reaches_twenty_four_inches(self):
        turf_strips = self.schema.field('turf_strips')

        for size in ('12x24', '16x24', '16x32', '18x36', '24x24', '24x36', '24x48', '32x32'):
            assert catalog.is_shown(turf_strips, {'size': size}), size
        # Ningún tamaño de brick llega a 24", así que con brick no aparece.
        for size in ('4x4', '4x8', '6x6', '6x12', '8x8', '8x12', '12x12', 'french_pattern_mix'):
            assert not catalog.is_shown(turf_strips, {'size': size}), size
        assert not catalog.is_shown(turf_strips, {})

    def test_the_coping_depends_on_the_request_not_on_the_container(self):
        """
        El coping es el remate del borde de la pileta: se pregunta porque el
        pedido incluye la vereda de la pileta, aunque la caja que se esté
        contestando sea otra.
        """
        coping = self.schema.field('coping')

        assert catalog.is_shown(coping, catalog.request_scope(['driveway', 'pool_deck']))
        assert not catalog.is_shown(coping, catalog.request_scope(['driveway']))
        assert not catalog.is_shown(coping, {})

    def test_the_coping_offers_the_three_styles_the_company_sells(self):
        """
        Bullnose, Tumbled Edge e Infinity Edge, y nada más. Sin `keep` no hay
        una respuesta que no sea una decisión de diseño: arranca sin respuesta
        y es obligatoria.
        """
        coping = self.schema.field('coping')

        assert coping.type == catalog.FIELD_RADIO
        assert self.schema.field('coping_style') is None
        assert values(coping.offered) == ['bullnose', 'tumbled', 'infinity_edge']
        assert coping.option('tumbled').label == 'Tumbled Edge'
        assert coping.required
        assert coping.default is None

    def test_the_retired_copings_are_still_known(self):
        """
        Hay pedidos guardados con esos códigos, y es probable que vuelvan: se
        retiran, no se borran.
        """
        coping = self.schema.field('coping')

        for code in ('keep', 'none', 'pool_coping_cap', 'straight_cut', 'match_field_pavers'):
            option = coping.option(code)
            assert option is not None, code
            assert option.retired, code

    def test_a_retired_option_does_not_travel(self):
        """Ni a la pantalla ni a la landing: lo que viaja es lo que se ofrece."""
        props = catalog.catalog_props('/static/')
        paver = next(schema for schema in props['services'] if schema['service'] == 'paver')
        coping = next(field for field in paver['fields'] if field['id'] == 'coping')

        assert [option['value'] for option in coping['options']] == [
            'bullnose',
            'tumbled',
            'infinity_edge',
        ]

    def test_optional_fields_are_the_ones_the_owner_marked(self):
        optional = [field.id for field in self.schema.fields if not field.required]

        assert optional == ['border', 'seal', 'turf_strips']


class TestConcretePour:
    schema = catalog.schema_for(Service.CONCRETE_POUR)

    def test_the_layout_appears_only_with_turf_strips(self):
        layout = self.schema.field('layout')

        assert catalog.is_shown(layout, {'turf_strips': True})
        assert not catalog.is_shown(layout, {'turf_strips': False})
        assert not catalog.is_shown(layout, {})
        # La condición compara con booleano, no con 'yes' (pregunta 44).
        assert not catalog.is_shown(layout, {'turf_strips': 'yes'})

    def test_the_toggle_is_required_and_says_something_either_way(self):
        turf_strips = self.schema.field('turf_strips')

        assert turf_strips.type == catalog.FIELD_TOGGLE
        assert turf_strips.required
        assert turf_strips.prompt_true
        assert turf_strips.prompt_false


class TestConcreteOverlay:
    schema = catalog.schema_for(Service.CONCRETE_OVERLAY)

    def test_the_shape_comes_first_then_the_stone_then_the_grout(self):
        assert [field.id for field in self.schema.fields] == ['shape', 'stone_color', 'grout_color']

    def test_the_shape_is_a_two_way_switch_that_starts_irregular(self):
        """
        Irregular es lo que el generador hacía antes de la pregunta: quien no
        toca el control obtiene lo mismo que antes.
        """
        shape = self.schema.field('shape')

        assert shape.type == catalog.FIELD_SEGMENTED
        assert values(shape.options) == ['regular', 'irregular']
        assert shape.default == 'irregular'
        assert shape.required
        assert 'square and rectangular' in shape.option('regular').prompt
        assert 'flagstone' in shape.option('irregular').prompt

    def test_every_stone_has_one_photo_per_shape_and_none_for_both(self):
        stone_color = self.schema.field('stone_color')

        assert stone_color.image_by == 'shape'
        for option in stone_color.options:
            assert option.image is None, option.value
            assert dict(option.images) == {
                'regular': f'ai/concrete_overlay/stone_color/regular/{option.value}.jpg',
                'irregular': f'ai/concrete_overlay/stone_color/irregular/{option.value}.jpg',
            }

    def test_the_stone_shown_and_sent_follows_the_shape(self):
        stone_color = self.schema.field('stone_color')
        gray = stone_color.option('gray_escape')

        assert (
            stone_color.image_of(gray, {'shape': 'regular'})
            == 'ai/concrete_overlay/stone_color/regular/gray_escape.jpg'
        )
        assert (
            stone_color.sample_of(gray, {'shape': 'irregular'})
            == 'ai/concrete_overlay/stone_color/irregular/gray_escape.jpg'
        )
        # Sin forma no hay foto que valga: no se elige una por las dudas.
        assert stone_color.image_of(gray, {}) is None


class TestArtificialTurf:
    schema = catalog.schema_for(Service.ARTIFICIAL_TURF)

    def test_no_edging_is_the_first_option_and_says_so_in_the_prompt(self):
        """
        Sin la opción, «sin borde» es no contestar, y lo que no se contesta
        queda a criterio del modelo (R49). Elegirla lo dice.
        """
        edging = self.schema.field('edging')

        assert values(edging.options) == ['none', 'plastic_polycarbonate_6', 'aluminum_6']
        assert edging.option('none').prompt == 'no edging border around the turf'


class TestPoolRenovation:
    schema = catalog.schema_for(Service.POOL_RENOVATION)

    def test_the_spa_location_sits_between_the_spa_and_the_sun_shelf(self):
        ids = [field.id for field in self.schema.fields]

        assert ids[ids.index('add_spa') + 1] == 'spa_location'
        assert ids[ids.index('spa_location') + 1] == 'add_sun_shelf'

    def test_the_spa_location_appears_only_with_a_spa(self):
        """«No» y sin contestar son lo mismo acá: no hay spa que ubicar."""
        location = self.schema.field('spa_location')

        assert catalog.is_shown(location, {'add_spa': '6x6_square'})
        assert catalog.is_shown(location, {'add_spa': '6x6_round'})
        assert not catalog.is_shown(location, {'add_spa': 'no'})
        assert not catalog.is_shown(location, {})
        assert values(location.options) == [
            'inside_pool',
            'perimeter_spillover',
            'outside_sheer_descent',
        ]

    def test_the_spillover_prompt_describes_the_configuration_not_the_name(self):
        """
        «Perimeter / Spillover» es el nombre del rubro y no dice nada al modelo:
        lo que se le manda es dónde se apoya el spa y hacia dónde cae el agua.
        """
        spillover = self.schema.field('spa_location').option('perimeter_spillover')

        assert 'bond beam' in spillover.prompt
        assert 'spilling water over its edge into the pool' in spillover.prompt
        assert 'perimeter' not in spillover.prompt.lower()

    def test_bubblers_are_said_only_with_the_explicit_option(self):
        sun_shelf = self.schema.field('add_sun_shelf')

        with_bubblers = sun_shelf.option('with_bubblers')
        standard = sun_shelf.option('standard_shelf')
        assert any('bubbler' in token.lower() for token in with_bubblers.prompt_tokens)
        assert standard.prompt_tokens == ()
        assert 'bubbler' not in standard.prompt.lower()

    def test_a_plaster_card_shows_the_stone_and_sends_the_water(self):
        """
        La persona elige mirando la piedrita; al modelo viaja la pileta con el
        agua que esa piedrita da. Son dos imágenes de la misma opción.
        """
        plaster = self.schema.field('plaster_color')
        sky_blue = plaster.option('sky_blue')

        assert sky_blue.image == 'ai/pool_renovation/plaster_color/sky_blue.jpg'
        assert (
            plaster.sample_of(sky_blue, {})
            == 'ai/pool_renovation/plaster_color/reference/sky_blue.jpg'
        )

    def test_the_plaster_prompt_leaves_the_color_to_the_reference(self):
        """Un color escrito y otro visto le dejarían al modelo elegir cuál seguir."""
        plaster = self.schema.field('plaster_color')

        assert plaster.reference == 'the water color'
        assert plaster.option('aqua_clear').prompt == (
            'the Aqua Clear plaster finish, which gives the water the color of its reference image'
        )

    def test_the_codes_already_stored_in_requests_still_exist(self):
        """Los ocho acabados de antes de la carta siguen con su código, aunque cambie el rótulo."""
        plaster = self.schema.field('plaster_color')

        for code in (
            'sky_blue',
            'blue_gem',
            'bone',
            'double_sky',
            'french_silver',
            'capri',
            'azure',
            'hawaiian',
        ):
            assert plaster.option(code) is not None, code
        assert plaster.option('double_sky').label == 'Double Sky Blue'

    def test_units_stay_in_the_labels_not_in_the_codes(self):
        """R19: pulgadas en el waterline, pies en el spa; los códigos van sin unidad."""
        waterline = self.schema.field('waterline_size')
        spa = self.schema.field('add_spa')

        assert waterline.option('6x6').label == '6" x 6"'
        assert spa.option('6x6_square').label == "6' x 6' square"


class TestEverySchema:
    @pytest.mark.parametrize('schema', catalog.SCHEMAS, ids=lambda schema: schema.service)
    def test_every_field_has_a_known_type_and_unique_option_codes(self, schema):
        ids = [field.id for field in schema.fields]
        assert len(ids) == len(set(ids))
        for field in schema.fields:
            assert field.type in catalog.FIELD_TYPES, field.id
            codes = values(field.options)
            assert len(codes) == len(set(codes)), field.id

    @pytest.mark.parametrize('schema', catalog.SCHEMAS, ids=lambda schema: schema.service)
    def test_a_segmented_field_has_few_options_and_an_icon_for_each(self, schema):
        """
        Son botones lado a lado: más de tres no entran en el ancho de un
        teléfono, y un botón sin ícono pierde lo que lo hace legible de un
        vistazo. Íconos sólo ahí: en otro tipo de campo nadie los dibuja.
        """
        for field in schema.fields:
            for option in field.options:
                if field.type == catalog.FIELD_SEGMENTED:
                    assert option.icon, f'{field.id}.{option.value}'
                else:
                    assert option.icon is None, f'{field.id}.{option.value}'
            if field.type == catalog.FIELD_SEGMENTED:
                assert 2 <= len(field.options) <= 3, field.id

    @pytest.mark.parametrize('schema', catalog.SCHEMAS, ids=lambda schema: schema.service)
    def test_a_photo_that_depends_on_an_answer_names_an_earlier_field(self, schema):
        """
        La foto se elige con una respuesta que ya está cuando se dibuja la
        tarjeta, y cada opción tiene una foto por cada respuesta posible.
        """
        ids = [field.id for field in schema.fields]
        for field in schema.fields:
            if not field.image_by:
                continue
            assert field.image_by in ids[: ids.index(field.id)], field.id
            answers = values(schema.field(field.image_by).options)
            for option in field.options:
                assert sorted(dict(option.images)) == sorted(answers), option.value

    @pytest.mark.parametrize('schema', catalog.SCHEMAS, ids=lambda schema: schema.service)
    def test_every_option_has_a_prompt_and_toggles_have_none(self, schema):
        """R43: toda opción lleva texto para el modelo; un toggle lo lleva en el campo."""
        for field in schema.fields:
            if field.type == catalog.FIELD_TOGGLE:
                assert field.options == (), field.id
                assert field.prompt_true, field.id
                # Apagado también se dice: la pantalla lo describe con una
                # promesa y el prompt no puede callarla.
                assert field.prompt_false, field.id
            else:
                assert field.options, field.id
                assert field.prompt_true is None and field.prompt_false is None, field.id
                for option in field.options:
                    assert option.prompt, f'{field.id} {option.value}'

    @pytest.mark.parametrize('schema', catalog.SCHEMAS, ids=lambda schema: schema.service)
    def test_cards_have_a_photo_by_convention_and_nothing_else_does(self, schema):
        for field in schema.fields:
            for option in field.options:
                if field.type == catalog.FIELD_RADIO_CARD and field.image_by:
                    # Una carpeta por respuesta, con el mismo nombre de archivo.
                    assert option.image is None, option.value
                    for answer, path in option.images:
                        assert path == (
                            f'ai/{schema.service}/{field.id}/{answer}/{option.value}.jpg'
                        ), option.value
                elif field.type == catalog.FIELD_RADIO_CARD:
                    assert option.image == (f'ai/{schema.service}/{field.id}/{option.value}.jpg'), (
                        option.value
                    )
                    assert option.images == (), option.value
                else:
                    assert option.image is None, option.value
                    assert option.images == (), option.value

    def test_card_options_are_the_twenty_seven_that_draw_a_card(self):
        """El patrón de overlay tiene una sola opción con default: no se dibuja, no lleva foto."""
        keys = [f'{s.service}.{f.id}.{o.value}' for s, f, o in catalog.card_options()]

        assert len(keys) == 27
        assert keys[:4] == [
            'paver.material.travertine',
            'paver.material.brick',
            'paver.material.porcelain',
            'paver.material.marble',
        ]
        assert 'artificial_turf.use_type.putting_green' in keys
        assert 'pool_renovation.plaster_color.hawaiian' in keys
        assert 'concrete_overlay.stone_color.terracotta_medley' in keys
        assert 'concrete_overlay.pattern.natural_stone_cut' not in keys

    def test_a_field_that_sends_its_sample_as_a_reference_has_one_for_every_option(self):
        """
        La muestra se adjunta a la llamada, así que una opción sin foto dejaría
        al modelo con el nombre de fantasía y nada más: justo lo que la
        referencia viene a arreglar.
        """
        referencing = [
            (schema, field)
            for schema in catalog.SCHEMAS
            for field in schema.fields
            if field.reference
        ]

        assert [(schema.service, field.id) for schema, field in referencing] == [
            ('concrete_overlay', 'stone_color'),
            ('pool_renovation', 'plaster_color'),
        ]
        for _schema, field in referencing:
            assert field.options
            for option in field.options:
                assert option.photos, option.value

    def test_every_sample_sent_to_the_model_is_on_disk(self):
        """
        Una muestra que falta se descubre recién al generar, como
        `REFERENCE_MISSING`: acá se descubre antes, sin pagar la llamada.
        """
        static = Path(settings.STATICFILES_DIRS[0])

        for schema in catalog.SCHEMAS:
            for field in schema.fields:
                if not field.reference:
                    continue
                for option in field.options:
                    # Una por cada respuesta que puede elegirla: con `image_by`,
                    # la piedra viaja en la forma que se pidió.
                    samples = (option.reference_image,) if option.reference_image else option.photos
                    for relative in samples:
                        sample = static / relative
                        assert sample.is_file(), relative
                        assert sample.stat().st_size > 0, relative

    def test_every_card_has_its_photo_on_disk(self):
        """
        La foto de una tarjeta es del producto y se versiona: una opción con
        tarjeta sin su JPG en `static/` es una tarjeta gris en producción.
        """
        static = Path(settings.STATICFILES_DIRS[0])

        for _schema, _field, option in catalog.card_options():
            assert option.photos, option.value
            for relative in option.photos:
                photo = static / relative
                assert photo.is_file(), relative
                assert photo.stat().st_size > 0, relative

    @pytest.mark.parametrize('schema', catalog.SCHEMAS, ids=lambda schema: schema.service)
    def test_every_service_has_at_least_one_required_field(self, schema):
        """Pregunta 21: todo servicio tiene al menos una respuesta obligatoria."""
        assert any(field.required for field in schema.fields)

    @pytest.mark.parametrize('schema', catalog.SCHEMAS, ids=lambda schema: schema.service)
    def test_a_default_is_one_of_the_options(self, schema):
        for field in schema.fields:
            if field.default is not None:
                assert field.option(field.default) is not None, field.id

    @pytest.mark.parametrize('schema', catalog.SCHEMAS, ids=lambda schema: schema.service)
    def test_no_prompt_mentions_a_place(self, schema):
        assert 'florida' not in schema.prompt.lower()
        for field in schema.fields:
            for option in field.options:
                assert 'florida' not in option.prompt.lower()


class TestProps:
    props = catalog.catalog_props('/static/')

    def test_the_areas_that_share_a_design_travel_as_data(self):
        """
        Cuál va con cuál es una regla del negocio: la circulación de la casa se
        pavimenta junta. La pantalla la ofrece agrupar, pero no la decide.
        """
        assert self.props['groupable_areas'] == ['driveway', 'entryway', 'walkway']
        assert 'pool' not in self.props['groupable_areas']
        assert 'patio_deck' not in self.props['groupable_areas']

    def test_the_guards_travel_with_both_wordings(self):
        """
        La pantalla arma el prompt a la vista: si sólo viajara el estado, bajar
        un guard se vería como una línea que desaparece y el servidor mandaría
        otra cosa.
        """
        guards = {guard['id']: guard for guard in self.props['guards']}

        assert list(guards) == ['coverage', 'objects']
        assert guards['coverage']['raised'] != guards['coverage']['lowered']
        assert guards['coverage']['notes_lowered']
        # El de objetos no gobierna lo que las notas pueden pedir: su línea del
        # cierre ya lo dice, y el bloque de notas no crece de gusto.
        assert guards['objects']['notes_raised'] == ''
        assert guards['objects']['notes_lowered'] == ''

    def test_what_no_guard_lifts_is_not_in_the_list(self):
        """Personas y fotorrealismo no son guards: son lo que el producto promete."""
        wordings = ' '.join(
            guard[key]
            for guard in self.props['guards']
            for key in ('raised', 'lowered', 'notes_raised', 'notes_lowered')
        ).lower()

        assert 'people' not in wordings
        assert 'photorealistic' not in wordings

    def test_areas_carry_their_services_and_the_pool_exception_as_data(self):
        by_value = {area['value']: area for area in self.props['areas']}

        assert list(by_value) == Area.values
        assert by_value['entryway']['services'] == ['paver', 'concrete_overlay']
        assert by_value['pool'] == {
            'value': 'pool',
            'label': 'Pool',
            'services': ['pool_renovation'],
            'requires_pool_present': True,
        }
        assert not by_value['driveway']['requires_pool_present']

    def test_schemas_carry_codes_conditions_and_prompt_pieces_never_screen_text(self):
        """Ni `description` ni el `label` de una opción: eso lo pone la traducción."""
        for schema in self.props['services']:
            assert set(schema) == {'service', 'label', 'prompt', 'image', 'show_when', 'fields'}
            # La foto con la que se elige el servicio, por la convención de ruta.
            assert schema['image'] == f'/static/ai/{schema["service"]}/service.jpg'
            for field in schema['fields']:
                assert set(field) == {
                    'id',
                    'type',
                    'label',
                    'required',
                    'default',
                    'show_when',
                    'prompt_true',
                    'prompt_false',
                    'image_by',
                    'options',
                }
                for option in field['options']:
                    assert set(option) == {
                        'value',
                        'prompt',
                        'prompt_tokens',
                        'image',
                        'images',
                        'icon',
                        'shown_if',
                    }

    def test_conditions_travel_as_plain_data(self):
        by_service = {schema['service']: schema for schema in self.props['services']}

        assert by_service['artificial_turf']['show_when'] == {
            'field': 'area',
            'operator': 'in',
            'value': ['patio_deck', 'driveway', 'walkway', 'pool_deck'],
        }
        assert by_service['pool_renovation']['show_when'] == {
            'all': [
                {'field': 'area', 'operator': 'in', 'value': ['pool']},
                {'field': 'pool_present', 'operator': 'eq', 'value': True},
            ]
        }
        pour_fields = {field['id']: field for field in by_service['concrete_pour']['fields']}
        assert pour_fields['layout']['show_when'] == {
            'field': 'turf_strips',
            'operator': 'eq',
            'value': True,
        }
        paver_fields = {field['id']: field for field in by_service['paver']['fields']}
        assert paver_fields['color']['options'][0]['shown_if'] == {
            'field': 'material',
            'operator': 'in',
            'value': ['travertine'],
        }
        assert paver_fields['material']['show_when'] is None

    def test_images_are_prefixed_with_the_static_url(self):
        by_service = {schema['service']: schema for schema in self.props['services']}
        material = by_service['paver']['fields'][0]

        assert material['type'] == 'radio-card'
        assert material['options'][0]['image'] == '/static/ai/paver/material/travertine.jpg'
        assert by_service['paver']['fields'][1]['options'][0]['image'] is None

    def test_prompt_tokens_travel_as_a_list(self):
        by_service = {schema['service']: schema for schema in self.props['services']}
        sun_shelf = next(
            field
            for field in by_service['pool_renovation']['fields']
            if field['id'] == 'add_sun_shelf'
        )
        with_bubblers = next(o for o in sun_shelf['options'] if o['value'] == 'with_bubblers')

        assert with_bubblers['prompt_tokens'] == [
            'Bubblers: small bubbler water jets rising from the sun shelf'
        ]
