"""
El catálogo v2 del generador: áreas, servicios y el esquema de cada servicio.

Vive en el servidor y no en la pantalla por lo mismo que `TOOLS`: qué se
pregunta es una decisión del producto, y la pantalla dibuja lo que le llega.
El día que las opciones salgan de una tabla de materiales, este módulo se
reemplaza por una consulta y ninguna vista se entera.

El primer nivel es el **área** (lo que hay en la foto: un driveway, una pileta);
sobre cada área se aplica **un servicio** (lo que se le hace: pavers, turf), y
cada servicio tiene su esquema de campos. Qué servicios ofrece un área no es una
tabla aparte: es el `show_when` de cada servicio evaluado contra esa área, y la
tabla que ve la pantalla es el resultado.

Dos clases de dependencia conviven en un mismo esquema, y las dos se guardan
como dato para que el motor las evalúe igual en los cinco servicios:

- un campo que **aparece o no** según lo contestado (`Field.show_when`);
- una opción que **se ofrece o no** según lo contestado (`Option.shown_if`): el
  color según el material, el patrón según el tamaño. Es lo que evita una
  pregunta distinta por rama.

Lo que **no** vive acá son los textos visibles: cada área, servicio, campo y
opción se traduce en la pantalla por su clave. Lo que sí vive acá, en inglés,
es el `label` —que es el token del prompt y la fuente del catálogo en inglés—
y el `prompt` de cada opción, que es lo que se le dice al modelo de imagen.
"""

from dataclasses import dataclass

from django.db import models


class Area(models.TextChoices):
    """Las seis áreas exteriores que el análisis puede encontrar en una foto."""

    PATIO_DECK = 'patio_deck', 'Patio Deck'
    DRIVEWAY = 'driveway', 'Driveway'
    ENTRYWAY = 'entryway', 'Entryway'
    WALKWAY = 'walkway', 'Walkway'
    POOL_DECK = 'pool_deck', 'Pool Deck'
    POOL = 'pool', 'Pool'


class Service(models.TextChoices):
    """Los cinco servicios que vende la compañía. Lista cerrada."""

    PAVER = 'paver', 'Paver'
    ARTIFICIAL_TURF = 'artificial_turf', 'Artificial Turf'
    CONCRETE_POUR = 'concrete_pour', 'Concrete Pour'
    CONCRETE_OVERLAY = 'concrete_overlay', 'Concrete Overlay'
    POOL_RENOVATION = 'pool_renovation', 'Pool Renovation'


#: Las áreas que en un trabajo real llevan el mismo diseño: entrada de autos,
#: sendero y entrada de la casa son la circulación de la casa y se pavimentan
#: juntas, con el mismo material y el mismo color. El patio, la vereda de la
#: pileta y la pileta no entran: cada una es una decisión aparte.
#:
#: Es lo que la pantalla ofrece agrupar en un solo contenedor. Sale del
#: catálogo y no de la pantalla porque es una regla del negocio, no una
#: comodidad de la interfaz.
GROUPABLE_AREAS: tuple[str, ...] = (
    Area.DRIVEWAY.value,
    Area.ENTRYWAY.value,
    Area.WALKWAY.value,
)

#: Las cinco áreas que se pavimentan. La pileta no: se renueva.
PAVED_AREAS: tuple[str, ...] = (
    Area.PATIO_DECK.value,
    Area.DRIVEWAY.value,
    Area.ENTRYWAY.value,
    Area.WALKWAY.value,
    Area.POOL_DECK.value,
)

# ---------------------------------------------------------------------------
# Condiciones
# ---------------------------------------------------------------------------

#: El juego cerrado de operadores. Los archivos del owner traían `===`; acá se
#: normaliza a estos tres y nada más. `any` se agrega el día que un esquema lo
#: necesite.
OP_EQ = 'eq'
OP_IN = 'in'
OP_INCLUDES = 'includes'

#: La clave del contexto que lleva las áreas marcadas en el pedido entero, y no
#: lo contestado en un contenedor. Es lo que permite que una pregunta dependa de
#: qué más se pidió: el coping de Paver aparece porque el pedido incluye la
#: vereda de la pileta, aunque el contenedor que se está contestando sea el
#: patio. Todo lo demás se resuelve con las respuestas del propio contenedor.
AREA_SELECTED = 'area_selected'


@dataclass(frozen=True)
class Condition:
    """
    Una regla sobre un valor del contexto.

    `eq` compara con `value`; `in` pregunta si el valor está en la lista
    `value`; `includes` pregunta si la lista del contexto contiene `value`. El
    contexto es el diccionario de respuestas de un contenedor más el del pedido
    (`AREA_SELECTED`), o —para el `show_when` de un servicio—
    `{'area': ..., 'pool_present': ...}`.
    """

    field: str
    operator: str
    value: object


@dataclass(frozen=True)
class AllOf:
    """Se cumple cuando todas las condiciones se cumplen."""

    conditions: tuple['Condition | AllOf', ...]


def evaluate(condition: 'Condition | AllOf | None', context: dict) -> bool:
    """
    Si la condición se cumple con lo que hay en el contexto.

    Una clave ausente no se cumple nunca, salvo `eq` contra `None`: lo que no
    se contestó no es igual a nada, no está en ninguna lista y no contiene
    nada. Es lo que hace que una pregunta dependiente no se muestre hasta que
    la anterior tenga respuesta.
    """
    if condition is None:
        return True
    if isinstance(condition, AllOf):
        return all(evaluate(inner, context) for inner in condition.conditions)
    actual = context.get(condition.field)
    if condition.operator == OP_EQ:
        return actual == condition.value
    if condition.operator == OP_IN:
        return actual is not None and actual in condition.value
    if condition.operator == OP_INCLUDES:
        return isinstance(actual, list | tuple | set) and condition.value in actual
    raise ValueError(f'Unknown operator: {condition.operator}')


# ---------------------------------------------------------------------------
# Esquema
# ---------------------------------------------------------------------------

#: Cómo se contesta un campo en pantalla. Lo decide el catálogo y no la
#: pantalla, para que cambiar una pregunta de lista a tarjetas sea tocar una
#: palabra acá. `radio-card` exige una foto por opción; `toggle` no tiene
#: opciones y su respuesta es un booleano. `segmented` es para dos o tres
#: opciones que se leen como un interruptor —se prende una y se apaga la
#: otra—: botones grandes lado a lado, cada uno con su ícono, y por eso exige
#: un `icon` por opción.
FIELD_RADIO_CARD = 'radio-card'
FIELD_SELECT = 'select'
FIELD_RADIO = 'radio'
FIELD_TOGGLE = 'toggle'
FIELD_SEGMENTED = 'segmented'

FIELD_TYPES = (FIELD_RADIO_CARD, FIELD_SELECT, FIELD_RADIO, FIELD_TOGGLE, FIELD_SEGMENTED)


@dataclass(frozen=True)
class Option:
    """
    Una opción de respuesta.

    `value` es lo que se guarda y lo que viaja: un código estable en inglés.
    `label` es el rótulo en inglés, fuente del catálogo de traducción y del
    prompt cuando hace falta el nombre. `prompt` es la descripción que recibe
    el modelo de imagen en lugar del rótulo pelado, y es lo único que se afina
    sin migrar nada. `prompt_tokens` son líneas extra que la opción agrega al
    prompt además de la suya. `shown_if` es la condición para que la opción se
    ofrezca, dado lo contestado antes en el mismo contenedor.
    """

    value: str
    label: str
    prompt: str
    description: str | None = None
    prompt_tokens: tuple[str, ...] = ()
    #: Ruta bajo `STATIC_URL`, o nada si la opción va sin foto.
    image: str | None = None
    shown_if: Condition | AllOf | None = None
    #: La muestra que viaja al modelo cuando no es la foto de la tarjeta. Un
    #: acabado de pileta se elige mirando la piedrita, pero al modelo le sirve
    #: el agua que esa piedrita da: la persona no sabe qué agua quiere y el
    #: modelo no sabe qué agua da una piedrita. Sin esto, viaja `image`.
    reference_image: str | None = None
    #: Las fotos de la opción cuando dependen de lo que se contestó en otro
    #: campo (`Field.image_by`): pares de respuesta y ruta. Una piedra de la
    #: carta viene en corte regular y en irregular, y la foto que se elige
    #: tiene que mostrar la forma que se eligió arriba. Con esto, `image` va
    #: vacío: no hay una foto que valga para cualquier respuesta.
    images: tuple[tuple[str, str], ...] = ()
    #: El ícono de la opción en un campo `segmented`. Es un nombre del registro
    #: de íconos de la pantalla, no un archivo.
    icon: str | None = None
    #: Una opción que ya no se ofrece pero que el catálogo sigue conociendo.
    #: No viaja a la pantalla ni a la landing, y un pedido nuevo que la trae es
    #: inválido; los pedidos viejos que la eligieron se siguen leyendo con su
    #: rótulo. Se retira en vez de borrarse cuando es probable que vuelva:
    #: volver es sacar esta marca, con el mismo código, texto y foto.
    retired: bool = False

    @property
    def photos(self) -> tuple[str, ...]:
        """Todas las fotos que la opción puede mostrar, sea cual sea la respuesta."""
        return tuple(path for path in (self.image, *dict(self.images).values()) if path)


@dataclass(frozen=True)
class Field:
    """
    Una pregunta del esquema.

    `required` aplica sólo cuando el campo está visible. `default` es la
    respuesta con la que se inicializa un campo obligatorio cuando no llega
    contestado: un campo con una sola opción válida no dibuja control y viaja
    igual en las respuestas; con varias, se dibuja con el `default` marcado.
    Un `toggle` no tiene `options`: `prompt_true` es
    lo que dice el prompt con `True`, y `prompt_false` con `False`; sin
    `prompt_false`, un `False` no dice nada.

    Todo toggle lleva los dos textos. La pantalla describe el interruptor
    apagado con una promesa —«sin ningún borde alrededor»—, y una promesa que
    el prompt calla queda a criterio del modelo.
    """

    id: str
    type: str
    label: str
    required: bool = True
    options: tuple[Option, ...] = ()
    show_when: Condition | AllOf | None = None
    default: str | None = None
    prompt_true: str | None = None
    prompt_false: str | None = None
    #: Cómo se nombra la muestra de este campo cuando viaja como imagen además
    #: del texto: «the stone color and texture». Vacío es el caso normal —el
    #: campo se describe con palabras y nada más—; con texto, la muestra de la
    #: opción elegida (`sample_of`) se adjunta a la llamada y el prompt la
    #: nombra. Exige que
    #: todas las opciones tengan foto, porque describir con palabras un color
    #: de carta deja el resultado a criterio de quien lo lea.
    reference: str = ''
    #: El campo cuya respuesta elige la foto de cada opción (`Option.images`).
    #: Vacío es el caso normal: cada opción tiene una sola foto, en `image`.
    image_by: str = ''

    def option(self, value: str) -> Option | None:
        return next((option for option in self.options if option.value == value), None)

    @property
    def offered(self) -> tuple[Option, ...]:
        """Las opciones que se ofrecen: todas menos las retiradas (`Option.retired`)."""
        return tuple(option for option in self.options if not option.retired)

    def image_of(self, option: Option, answers: dict) -> str | None:
        """La foto de la opción con lo que se contestó en el contenedor."""
        if not self.image_by:
            return option.image
        return dict(option.images).get(answers.get(self.image_by))

    def sample_of(self, option: Option, answers: dict) -> str | None:
        """
        La imagen que se adjunta a la llamada si el campo manda referencia.

        Es la foto de la tarjeta, salvo que la opción tenga una muestra propia
        para el modelo. Depende de las respuestas por lo mismo que la foto: la
        piedra que viaja tiene que tener la forma que se pidió.
        """
        return option.reference_image or self.image_of(option, answers)


@dataclass(frozen=True)
class ServiceSchema:
    """
    Un servicio con su esquema.

    `show_when` es la regla para salir en el selector de servicio de un área:
    `area in [...]`, y para Pool Renovation además `pool_present`. `prompt` es
    la descripción fija del servicio, la que va aunque no haya respuestas
    (Concrete Pour sin turf strips es concreto gris y nada más).
    """

    service: str
    label: str
    prompt: str
    show_when: Condition | AllOf
    fields: tuple[Field, ...]

    def field(self, field_id: str) -> Field | None:
        return next((field for field in self.fields if field.id == field_id), None)


@dataclass(frozen=True)
class Guard:
    """
    Una regla del cierre que se puede bajar.

    No describe trabajo: describe lo que el modelo **no** puede hacer. Por eso
    tiene dos redacciones de la misma línea del cierre —`raised` y `lowered`— y
    no un texto que aparece o desaparece: bajar un guard no es quedarse callado,
    es decir otra cosa.

    `notes_raised` y `notes_lowered` son lo que el bloque de notas dice en cada
    estado, para los guards que gobiernan qué pueden pedir las notas. Un guard
    que no las toca los deja vacíos y el bloque no crece.
    """

    id: str
    label: str
    raised: str
    lowered: str
    notes_raised: str = ''
    notes_lowered: str = ''


#: Los guards, en el orden en que se leen. Es una lista cerrada, como todo lo
#: demás: bajar uno es una decisión del pedido, no un texto que alguien escribe.
#:
#: Lo que **no** está acá es lo que ningún control levanta —personas, el
#: fotorrealismo, la casa, el ángulo y la luz—: eso vive en el preámbulo y en el
#: cierre fijo, y es lo que la pantalla muestra como lista sin interruptor.
GUARDS: tuple[Guard, ...] = (
    Guard(
        id='coverage',
        label='Respect only the areas requested',
        raised='Do not add areas or features that are not requested in the sections above.',
        lowered=(
            'Do not add areas or features that are not requested in the sections above '
            'or in the Special Notes.'
        ),
        notes_raised='Follow the notes only where they do not contradict the sections above.',
        notes_lowered=(
            'The notes may change which surfaces are edited: they may leave untouched a '
            'surface described above, or ask for work on ground that no section describes. '
            'Where they do, follow the notes instead of the sections above.'
        ),
    ),
    Guard(
        id='objects',
        label='Do not add new objects',
        raised='Do not add any objects that are not in the photo.',
        lowered=(
            'You may add outdoor objects —furniture, a grill, planters— only where the '
            'Special Notes ask for them.'
        ),
    ),
)


def sample_image(service: str, field_id: str, value: str, variant: str = '') -> str:
    """
    Dónde vive la foto de muestra de una opción `radio-card`, bajo `STATIC_URL`.

    Con `variant`, la foto depende de otra respuesta (`Field.image_by`) y cada
    variante tiene su carpeta: misma opción, mismo nombre de archivo.
    """
    if variant:
        return f'ai/{service}/{field_id}/{variant}/{value}.jpg'
    return f'ai/{service}/{field_id}/{value}.jpg'


def service_image(service: str) -> str:
    """
    Dónde vive la foto del servicio, bajo `STATIC_URL`.

    Misma convención que la de una opción, un nivel más arriba: el servicio se
    elige con tarjetas y cada una muestra de qué se trata. `service` no puede
    chocar con el id de un campo, que es lo único que ocupa ese nivel.
    """
    return f'ai/{service}/service.jpg'


def reference_image(service: str, field_id: str, value: str) -> str:
    """
    Dónde vive la muestra para el modelo de una opción que no manda su tarjeta.

    Al lado de las tarjetas, en su propia carpeta: misma opción, mismo nombre,
    y una imagen distinta según quién la mira.
    """
    return f'ai/{service}/{field_id}/reference/{value}.jpg'


def _card(service: str, field_id: str, value: str, label: str, prompt: str, **extra) -> Option:
    """Una opción con tarjeta: su foto sigue la convención de ruta, siempre."""
    return Option(value, label, prompt, image=sample_image(service, field_id, value), **extra)


def _plaster(value: str, label: str, description: str) -> Option:
    """
    Un acabado de la carta del proveedor: la tarjeta es la piedrita y la
    muestra es una pileta con el agua que da.

    El prompt no describe el color con palabras: lo dice la imagen, y dos
    descripciones del mismo color —una escrita y otra vista— le dejan al modelo
    elegir cuál seguir.
    """
    return _card(
        'pool_renovation',
        'plaster_color',
        value,
        label,
        f'the {label} plaster finish, which gives the water the color of its reference image',
        description=description,
        reference_image=reference_image('pool_renovation', 'plaster_color', value),
    )


# ---------------------------------------------------------------------------
# Paver
# ---------------------------------------------------------------------------
#
# Todo lo que sigue es de este servicio y de ninguno otro. Que Hormigón vaciado
# o el overlay lleguen a nombrar medidas parecidas no los ata a estas listas: un
# esquema se acota a su servicio, y el día que la carta de Paver cambie se toca
# acá sin revisar qué más quedó colgado. Por eso los ayudantes de condición
# también llevan el nombre del servicio.


def _paver_material(*materials: str) -> Condition:
    return Condition('material', OP_IN, tuple(materials))


def _paver_size(*sizes: str) -> Condition:
    return Condition('size', OP_IN, tuple(sizes))


#: Los cuadrados: la proporción es 1:1 y por eso admiten grilla y diagonal.
_PAVER_SQUARE_SIZES = (
    '4x4',
    '6x6',
    '8x8',
    '12x12',
    '16x16',
    '18x18',
    '24x24',
    '32x32',
    '36x36',
)
#: Los rectangulares, cualquiera sea la proporción: los que admiten hiladas.
_PAVER_RECTANGULAR_SIZES = (
    '4x8',
    '6x12',
    '8x12',
    '12x24',
    '16x24',
    '16x32',
    '18x36',
    '24x36',
    '24x48',
)
#: Los que la carta trata como 2:1 para espiga y canasto. Es la lista que dio el
#: owner y no el resultado de dividir el largo por el ancho: `16x32`, `18x36` y
#: `24x48` también dan dos, y aun así quedan afuera.
_PAVER_RATIO_2_1_SIZES = ('4x8', '6x12', '12x24')
#: Franjas de pasto: todo tamaño con una dimensión de 24" o más. Ninguno de
#: brick llega, así que con brick la pregunta no aparece.
_PAVER_TURF_STRIP_SIZES = (
    '12x24',
    '16x24',
    '16x32',
    '18x36',
    '24x24',
    '24x36',
    '24x48',
    '32x32',
    '36x36',
)

PAVER = ServiceSchema(
    service=Service.PAVER.value,
    label=Service.PAVER.label,
    prompt='paving stones laid over the entire surface of the area',
    show_when=Condition('area', OP_IN, PAVED_AREAS),
    fields=(
        Field(
            id='material',
            type=FIELD_RADIO_CARD,
            label='Material',
            options=(
                _card(
                    'paver',
                    'material',
                    'travertine',
                    'Travertine',
                    'natural travertine stone pavers with a soft porous texture and subtle veining',
                ),
                _card(
                    'paver',
                    'material',
                    'brick',
                    'Brick Pavers',
                    'classic clay brick pavers with a slightly textured surface',
                ),
                _card(
                    'paver',
                    'material',
                    'porcelain',
                    'Porcelain',
                    'large-format porcelain pavers with a smooth, uniform surface',
                ),
                _card(
                    'paver',
                    'material',
                    'marble',
                    'Marble',
                    'polished natural marble pavers with elegant veining',
                ),
            ),
        ),
        Field(
            id='color',
            type=FIELD_SELECT,
            label='Color',
            options=(
                # Travertine
                Option(
                    'ivory', 'Ivory', 'ivory cream color', shown_if=_paver_material('travertine')
                ),
                Option(
                    'walnut',
                    'Walnut',
                    'warm walnut brown color',
                    shown_if=_paver_material('travertine'),
                ),
                Option(
                    'noce',
                    'Noce',
                    'rich noce brown color with tan tones',
                    shown_if=_paver_material('travertine'),
                ),
                Option(
                    'country_classic',
                    'Country Classic',
                    'country classic blend of beige, tan and light brown',
                    shown_if=_paver_material('travertine'),
                ),
                Option(
                    'tahoe',
                    'Tahoe',
                    'tahoe blend of cream, gray and soft brown',
                    shown_if=_paver_material('travertine'),
                ),
                Option(
                    'silver', 'Silver', 'silver gray color', shown_if=_paver_material('travertine')
                ),
                Option(
                    'lucida_sandblasted',
                    'Lucida Sandblasted',
                    'light lucida color with a sandblasted matte surface',
                    shown_if=_paver_material('travertine'),
                ),
                Option(
                    'white_aurora',
                    'White Aurora',
                    'bright white aurora color with faint veining',
                    shown_if=_paver_material('travertine'),
                ),
                # Brick
                Option(
                    'red_terracotta',
                    'Red / Terracotta',
                    'red terracotta color',
                    shown_if=_paver_material('brick'),
                ),
                Option(
                    'brown_earth_tone',
                    'Brown / Earth Tone',
                    'brown earth tone color',
                    shown_if=_paver_material('brick'),
                ),
                Option(
                    'charcoal_gray',
                    'Charcoal / Gray',
                    'charcoal gray color',
                    shown_if=_paver_material('brick'),
                ),
                Option(
                    'mixed_tumbled',
                    'Mixed Tumbled',
                    'mixed tumbled blend of reds, browns and grays with softened edges',
                    shown_if=_paver_material('brick'),
                ),
                # Porcelain
                Option(
                    'light_gray',
                    'Light Gray',
                    'light gray color',
                    shown_if=_paver_material('porcelain'),
                ),
                Option(
                    'dark_gray',
                    'Dark Gray',
                    'dark gray color',
                    shown_if=_paver_material('porcelain'),
                ),
                Option(
                    'beige_sand',
                    'Beige / Sand',
                    'beige sand color',
                    shown_if=_paver_material('porcelain'),
                ),
                # Marble (white is shared with porcelain: one option, one condition)
                Option('beige', 'Beige', 'beige color', shown_if=_paver_material('marble')),
                Option('gray', 'Gray', 'gray color', shown_if=_paver_material('marble')),
                Option(
                    'white',
                    'White',
                    'clean white color',
                    shown_if=_paver_material('porcelain', 'marble'),
                ),
            ),
        ),
        # Una lista por material, sin nada compartido entre ellos: cada carta se
        # lee sola. Cambiar de material deja el tamaño elegido fuera de las
        # opciones vigentes y la respuesta se descarta, igual que el color.
        Field(
            id='size',
            type=FIELD_SELECT,
            label='Size (inches)',
            options=(
                Option(
                    'french_pattern_mix',
                    'French Pattern Mix',
                    'a French pattern mix of four different paver sizes',
                    shown_if=_paver_material('travertine', 'marble'),
                ),
                Option(
                    '4x8', '4" x 8"', 'small 4 by 8 inch pavers', shown_if=_paver_material('brick')
                ),
                Option(
                    '4x4',
                    '4" x 4"',
                    'small 4 by 4 inch square pavers',
                    shown_if=_paver_material('brick'),
                ),
                Option(
                    '6x6',
                    '6" x 6"',
                    'small 6 by 6 inch square pavers',
                    shown_if=_paver_material('brick'),
                ),
                Option(
                    '6x12',
                    '6" x 12"',
                    '6 by 12 inch pavers',
                    shown_if=_paver_material('travertine'),
                ),
                Option(
                    '8x8',
                    '8" x 8"',
                    '8 by 8 inch square pavers',
                    shown_if=_paver_material('brick'),
                ),
                Option(
                    '8x12', '8" x 12"', '8 by 12 inch pavers', shown_if=_paver_material('brick')
                ),
                Option(
                    '12x12',
                    '12" x 12"',
                    '12 by 12 inch square pavers',
                    shown_if=_paver_material('travertine', 'marble', 'brick'),
                ),
                Option(
                    '12x24',
                    '12" x 24"',
                    '12 by 24 inch pavers',
                    shown_if=_paver_material('travertine', 'marble', 'porcelain'),
                ),
                Option(
                    '16x16',
                    '16" x 16"',
                    '16 by 16 inch square pavers',
                    shown_if=_paver_material('travertine'),
                ),
                Option(
                    '16x24',
                    '16" x 24"',
                    '16 by 24 inch pavers',
                    shown_if=_paver_material('travertine', 'marble'),
                ),
                Option(
                    '16x32',
                    '16" x 32"',
                    '16 by 32 inch pavers',
                    shown_if=_paver_material('porcelain'),
                ),
                Option(
                    '18x18',
                    '18" x 18"',
                    '18 by 18 inch square pavers',
                    shown_if=_paver_material('marble'),
                ),
                Option(
                    '18x36',
                    '18" x 36"',
                    '18 by 36 inch pavers',
                    shown_if=_paver_material('porcelain'),
                ),
                Option(
                    '24x24',
                    '24" x 24"',
                    'large 24 by 24 inch square pavers',
                    shown_if=_paver_material('travertine', 'marble', 'porcelain'),
                ),
                Option(
                    '24x36',
                    '24" x 36"',
                    'large 24 by 36 inch pavers',
                    shown_if=_paver_material('porcelain'),
                ),
                Option(
                    '24x48',
                    '24" x 48"',
                    'large 24 by 48 inch pavers',
                    shown_if=_paver_material('porcelain'),
                ),
                Option(
                    '32x32',
                    '32" x 32"',
                    'large 32 by 32 inch square pavers',
                    shown_if=_paver_material('porcelain'),
                ),
                Option(
                    '36x36',
                    '36" x 36"',
                    'large 36 by 36 inch square pavers',
                    shown_if=_paver_material('porcelain'),
                ),
            ),
        ),
        # El patrón se filtra por la forma del tamaño elegido: la mezcla sólo
        # admite el patrón francés, los rectangulares admiten hiladas, los 2:1
        # además espiga y canasto, y los cuadrados grilla y diagonal. Las dos
        # hiladas de borde son de brick 4x8 y de nada más.
        Field(
            id='pattern',
            type=FIELD_RADIO,
            label='Pattern / Layout',
            options=(
                Option(
                    'french_pattern',
                    'French Pattern',
                    'laid in a French pattern, an irregular interlocking layout of mixed sizes',
                    shown_if=_paver_size('french_pattern_mix'),
                ),
                Option(
                    'running_bond',
                    'Running Bond',
                    'laid in a running bond, each row offset by half a paver from the one before',
                    shown_if=_paver_size(*_PAVER_RECTANGULAR_SIZES),
                ),
                Option(
                    'stack_bond',
                    'Stack Bond',
                    'laid in a stack bond, every paver squarely aligned so the joints run '
                    'straight in both directions',
                    shown_if=_paver_size(*_PAVER_RECTANGULAR_SIZES),
                ),
                Option(
                    'herringbone_45',
                    '45-Degree Herringbone',
                    'laid in a herringbone zigzag set at 45 degrees to the edges of the area',
                    shown_if=_paver_size(*_PAVER_RATIO_2_1_SIZES),
                ),
                Option(
                    'herringbone_90',
                    '90-Degree Herringbone',
                    'laid in a herringbone zigzag set square to the edges of the area',
                    shown_if=_paver_size(*_PAVER_RATIO_2_1_SIZES),
                ),
                Option(
                    'basketweave_standard',
                    'Standard Basketweave',
                    'laid in a standard basketweave, pairs of pavers alternating between '
                    'horizontal and vertical',
                    shown_if=_paver_size(*_PAVER_RATIO_2_1_SIZES),
                ),
                Option(
                    'basketweave_boxed',
                    'Boxed Basketweave',
                    'laid in a boxed basketweave, each pair of pavers framed by pavers laid '
                    'at a right angle around it',
                    shown_if=_paver_size(*_PAVER_RATIO_2_1_SIZES),
                ),
                Option(
                    'diagonal_diamond',
                    'Diagonal / Diamond',
                    'laid diagonally in a diamond layout',
                    shown_if=_paver_size(*_PAVER_SQUARE_SIZES),
                ),
                Option(
                    'large_grid',
                    'Large Grid',
                    'laid in a clean large grid with straight aligned joints',
                    shown_if=_paver_size(*_PAVER_SQUARE_SIZES),
                ),
                Option(
                    'sailor_course',
                    'Sailor Course (decorative border)',
                    'laid as a decorative border course, the pavers running lengthwise along '
                    'the edge of the area',
                    shown_if=AllOf((_paver_material('brick'), _paver_size('4x8'))),
                ),
                Option(
                    'soldier_course',
                    'Soldier Course (decorative border)',
                    'laid as a decorative border course, the pavers set end to end and '
                    'perpendicular to the edge of the area',
                    shown_if=AllOf((_paver_material('brick'), _paver_size('4x8'))),
                ),
            ),
        ),
        Field(
            id='border',
            type=FIELD_TOGGLE,
            # «Contrast border» y no «Border» a secas: el pedido también tiene el
            # borde de la pileta —el coping—, y con un rótulo pelado se
            # confundían los dos.
            label='Contrast border',
            required=False,
            prompt_true='a contrasting paver border running along the edges of the area',
            prompt_false=(
                'no contrasting border: the field pavers run all the way to the edges of the area'
            ),
        ),
        Field(
            id='seal',
            type=FIELD_RADIO,
            label='Seal',
            required=False,
            options=(
                Option(
                    'natural',
                    'Natural (no sealer)',
                    'unsealed, natural matte stone surface',
                ),
                Option(
                    'wet_look',
                    'Wet look / gloss',
                    'sealed with a glossy wet-look finish that deepens the color',
                ),
                Option('matte', 'Matte / flat sheen', 'sealed with a flat matte finish, no shine'),
            ),
        ),
        Field(
            id='turf_strips',
            type=FIELD_TOGGLE,
            label='Turf Strips',
            required=False,
            show_when=_paver_size(*_PAVER_TURF_STRIP_SIZES),
            prompt_true='narrow strips of green turf between the pavers',
            prompt_false='no turf between the pavers: they sit tight against each other',
        ),
        # El coping es el remate del borde de la pileta: se pregunta porque el
        # pedido incluye la vereda de la pileta, no porque este contenedor sea
        # ésa. Es la única pregunta del catálogo que mira el pedido entero.
        #
        # Es una sola lista y no un interruptor con su estilo aparte: el borde
        # de la pileta llegó a tener tres salidas —dejar el que está, sacarlo o
        # poner uno nuevo— y un interruptor sólo sabe decir dos.
        #
        # Hoy la compañía ofrece tres estilos y nada más (owner, 2026-10-12),
        # así que la pregunta arranca sin respuesta y es obligatoria: sin
        # `keep` no hay una que no sea una decisión de diseño. El resto se
        # retira y no se borra, porque es probable que vuelva y porque hay
        # pedidos guardados con esos códigos.
        Field(
            id='coping',
            type=FIELD_RADIO,
            label='Coping',
            show_when=Condition(AREA_SELECTED, OP_INCLUDES, Area.POOL_DECK.value),
            options=(
                Option(
                    'bullnose',
                    'Bullnose',
                    'bullnose coping with a rounded front edge capping the edge of the pool',
                ),
                Option(
                    'tumbled',
                    'Tumbled Edge',
                    'tumbled coping with softened, weathered edges capping the edge of the pool',
                ),
                # Como estilo de coping, no como pileta de desborde: eso sería
                # reconstruir la pared, y esta pregunta sólo remata el borde.
                Option(
                    'infinity_edge',
                    'Infinity Edge',
                    'an infinity-style edge: a thin, flush coping with no overhanging lip and the '
                    'water level right at its top, so the water seems to meet the deck with no '
                    'visible border',
                ),
                Option(
                    'keep',
                    'Leave it as it is',
                    'no new coping: the edge of the pool stays exactly as it is in the photo',
                    retired=True,
                ),
                Option(
                    'none',
                    'No coping: the deck runs to the water',
                    'no coping at all: remove any existing coping and run the field pavers all '
                    "the way to the water's edge, flush with the pool wall",
                    retired=True,
                ),
                Option(
                    'pool_coping_cap',
                    'Pool coping cap',
                    'a pool coping cap that overhangs the edge of the pool with a squared lip',
                    retired=True,
                ),
                Option(
                    'straight_cut',
                    'Straight cut',
                    'straight cut coping with sharp square edges capping the edge of the pool',
                    retired=True,
                ),
                Option(
                    'match_field_pavers',
                    'Match field pavers',
                    'coping in the same material, color and finish as the field pavers, capping '
                    'the edge of the pool',
                    retired=True,
                ),
            ),
        ),
    ),
)

# ---------------------------------------------------------------------------
# Artificial Turf
# ---------------------------------------------------------------------------

#: Dónde se ofrece pasto sintético. Escrito entero y no derivado de otro
#: servicio: que hoy coincida con las de Hormigón vaciado es una coincidencia
#: del negocio, no una regla, y el día que una de las dos cambie se toca una
#: sola lista.
_TURF_AREAS = (
    Area.PATIO_DECK.value,
    Area.DRIVEWAY.value,
    Area.WALKWAY.value,
    Area.POOL_DECK.value,
)

ARTIFICIAL_TURF = ServiceSchema(
    service=Service.ARTIFICIAL_TURF.value,
    label=Service.ARTIFICIAL_TURF.label,
    prompt='artificial turf covering the entire surface of the area',
    show_when=Condition('area', OP_IN, _TURF_AREAS),
    fields=(
        Field(
            id='use_type',
            type=FIELD_RADIO_CARD,
            label='Use type',
            options=(
                _card(
                    'artificial_turf',
                    'use_type',
                    'general_use',
                    'General use',
                    'lush general-purpose artificial lawn turf with natural-looking blades',
                ),
                _card(
                    'artificial_turf',
                    'use_type',
                    'putting_green',
                    'Putting green',
                    'short, dense putting-green turf with a smooth, even surface',
                ),
            ),
        ),
        Field(
            id='shade',
            type=FIELD_RADIO,
            label='Shade',
            options=(
                Option('bright', 'Bright', 'bright vivid green'),
                Option('medium', 'Medium', 'medium natural green'),
                Option('dark', 'Dark', 'deep dark green'),
            ),
        ),
        Field(
            id='edging',
            type=FIELD_SELECT,
            label='Edging types',
            required=False,
            options=(
                # Primera de la lista y dicha en voz alta: sin ella, «sin borde»
                # es no contestar, y lo que no se contesta queda a criterio del
                # modelo (R49). Elegirla lo dice.
                Option('none', 'No edging', 'no edging border around the turf'),
                Option(
                    'plastic_polycarbonate_6',
                    'Plastic / Polycarbonate - 6"',
                    'a 6 inch plastic polycarbonate edging border around the turf',
                ),
                Option(
                    'aluminum_6',
                    'Aluminum - 6"',
                    'a 6 inch aluminum edging border around the turf',
                ),
            ),
        ),
    ),
)

# ---------------------------------------------------------------------------
# Concrete Pour
# ---------------------------------------------------------------------------

#: Dónde se ofrece hormigón vaciado, con su propia lista. Ver `_TURF_AREAS`.
_POUR_AREAS = (
    Area.PATIO_DECK.value,
    Area.DRIVEWAY.value,
    Area.WALKWAY.value,
    Area.POOL_DECK.value,
)

CONCRETE_POUR = ServiceSchema(
    service=Service.CONCRETE_POUR.value,
    label=Service.CONCRETE_POUR.label,
    # No hay color ni acabado a elegir: es concreto vaciado normal (owner,
    # pregunta 20). El prompt lo dice fijo.
    prompt='plain poured concrete in natural gray with a standard finish',
    show_when=Condition('area', OP_IN, _POUR_AREAS),
    fields=(
        Field(
            id='turf_strips',
            type=FIELD_TOGGLE,
            label='Turf strips',
            prompt_true='strips of green turf set into the concrete',
            prompt_false='a continuous concrete surface with no turf strips',
        ),
        Field(
            id='layout',
            type=FIELD_RADIO,
            label='Layout',
            show_when=Condition('turf_strips', OP_EQ, True),
            options=(
                Option(
                    'parallel_up_down',
                    'Parallel strips up & down',
                    'parallel turf strips running up and down, away from the viewer',
                ),
                Option(
                    'parallel_side_to_side',
                    'Parallel strips side to side',
                    'parallel turf strips running side to side across the area',
                ),
                Option(
                    'standard_grid',
                    'Standard grid',
                    'turf strips forming a standard square grid',
                ),
                Option(
                    'diamond_grid',
                    'Diamond grid',
                    'turf strips forming a diagonal diamond grid',
                ),
            ),
        ),
    ),
)

# ---------------------------------------------------------------------------
# Concrete Overlay
# ---------------------------------------------------------------------------

#: Las formas en que viene cada piedra de la carta. Son las respuestas de
#: `shape` y, a la vez, las carpetas de las fotos de `stone_color`.
_OVERLAY_SHAPES = ('regular', 'irregular')


def _stone(value: str, label: str, prompt: str, **extra) -> Option:
    """Una piedra de la carta, con una foto por forma y ninguna que valga para las dos."""
    return Option(
        value,
        label,
        prompt,
        images=tuple(
            (shape, sample_image('concrete_overlay', 'stone_color', value, shape))
            for shape in _OVERLAY_SHAPES
        ),
        **extra,
    )


CONCRETE_OVERLAY = ServiceSchema(
    service=Service.CONCRETE_OVERLAY.value,
    label=Service.CONCRETE_OVERLAY.label,
    prompt='a decorative concrete overlay applied over the existing surface',
    show_when=Condition('area', OP_IN, PAVED_AREAS),
    fields=(
        # Primero la forma, después la piedra y al final la junta: es el orden
        # en que se decide el piso. La forma cambia la foto de cada piedra, y
        # la junta es el detalle que se elige con la piedra ya a la vista.
        Field(
            id='shape',
            type=FIELD_SEGMENTED,
            label='Shape',
            # Irregular porque es lo que el generador hizo hasta que existió la
            # pregunta: todas las fotos de piedra eran irregulares, y quien no
            # toca el control obtiene lo mismo que antes. Los pedidos viejos se
            # migraron a esta misma respuesta.
            default='irregular',
            options=(
                Option(
                    'regular',
                    'Regular',
                    'a regular stone cut pattern of square and rectangular stones '
                    'separated by straight grout lines',
                    description='Squares and rectangles.',
                    icon='regular_stone',
                ),
                Option(
                    'irregular',
                    'Irregular',
                    'an irregular natural stone cut pattern of flagstone-shaped stones '
                    'separated by winding grout lines',
                    description='Natural flagstone shapes.',
                    icon='irregular_stone',
                ),
            ),
        ),
        Field(
            id='stone_color',
            type=FIELD_RADIO_CARD,
            label='Stone color',
            # Son nombres de fantasía de la carta de colores, no colores. Por eso
            # van con foto y no como lista: «Terracotta Medley» no se elige
            # leyéndolo. Y por eso la foto viaja además como referencia al
            # modelo: describir con palabras un color de carta deja el resultado
            # a criterio de quien lo lea. La `description` es para quien elige y
            # el `prompt` para el modelo; la muestra es la misma para los dos.
            reference='the stone color and texture',
            # La misma piedra en corte regular y en irregular: la foto que se
            # ve, y la que viaja, es la de la forma elegida. Una muestra
            # irregular junto a «Regular» le daría al modelo dos formas.
            image_by='shape',
            options=(
                _stone(
                    'gray_escape',
                    'Gray Escape',
                    'a cool gray stone color with soft light and dark gray mottling',
                    description='Cool grays, soft mottling.',
                ),
                _stone(
                    'terracotta_medley',
                    'Terracotta Medley',
                    'a warm terracotta stone color blended with rust and clay tones',
                    description='Terracotta, rust and clay.',
                ),
                _stone(
                    'mocha_mist',
                    'Mocha Mist',
                    'a soft mocha brown stone color with light coffee and cream mottling',
                    description='Coffee brown with cream.',
                ),
                _stone(
                    'earth_stone',
                    'Earth Stone',
                    'a natural earth-toned stone color in warm tan and brown',
                    description='Natural tan and brown.',
                ),
                _stone(
                    'shadow_stone',
                    'Shadow Stone',
                    'a deep charcoal stone color with dark gray shadowing',
                    description='Deep charcoal, dark shadowing.',
                ),
                _stone(
                    'colorado_canyon',
                    'Colorado Canyon',
                    'a red-brown canyon stone color with rust and sand banding',
                    description='Canyon red-brown, sand banding.',
                ),
                _stone(
                    'sunbaked_sandstone',
                    'Sunbaked Sandstone',
                    'a pale sandstone color in warm sun-bleached beige',
                    description='Pale sandstone, warm beige.',
                ),
            ),
        ),
        Field(
            id='grout_color',
            type=FIELD_SELECT,
            label='Grout color',
            options=(
                Option('light_gray', 'Light Gray', 'light gray grout lines'),
                Option('beige', 'Beige', 'beige grout lines'),
                Option('sand', 'Sand', 'sand-colored grout lines'),
                Option('white', 'White', 'white grout lines'),
                Option('off_white', 'Off-White', 'off-white grout lines'),
            ),
        ),
    ),
)

# ---------------------------------------------------------------------------
# Pool Renovation / Resurfacing
# ---------------------------------------------------------------------------

POOL_RENOVATION = ServiceSchema(
    service=Service.POOL_RENOVATION.value,
    label=Service.POOL_RENOVATION.label,
    prompt=(
        'a full renovation and resurfacing of the existing pool, keeping its shape, '
        'size and position'
    ),
    # La pileta no se agrega a mano: el servicio exige que el análisis la haya
    # visto (R41), y es la única regla de servicio que mira algo más que el área.
    show_when=AllOf(
        (
            Condition('area', OP_IN, (Area.POOL.value,)),
            Condition('pool_present', OP_EQ, True),
        )
    ),
    fields=(
        Field(
            id='plaster_color',
            type=FIELD_RADIO_CARD,
            label='Plaster finish',
            # La carta del proveedor (Gem Finishes): la persona elige la
            # piedrita y el modelo recibe una pileta con el agua que da. Los
            # códigos de los ocho acabados que ya existían no cambian, aunque
            # `double_sky` se llame «Double Sky Blue» en la carta: son los que
            # están guardados en los pedidos.
            reference='the water color',
            options=(
                _plaster('aqua_clear', 'Aqua Clear', 'Pale milky aqua water'),
                _plaster('aqua_gem', 'Aqua Gem', 'Light aqua water'),
                _plaster('azure', 'Azure', 'Deep royal blue water'),
                _plaster('blue_gem', 'Blue Gem', 'Bright turquoise-blue water'),
                _plaster('bone', 'Bone', 'Soft light blue water'),
                _plaster('capri', 'Capri', 'Deep navy blue water'),
                _plaster('double_sky', 'Double Sky Blue', 'Bright light blue water'),
                _plaster('emerald_sea', 'Emerald Sea', 'Deep teal-green water'),
                _plaster('french_silver', 'French Silver', 'Blue-gray water'),
                _plaster('hawaiian', 'Hawaiian', 'Ocean blue water'),
                _plaster('lagoon', 'Lagoon', 'Dark, lagoon-like water'),
                _plaster('ocean_breeze', 'Ocean Breeze', 'Light ocean blue water'),
                _plaster('sky_blue', 'Sky Blue', 'Pale sky blue water'),
                _plaster('white', 'White', 'Bright pale aqua water'),
            ),
        ),
        Field(
            id='waterline_color',
            type=FIELD_SELECT,
            label='Waterline color',
            options=(
                Option('blues', 'Blues', 'waterline tile in shades of blue'),
                Option('greens', 'Greens', 'waterline tile in shades of green'),
                Option('grays', 'Grays', 'waterline tile in shades of gray'),
                Option('black', 'Black', 'black waterline tile'),
                Option('teal', 'Teal', 'teal waterline tile'),
            ),
        ),
        Field(
            id='waterline_size',
            type=FIELD_RADIO,
            label='Waterline size',
            options=(
                Option('6x6', '6" x 6"', 'large 6 by 6 inch waterline tiles'),
                Option('1x2', '1" x 2"', 'small 1 by 2 inch mosaic waterline tiles'),
                Option('1x1', '1" x 1"', 'small 1 by 1 inch mosaic waterline tiles'),
                Option('2x2', '2" x 2"', '2 by 2 inch waterline tiles'),
            ),
        ),
        Field(
            id='add_spa',
            type=FIELD_RADIO,
            label='Add a spa?',
            required=False,
            options=(
                Option(
                    '6x6_square',
                    "6' x 6' square",
                    'an attached 6 by 6 foot square spa next to the pool',
                ),
                Option(
                    '6x6_round',
                    "6' x 6' round",
                    'an attached 6 by 6 foot round spa next to the pool',
                ),
                Option('no', 'No', 'no spa'),
            ),
        ),
        # Dónde va el spa, una vez que hay spa. La condición nombra las dos
        # opciones que sí son un spa en vez de decir «distinto de No»: el motor
        # tiene `in` y no tiene negación, y así un `add_spa` sin contestar
        # tampoco abre la pregunta, que es lo que corresponde.
        Field(
            id='spa_location',
            type=FIELD_RADIO,
            label='Spa location',
            required=False,
            show_when=Condition('add_spa', OP_IN, ('6x6_square', '6x6_round')),
            options=(
                Option(
                    'inside_pool',
                    'Inside pool',
                    'the spa built inside the pool, sharing the same water surface',
                    description='Fully inside the pool',
                ),
                # El término de la industria para lo que el owner llamaba
                # «offset»: apoyado sobre el bond beam, mitad adentro y mitad
                # elevado, con el agua cayendo hacia la pileta.
                Option(
                    'perimeter_spillover',
                    'Perimeter / Spillover',
                    'the spa set on the pool bond beam, half inside the pool and half raised '
                    'above the waterline, spilling water over its edge into the pool',
                    description='On the bond beam, spilling into the pool',
                ),
                Option(
                    'outside_sheer_descent',
                    'Outside with sheer descent',
                    'the spa built outside the pool, raised, with a sheer descent pouring from '
                    'it into the pool',
                    description='Separate, with a sheer descent into the pool',
                ),
            ),
        ),
        Field(
            id='add_sun_shelf',
            type=FIELD_RADIO,
            label='Add a sun shelf?',
            required=False,
            options=(
                Option(
                    'standard_shelf',
                    'Yes — standard shelf',
                    'a shallow sun shelf (tanning ledge) inside the pool',
                ),
                # Los bubblers se dicen sólo con esta opción, nunca inferidos
                # de que haya sun shelf (owner, P15).
                Option(
                    'with_bubblers',
                    'Yes — with bubblers',
                    'a shallow sun shelf (tanning ledge) inside the pool',
                    prompt_tokens=('Bubblers: small bubbler water jets rising from the sun shelf',),
                ),
                Option('no', 'No', 'no sun shelf'),
            ),
        ),
        Field(
            id='water_feature',
            type=FIELD_RADIO,
            label='Add a water feature?',
            required=False,
            options=(
                Option(
                    'sheer_descent_blade_waterfall',
                    'Sheer Descent / Blade Waterfall',
                    'a sheer descent blade waterfall pouring into the pool',
                ),
                Option('deck_jets', 'Deck Jets', 'deck jets arcing streams of water into the pool'),
                Option(
                    'both',
                    'Both',
                    'a sheer descent blade waterfall and deck jets arcing water into the pool',
                ),
                Option('none', 'None', 'no water features'),
            ),
        ),
    ),
)

#: Los cinco esquemas, en el orden en que se ofrecen en un selector.
SCHEMAS: tuple[ServiceSchema, ...] = (
    PAVER,
    ARTIFICIAL_TURF,
    CONCRETE_POUR,
    CONCRETE_OVERLAY,
    POOL_RENOVATION,
)

_BY_SERVICE: dict[str, ServiceSchema] = {schema.service: schema for schema in SCHEMAS}


# ---------------------------------------------------------------------------
# Consultas
# ---------------------------------------------------------------------------


def schema_for(service: str) -> ServiceSchema:
    return _BY_SERVICE[service]


def services_for(area: str, pool_present: bool) -> list[str]:
    """
    Los servicios que un área ofrece en su selector, en el orden del catálogo.

    Es la tabla área → servicios, calculada: el `show_when` de cada servicio
    evaluado contra el área. Pool Renovation sale sólo con pileta detectada.
    """
    context = {'area': area, 'pool_present': pool_present}
    return [schema.service for schema in SCHEMAS if evaluate(schema.show_when, context)]


def request_scope(areas) -> dict:
    """
    El contexto que vale para todos los contenedores de un pedido.

    Se mezcla debajo de las respuestas del contenedor: un campo ve primero lo
    que se contestó ahí y, además, qué áreas trae el pedido. Va acá y no en cada
    llamador para que la clave se escriba una sola vez de este lado y una sola
    del otro.
    """
    return {AREA_SELECTED: list(areas)}


def is_shown(field: Field, context: dict) -> bool:
    """Si el campo corresponde con el contexto: lo contestado más el del pedido."""
    return evaluate(field.show_when, context)


def options_for(field: Field, context: dict) -> tuple[Option, ...]:
    """Las opciones vigentes del campo, dado el contexto."""
    return tuple(option for option in field.offered if evaluate(option.shown_if, context))


def requires_pool_present(area: str) -> bool:
    """
    Si el área sólo existe cuando el análisis vio una pileta.

    Es la excepción a «el usuario agrega cualquier área a mano»: la pileta no
    se inventa. Se deduce del catálogo —el área no tiene servicio sin
    `pool_present`— y no de una lista aparte.
    """
    return bool(services_for(area, True)) and not services_for(area, False)


def draws_control(field: Field) -> bool:
    """
    Si el campo se dibuja en pantalla.

    Un campo obligatorio con una sola opción y `default` se inicializa solo y
    no dibuja control (R40): la persona nunca lo ve, así que tampoco ve la foto
    de esa opción. Es la única forma en que un `radio-card` queda sin tarjeta.
    """
    return not (field.required and field.default is not None and len(field.offered) == 1)


def card_options() -> list[tuple[ServiceSchema, Field, Option]]:
    """
    Toda opción que se muestra como tarjeta con foto, en el orden del catálogo.

    Es la lista de fotos de muestra que el producto necesita: sale de acá y no
    de una lista escrita a mano, así agregar una opción con tarjeta agrega su
    foto a lo que el test exige en disco.
    """
    return [
        (schema, field, option)
        for schema in SCHEMAS
        for field in schema.fields
        if field.type == FIELD_RADIO_CARD and draws_control(field)
        for option in field.options
    ]


# ---------------------------------------------------------------------------
# Props
# ---------------------------------------------------------------------------


def _condition_props(condition: Condition | AllOf | None) -> dict | None:
    if condition is None:
        return None
    if isinstance(condition, AllOf):
        return {'all': [_condition_props(inner) for inner in condition.conditions]}
    value = condition.value
    if isinstance(value, tuple):
        value = list(value)
    return {'field': condition.field, 'operator': condition.operator, 'value': value}


def _option_props(option: Option, static_url: str) -> dict:
    return {
        'value': option.value,
        'prompt': option.prompt,
        'prompt_tokens': list(option.prompt_tokens),
        'image': f'{static_url}{option.image}' if option.image else None,
        'images': {answer: f'{static_url}{path}' for answer, path in option.images},
        'icon': option.icon,
        'shown_if': _condition_props(option.shown_if),
    }


def _field_props(field: Field, static_url: str) -> dict:
    return {
        'id': field.id,
        'type': field.type,
        'label': field.label,
        'required': field.required,
        'default': field.default,
        'show_when': _condition_props(field.show_when),
        'prompt_true': field.prompt_true,
        'prompt_false': field.prompt_false,
        'image_by': field.image_by or None,
        'options': [_option_props(option, static_url) for option in field.offered],
    }


def _schema_props(schema: ServiceSchema, static_url: str) -> dict:
    return {
        'service': schema.service,
        'label': schema.label,
        'prompt': schema.prompt,
        'image': f'{static_url}{service_image(schema.service)}',
        'show_when': _condition_props(schema.show_when),
        'fields': [_field_props(field, static_url) for field in schema.fields],
    }


def catalog_props(static_url: str) -> dict:
    """
    El catálogo entero tal como viaja a la pantalla, de una vez.

    Nunca texto para la persona: los rótulos de pantalla los pone el catálogo
    de traducción por clave, y por eso no viajan ni `description` ni el `label`
    de cada opción. Sí viajan los `label` de área, servicio y campo, y el
    `prompt` de cada opción: son el texto del prompt, en inglés y tal cual, que
    la pantalla muestra mientras se arma, no un texto para leer. Y viajan las
    condiciones, porque es la pantalla la que decide qué mostrar a medida que
    se contesta, sin volver al servidor.

    La tabla área → servicios va calculada por área, con la excepción de la
    pileta dicha como dato: `requires_pool_present` marca el área cuyos
    servicios sólo aparecen con pileta detectada.
    """
    return {
        # Las que la pantalla puede juntar en un solo contenedor. Viaja como
        # lista de códigos, igual que todo lo demás: cuál va con cuál es una
        # regla del negocio y se decide acá.
        'groupable_areas': list(GROUPABLE_AREAS),
        # Los guards viajan con sus dos redacciones porque la pantalla arma el
        # prompt a la vista y tiene que dar lo mismo que el servidor.
        'guards': [
            {
                'id': guard.id,
                'label': guard.label,
                'raised': guard.raised,
                'lowered': guard.lowered,
                'notes_raised': guard.notes_raised,
                'notes_lowered': guard.notes_lowered,
            }
            for guard in GUARDS
        ],
        'areas': [
            {
                'value': area.value,
                'label': area.label,
                'services': services_for(area.value, True),
                'requires_pool_present': requires_pool_present(area.value),
            }
            for area in Area
        ],
        'services': [_schema_props(schema, static_url) for schema in SCHEMAS],
    }


#: Lo que el catálogo de la pantalla trae y la landing no recibe: el texto que
#: se le dice al modelo. La landing no arma el prompt —lo arma Tools al
#: generar—, así que la receta no sale de acá (decisión de la owner,
#: 2026-10-08).
_PROMPT_KEYS = frozenset({'prompt', 'prompt_tokens', 'prompt_true', 'prompt_false'})


def public_catalog_props(static_url: str) -> dict:
    """
    El catálogo que recibe la landing: el de la pantalla, sin prompts y sin guards.

    Sale de `catalog_props` y no de un armado aparte para que lo que se
    pregunta sea lo mismo en los dos lados: una opción nueva aparece en la
    landing sin escribirla dos veces. Los guards no viajan porque la landing no
    los muestra; su prompt usa un guard propio que el visitante no controla.
    """
    full = catalog_props(static_url)
    return {
        'groupable_areas': full['groupable_areas'],
        'areas': full['areas'],
        'services': [_without_prompts(schema) for schema in full['services']],
    }


def _without_prompts(value):
    if isinstance(value, dict):
        return {
            key: _without_prompts(item) for key, item in value.items() if key not in _PROMPT_KEYS
        }
    if isinstance(value, list):
        return [_without_prompts(item) for item in value]
    return value
