Source code for cadrumo.application.wizard.descendant_group

"""The descendant repeating group for the setup flow's familia phase.

Descendant collection is a genuinely new wizard surface: the one-shot
:class:`~cadrumo.application.wizard.models.WizardFlow` catalogue carries no
repeating-group primitive, so the group is authored directly in the
substrate's :class:`~cadrumo.application.flows.definition.FlowRepeatingGroup`
vocabulary and spliced into the bridged
:class:`~cadrumo.application.flows.definition.FlowDefinition` by
:func:`attach_descendant_group` -- the same post-bridge decoration seam
as the format-hint and legal-validator attachers, so the substrate's
one-way projection stays domain-blind.

A count question (:data:`DESCENDANTS_COUNT_PAGE_ID`) gates the per-
descendant page family: the engine expands the group to that INTEGER
answer, so ``count = 0`` hides every instance page and a positive count
exposes one page set per descendant. Instance answers key the canonical
answer map as ``descendientes#<index>.<page-id>``; the commit path
projects them through
:func:`~cadrumo.application.wizard.persistence.descendant_facts_from_answers`
into the exact ``renta_family.descendiente.{n}.*`` fact shape the
``_minimo_descendientes_facts`` injector and the registry selectors
consume.

Every static ``wizard.setup.descendientes.*`` copy reference is declared as a
``_LOCALE_KEY`` module constant so the locale scaffold's static usage scanner
treats it as live. Relationship-choice labels are the one data-driven
exception: their locale key is projected from the registry-owned relationship
token, never maintained as a second Python catalogue.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import TYPE_CHECKING

from ...core.decimal.grammar import try_parse_canonical_decimal
from ...core.errors.hierarchy import ProfileAnswerTypeError
from ...core.flows import REPEATING_INSTANCE_SEPARATOR, FlowWidgetKind
from ...core.identity.documents import IdentityError
from ...core.parsing.dates import parse_iso8601_date
from ...core.text_bounds import CALENDAR_MONTH_MAX, CALENDAR_MONTH_MIN, is_calendar_month
from ...core.time.clock import today_madrid
from ...domain.calculations.registry.descendant_relacion_catalogue import (
    descendant_relacion_adoption_token,
    descendant_relacion_entitling_tokens,
    descendant_relacion_tokens,
)
from ...domain.calculations.registry.entity_type import entity_type_natural_person_token
from ...domain.calculations.registry.tax_id_runtime import validate_runtime_identity
from ..flows.definition import (
    FlowChoice,
    FlowCondition,
    FlowDefinition,
    FlowPage,
    FlowRepeatingGroup,
    FlowSection,
    FlowVisibility,
)
from ..flows.definition import locale_copy_ref as _locale_ref
from ..flows.validators import ValidationVerdict, register_answer_validator, register_cross_field_validator
from ._format_hints import (
    FORMAT_AMOUNT_LOCALE_KEY,
    FORMAT_DATE_LOCALE_KEY,
    FORMAT_TAX_ID_LOCALE_KEY,
    FORMAT_UNITS_LOCALE_KEY,
    NIF_INVALID_LOCALE_KEY,
)
from .catalogue import FAMILIA_SECTION_ID as _FAMILIA_SECTION_ID

if TYPE_CHECKING:
    from datetime import date

    from ...domain.calculations.registry.authority import PinnedAuthorityOperation


#: Repeating-group id; instance answers key as ``descendientes#<index>.<page>``.
DESCENDANTS_GROUP_ID = "descendientes"

#: Count-source page id; its INTEGER answer drives the instance count.
DESCENDANTS_COUNT_PAGE_ID = "descendientes-count"

# Per-descendant page ids, in walk order. These bare tokens are the
# ``<page-id>`` half of every ``descendientes#<index>.<page-id>`` answer key.
_BIRTH_DATE_PAGE_ID = "birth-date"
_RELACION_PAGE_ID = "relacion"
_INSCRIPCION_PAGE_ID = "inscripcion-registro-civil"
_ACOGIMIENTO_PAGE_ID = "acogimiento-resolucion"
_FALLECIMIENTO_PAGE_ID = "fallecimiento"
_DISCAPACIDAD_PAGE_ID = "discapacidad"
_CONVIVENCIA_PAGE_ID = "convivencia"
_DEPENDENCIA_PAGE_ID = "dependencia-economica"
_CUSTODIA_COMPARTIDA_PAGE_ID = "custodia-compartida"
_RENTAS_ANUALES_PAGE_ID = "rentas-anuales"
_DECLARACION_PROPIA_PAGE_ID = "declaracion-propia"
_PRORRATA_MINIMO_PAGE_ID = "prorrata-minimo"
_MESES_MADRE_TRABAJO_PAGE_ID = "meses-madre-trabajo"
_ALTA_POSTERIOR_NACIMIENTO_MES_PAGE_ID = "alta-posterior-nacimiento-mes"
_GASTOS_GUARDERIA_PAGE_ID = "gastos-guarderia"
_GASTOS_GUARDERIA_MENSUALES_PAGE_ID = "gastos-guarderia-mensuales"
_NIF_PAGE_ID = "nif"

#: Per-descendant page ids in walk order (consumed by the fact projection).
DESCENDANT_PAGE_IDS: tuple[str, ...] = (
    _BIRTH_DATE_PAGE_ID,
    _RELACION_PAGE_ID,
    _INSCRIPCION_PAGE_ID,
    _ACOGIMIENTO_PAGE_ID,
    _FALLECIMIENTO_PAGE_ID,
    _DISCAPACIDAD_PAGE_ID,
    _CONVIVENCIA_PAGE_ID,
    _DEPENDENCIA_PAGE_ID,
    _CUSTODIA_COMPARTIDA_PAGE_ID,
    _RENTAS_ANUALES_PAGE_ID,
    _DECLARACION_PROPIA_PAGE_ID,
    _PRORRATA_MINIMO_PAGE_ID,
    _MESES_MADRE_TRABAJO_PAGE_ID,
    _ALTA_POSTERIOR_NACIMIENTO_MES_PAGE_ID,
    _GASTOS_GUARDERIA_PAGE_ID,
    _GASTOS_GUARDERIA_MENSUALES_PAGE_ID,
    _NIF_PAGE_ID,
)

#: Registered id of the per-answer descendant-NIF validator.
DESCENDANT_NIF_VALIDATOR_ID = "descendant-nif"

#: Registered id of the per-answer descendant meses-madre-trabajo range validator.
DESCENDANT_MESES_VALIDATOR_ID = "descendant-meses-range"

#: Registered id of the post-birth Social Security completion-month validator.
DESCENDANT_ALTA_POSTERIOR_VALIDATOR_ID = "descendant-alta-posterior-month"

#: Registered id of the per-answer descendant gastos-guardería sign validator.
DESCENDANT_GASTOS_VALIDATOR_ID = "descendant-gastos-nonneg"

#: Registered id of the per-answer monthly-guardería grammar validator.
#:
#: The map is ONE flattened page carrying a ``MM:AMOUNT`` grammar rather than a
#: per-month sub-question, because a per-month group inside the per-descendant
#: group is a NESTED repetition and this substrate has no primitive for it. The
#: flattening is therefore a measured constraint, not a preference, and the
#: grammar validator is what carries the structure the substrate cannot.
DESCENDANT_GASTOS_MENSUALES_VALIDATOR_ID = "descendant-gastos-mensuales-grammar"

#: Registered id of the flow-scope one-spend-authority-per-child guard.
#:
#: Flow-scope rather than per-answer because the rule spans two pages of one
#: instance: an annual total is only wrong in the presence of a monthly map on a
#: sibling page, which a per-answer validator cannot see. It mirrors the
#: canonical record's own refusal so a scripted or resumed answer map cannot
#: reach the persist path carrying both.
DESCENDANT_GUARDERIA_SPEND_VALIDATOR_ID = "descendant-guarderia-spend"

#: Registered id of the per-answer descendant rentas-anuales sign validator.
DESCENDANT_RENTAS_VALIDATOR_ID = "descendant-rentas-nonneg"

#: Registered id of the flow-scope descendant entry-event cross-field validator.
#:
#: Covers both Art. 58.2 anchors (the Registro Civil inscription and the
#: acogimiento resolución) and their coherence against the declared relación.
#: One validator rather than two because the rules are not independent: which
#: date a record may carry is decided by the relación, so splitting them would
#: leave each half checking a condition the other half owns.
DESCENDANT_ENTRY_EVENT_VALIDATOR_ID = "descendant-entry-event-dates"

#: Bounds of a calendar MONTH, quoted back in the refusal so the operator is
#: told the range that actually binds. One, not zero: the answer names which
#: months the mother qualified, and there is no month zero. Zero was the count
#: era's floor, when "she qualified in no month" was itself a value; that state
#: is now the empty answer, which the validator passes as optional before it
#: reaches any range check.
_MESES_MIN = 1
_MESES_MAX = 12

# --- copy references (new wizard.setup.descendientes.* locale keys) ---------

_GROUP_TITLE_LOCALE_KEY = "wizard.setup.descendientes.title"
_COUNT_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.count.prompt"
_COUNT_HELP_LOCALE_KEY = "wizard.setup.descendientes.count.help"
_BIRTH_DATE_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.birth-date.prompt"
_RELACION_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.relacion.prompt"
_RELACION_HELP_LOCALE_KEY = "wizard.setup.descendientes.relacion.help"
_INSCRIPCION_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.inscripcion-registro-civil.prompt"
_INSCRIPCION_HELP_LOCALE_KEY = "wizard.setup.descendientes.inscripcion-registro-civil.help"
_ACOGIMIENTO_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.acogimiento-resolucion.prompt"
_ACOGIMIENTO_HELP_LOCALE_KEY = "wizard.setup.descendientes.acogimiento-resolucion.help"
_DISCAPACIDAD_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.discapacidad.prompt"
_DISCAPACIDAD_CHOICE_0_LOCALE_KEY = "wizard.setup.descendientes.discapacidad.choices.0.label"
_DISCAPACIDAD_CHOICE_33_LOCALE_KEY = "wizard.setup.descendientes.discapacidad.choices.33.label"
_DISCAPACIDAD_CHOICE_65_LOCALE_KEY = "wizard.setup.descendientes.discapacidad.choices.65.label"
_CONVIVENCIA_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.convivencia.prompt"
_DEPENDENCIA_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.dependencia-economica.prompt"
_DEPENDENCIA_HELP_LOCALE_KEY = "wizard.setup.descendientes.dependencia-economica.help"
_DEPENDENCIA_CHOICE_TRUE_LOCALE_KEY = "wizard.setup.descendientes.dependencia-economica.choices.true.label"
_DEPENDENCIA_CHOICE_FALSE_LOCALE_KEY = "wizard.setup.descendientes.dependencia-economica.choices.false.label"
_CUSTODIA_COMPARTIDA_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.custodia-compartida.prompt"
_RENTAS_ANUALES_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.rentas-anuales.prompt"
_RENTAS_ANUALES_HELP_LOCALE_KEY = "wizard.setup.descendientes.rentas-anuales.help"
_DECLARACION_PROPIA_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.declaracion-propia.prompt"
_DECLARACION_PROPIA_HELP_LOCALE_KEY = "wizard.setup.descendientes.declaracion-propia.help"
_PRORRATA_MINIMO_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.prorrata-minimo.prompt"
_PRORRATA_MINIMO_HELP_LOCALE_KEY = "wizard.setup.descendientes.prorrata-minimo.help"
_PRORRATA_MINIMO_CHOICE_TRUE_LOCALE_KEY = "wizard.setup.descendientes.prorrata-minimo.choices.true.label"
_PRORRATA_MINIMO_CHOICE_FALSE_LOCALE_KEY = "wizard.setup.descendientes.prorrata-minimo.choices.false.label"
_MESES_MADRE_TRABAJO_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.meses-madre-trabajo.prompt"
_MESES_MADRE_TRABAJO_HELP_LOCALE_KEY = "wizard.setup.descendientes.meses-madre-trabajo.help"
_ALTA_POSTERIOR_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.alta-posterior-nacimiento-mes.prompt"
_ALTA_POSTERIOR_HELP_LOCALE_KEY = "wizard.setup.descendientes.alta-posterior-nacimiento-mes.help"
_FALLECIMIENTO_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.fallecimiento.prompt"
_FALLECIMIENTO_HELP_LOCALE_KEY = "wizard.setup.descendientes.fallecimiento.help"
_GASTOS_GUARDERIA_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.gastos-guarderia.prompt"
_GASTOS_GUARDERIA_HELP_LOCALE_KEY = "wizard.setup.descendientes.gastos-guarderia.help"
_GASTOS_MENSUALES_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.gastos-guarderia-mensuales.prompt"
_GASTOS_MENSUALES_HELP_LOCALE_KEY = "wizard.setup.descendientes.gastos-guarderia-mensuales.help"
_NIF_PROMPT_LOCALE_KEY = "wizard.setup.descendientes.nif.prompt"

# Verdict message keys minted for the constraint guards below (the range,
# sign, and entry-event refusals). Reused generic keys were checked
# first: the closest existing leaves (cli.config.descendiente.*) carry
# CLI-flag-specific prose, so wizard-namespace keys are minted here.
_MESES_INVALID_RANGE_LOCALE_KEY = "wizard.setup.descendientes.meses-madre-trabajo.invalid_range"
_ALTA_POSTERIOR_INVALID_RANGE_LOCALE_KEY = "wizard.setup.descendientes.alta-posterior-nacimiento-mes.invalid_range"
_RENTAS_INVALID_NEGATIVE_LOCALE_KEY = "wizard.setup.descendientes.rentas-anuales.invalid_negative"
_GASTOS_INVALID_NEGATIVE_LOCALE_KEY = "wizard.setup.descendientes.gastos-guarderia.invalid_negative"
_GASTOS_MENSUALES_INVALID_LOCALE_KEY = "wizard.setup.descendientes.gastos-guarderia-mensuales.invalid_grammar"
_GASTOS_BOTH_DECLARED_LOCALE_KEY = "wizard.setup.descendientes.gastos-guarderia.both_declared"
_ENTRY_BEFORE_BIRTH_LOCALE_KEY = "wizard.setup.descendientes.entry-event.before_birth"
_ENTRY_IN_FUTURE_LOCALE_KEY = "wizard.setup.descendientes.entry-event.in_future"
_ENTRY_RELACION_MISMATCH_LOCALE_KEY = "wizard.setup.descendientes.entry-event.relacion_mismatch"

# Format hints reuse the shared wizard.setup.format.* keys already shipped
# by the format-hint decorator; the NIF failure verdict reuses the existing
# tax-id error key, so neither mints a new locale key.

# The rentas grammar refusal reuses the exact key the persistence-projection
# path (`_persistence._descendant_from_row`) already raises for the same
# malformed-amount case on the same field, rather than minting a second one:
# the CLI `--descendiente RENTAS=` flag path carries it too
# (`domain.contribuyente._descendant_facts`). Two keys for one refusal would
# drift, and the operator would meet different words depending on which door
# they came through.
_RENTAS_NOT_A_VALID_AMOUNT_LOCALE_KEY = "application.wizard.errors.descendant_rentas_not_a_valid_amount"


def _validate_descendant_nif(page: FlowPage, canonical: str) -> ValidationVerdict:
    """Validate a descendant NIF through the canonical identity authority.

    A descendant may legitimately lack a NIF (a minor without one), so a
    blank canonical passes; a non-blank value must satisfy the Spanish
    NIF / NIE / CIF checksum in
    :func:`cadrumo.domain.calculations.registry.tax_id_runtime.validate_runtime_identity`
    -- the same authority the identity pages bind. A malformed value
    returns the ``wizard.errors.invalid_tax_id`` verdict carrying only the
    page id; the raw answer never enters the diagnostic.
    """
    if not canonical:
        return ValidationVerdict.passed()
    try:
        validate_runtime_identity(canonical)
    except IdentityError:
        return ValidationVerdict.failed(NIF_INVALID_LOCALE_KEY, page_id=page.id)
    return ValidationVerdict.passed()


register_answer_validator(DESCENDANT_NIF_VALIDATOR_ID, _validate_descendant_nif)


def _validate_meses_range(page: FlowPage, canonical: str) -> ValidationVerdict:
    """Refuse a months-worked answer that is not a well-formed month set.

    The answer names WHICH months the mother met the Art. 81.1 requirements,
    because Art. 81.2 prorates by an intersection with the declared nursery
    months and a count cannot express one. Blank (optional) passes.

    Delegates to the one shared grammar rather than restating it, so this door
    accepts exactly what the ``--descendiente`` flag and the fact index do.
    Surfacing the refusal as a verdict keeps a malformed value out of the answer
    map entirely, so it never reaches the persistence-boundary construction of
    :class:`~cadrumo.domain.contribuyente.descendant.DescendantInfo` (whose own month
    validator stays as defence-in-depth).
    """
    if not canonical:
        return ValidationVerdict.passed()
    from ...domain.contribuyente.meses_trabajo import parse_meses_trabajo

    try:
        parse_meses_trabajo(canonical, field=page.id)
    except ProfileAnswerTypeError:
        return ValidationVerdict.failed(
            _MESES_INVALID_RANGE_LOCALE_KEY,
            page_id=page.id,
            minimum=_MESES_MIN,
            maximum=_MESES_MAX,
        )
    return ValidationVerdict.passed()


def _validate_alta_posterior_month(page: FlowPage, canonical: str) -> ValidationVerdict:
    """Keep the optional completion month inside the calendar range."""
    if not canonical:
        return ValidationVerdict.passed()
    if is_calendar_month(int(canonical)):
        return ValidationVerdict.passed()
    return ValidationVerdict.failed(
        _ALTA_POSTERIOR_INVALID_RANGE_LOCALE_KEY,
        page_id=page.id,
        minimum=CALENDAR_MONTH_MIN,
        maximum=CALENDAR_MONTH_MAX,
    )


def _validate_gastos_nonneg(page: FlowPage, canonical: str) -> ValidationVerdict:
    """Refuse a negative guardería expense (Art. 81.2 LIRPF; euros >= 0).

    Blank (optional) passes; a parsed negative amount refuses as a verdict
    so it never reaches the ``ge=0`` model constraint at persist.
    """
    if not canonical:
        return ValidationVerdict.passed()
    if int(canonical) < 0:
        return ValidationVerdict.failed(_GASTOS_INVALID_NEGATIVE_LOCALE_KEY, page_id=page.id)
    return ValidationVerdict.passed()


def _validate_rentas_nonneg(page: FlowPage, canonical: str) -> ValidationVerdict:
    """Refuse a malformed or negative rentas figure (Art. 58.1 LIRPF; euros >= 0).

    Blank (optional) passes and leaves the figure UNDECLARED, which the
    eligibility predicate reads as non-excluding. Unlike ``gastos_guarderia``,
    this field is genuinely ``Decimal`` on the domain model
    (:class:`~cadrumo.domain.contribuyente.descendant.DescendantInfo`), and the Art. 58.1
    ceiling comparison is strict (``>``), so a cents figure genuinely at the
    boundary (e.g. ``8000.01``) is legally significant, not cosmetic
    precision. The DECIMAL widget's own shape validation already confirmed
    the canonical grammar uncapped -- it is a generic numeric channel also
    serving rates and percentages -- so this applies the money-specific
    ambiguity cap here: at most two fraction digits, since a Spanish
    thousands grouping is always exactly three and a longer fraction is
    genuinely undecidable between one euro and one thousand. A malformed
    figure refuses rather than resolving to zero or ``None``: ``None`` means
    UNDECLARED and is the CLAIMING direction (Art. 58.1 reads an absent
    figure as non-excluding), so silently mapping a typo onto it would assert
    a mínimo the taxpayer never established -- the same over-claim this cap
    guards against in the persistence-projection and CLI flag paths that
    already enforce it on the same field.
    """
    if not canonical:
        return ValidationVerdict.passed()
    value = try_parse_canonical_decimal(canonical, max_fraction_digits=2)
    if value is None:
        return ValidationVerdict.failed(_RENTAS_NOT_A_VALID_AMOUNT_LOCALE_KEY, page_id=page.id)
    if value < 0:
        return ValidationVerdict.failed(_RENTAS_INVALID_NEGATIVE_LOCALE_KEY, page_id=page.id)
    return ValidationVerdict.passed()


def _validate_gastos_mensuales_grammar(page: FlowPage, canonical: str) -> ValidationVerdict:
    """Refuse a monthly-spend map the canonical grammar cannot read.

    Blank (optional) passes and leaves the map UNDECLARED. Anything else is
    parsed by the one shared grammar
    (:func:`~domain.contribuyente.guarderia_mensual.parse_guarderia_mensual`) rather than a
    wizard-local reader, so this page, the ``--descendiente`` flag and the fact
    index accept and refuse exactly the same shapes. A page-local parser is how
    a surface starts accepting a value another door rejects.

    Refusing as a verdict keeps a bad map out of the answer map entirely, so it
    never reaches the persistence-boundary construction of
    :class:`~cadrumo.domain.contribuyente.descendant.DescendantInfo` (whose own duplicate-month
    refusal stays as defence-in-depth). The raw answer never enters the
    diagnostic; the operator is pointed at the accepted form instead.
    """
    if not canonical.strip():
        return ValidationVerdict.passed()
    from ...core.errors.hierarchy import ProfileAnswerTypeError
    from ...domain.contribuyente.guarderia_mensual import parse_guarderia_mensual

    try:
        parse_guarderia_mensual(canonical, field=page.id)
    except ProfileAnswerTypeError:
        return ValidationVerdict.failed(_GASTOS_MENSUALES_INVALID_LOCALE_KEY, page_id=page.id)
    return ValidationVerdict.passed()


register_answer_validator(DESCENDANT_MESES_VALIDATOR_ID, _validate_meses_range)
register_answer_validator(DESCENDANT_ALTA_POSTERIOR_VALIDATOR_ID, _validate_alta_posterior_month)
register_answer_validator(DESCENDANT_GASTOS_VALIDATOR_ID, _validate_gastos_nonneg)
register_answer_validator(DESCENDANT_RENTAS_VALIDATOR_ID, _validate_rentas_nonneg)
register_answer_validator(DESCENDANT_GASTOS_MENSUALES_VALIDATOR_ID, _validate_gastos_mensuales_grammar)


def _validate_descendant_guarderia_spend(answers: Mapping[str, str]) -> tuple[ValidationVerdict, ...]:
    """Flow-scope guard: one guardería spend authority per descendant.

    Art. 81.2 spend may be stated as an annual total or as a month map, never
    both for one child. Two figures would have to be reconciled, and whichever
    was chosen the other would sit in the record contradicting it — a filer
    reading their own profile could not tell which one reached the filing.

    Reported against the ANNUAL page rather than the monthly one because the
    monthly map is the authority where it exists: it is the only shape that can
    express the period the child turns three, so the annual figure is the one to
    drop. Pointing at the field the operator should clear is the difference
    between a verdict they can act on and one that only says something is wrong.
    """
    prefix_root = f"{DESCENDANTS_GROUP_ID}{REPEATING_INSTANCE_SEPARATOR}"
    verdicts = [
        ValidationVerdict.failed(
            _GASTOS_BOTH_DECLARED_LOCALE_KEY,
            instance=instance,
            page=_GASTOS_GUARDERIA_PAGE_ID,
        )
        for instance in sorted(_descendant_instances(answers, prefix_root=prefix_root))
        if _declares_both_guarderia_shapes(answers, prefix=f"{prefix_root}{instance}")
    ]
    return tuple(verdicts) if verdicts else (ValidationVerdict.passed(),)


def _declares_both_guarderia_shapes(answers: Mapping[str, str], *, prefix: str) -> bool:
    """Whether one instance carries an annual total AND a monthly map.

    A zero annual answer is not a second declaration: it states no spend, so it
    contradicts nothing. Treating it as one would refuse an operator who typed
    ``0`` on the annual page before reaching the monthly one.
    """
    annual = (answers.get(f"{prefix}.{_GASTOS_GUARDERIA_PAGE_ID}") or "").strip()
    mensuales = (answers.get(f"{prefix}.{_GASTOS_GUARDERIA_MENSUALES_PAGE_ID}") or "").strip()
    if not mensuales or not annual:
        return False
    return annual.isdigit() and int(annual) > 0


register_cross_field_validator(DESCENDANT_GUARDERIA_SPEND_VALIDATOR_ID, _validate_descendant_guarderia_spend)


def _validate_descendant_entry_event_dates(answers: Mapping[str, str]) -> tuple[ValidationVerdict, ...]:
    """Flow-scope guard for the per-instance Art. 58.2 entry-event invariants.

    Both rules span pages inside one instance, so neither can be a per-answer
    validator: an entry date is judged against the BIRTH date on a sibling page,
    and against the RELACIÓN on another. This scans every descendant instance
    and refuses an entry event earlier than the birth or in the future, and an
    entry date the declared relación cannot carry.

    The relación gate is enforced here as well as by the page-visibility
    conditions, and that is not redundancy. Visibility stops the operator
    REACHING an incompatible page in an interactive walk, but a scripted or
    resumed answer map can carry an answer whose gate later stopped holding —
    the operator changes the relación from adoptado to tutela and the
    inscription answer is left behind. Enforcing it as a verdict is what turns
    that into a blocked submit rather than a refusal raised from deep inside the
    persist path, where it would read as a crash.

    A page id and instance index are the only context; the raw date never enters
    a diagnostic.
    """
    today = today_madrid()
    prefix_root = f"{DESCENDANTS_GROUP_ID}{REPEATING_INSTANCE_SEPARATOR}"
    verdicts = [
        verdict
        for instance in sorted(_descendant_instances(answers, prefix_root=prefix_root))
        for verdict in _entry_event_verdicts(
            answers,
            prefix=f"{prefix_root}{instance}",
            instance=instance,
            today=today,
        )
    ]
    return tuple(verdicts) if verdicts else (ValidationVerdict.passed(),)


def _descendant_instances(answers: Mapping[str, str], *, prefix_root: str) -> set[str]:
    """The instance indices present in the answer map, as bare tokens."""
    return {key[len(prefix_root) :].split(".", 1)[0] for key in answers if key.startswith(prefix_root) and "." in key}


def _entry_event_page_verdicts(
    raw: str,
    *,
    page_id: str,
    permitted: frozenset[str] | None,
    birth: date | None,
    relacion: str,
    instance: str,
    today: date,
) -> list[ValidationVerdict]:
    """Judge one populated entry-event page against date and relación facts."""
    if not raw:
        return []
    entry = parse_iso8601_date(raw)
    if entry is None:
        return []
    verdicts: list[ValidationVerdict] = []
    # A future date is reported on its own; only a past one is then compared
    # against the birth, so the operator is told the one thing wrong with the
    # value rather than two overlapping refusals.
    if entry > today:
        verdicts.append(ValidationVerdict.failed(_ENTRY_IN_FUTURE_LOCALE_KEY, instance=instance, page=page_id))
    elif birth is not None and entry < birth:
        verdicts.append(ValidationVerdict.failed(_ENTRY_BEFORE_BIRTH_LOCALE_KEY, instance=instance, page=page_id))
    # An UNSTATED relación is not judged: an inscription date alone reads as
    # an adoption (the canonical record infers it), so refusing here would
    # block the shape the model accepts.
    if permitted is not None and relacion and relacion not in permitted:
        verdicts.append(
            ValidationVerdict.failed(
                _ENTRY_RELACION_MISMATCH_LOCALE_KEY,
                instance=instance,
                page=page_id,
                accepted=", ".join(sorted(permitted)),
            ),
        )
    return verdicts


def _entry_event_verdicts(
    answers: Mapping[str, str],
    *,
    prefix: str,
    instance: str,
    today: date,
) -> list[ValidationVerdict]:
    """Judge one instance's two entry-event dates against birth, today and relación."""
    birth = parse_iso8601_date(answers.get(f"{prefix}.{_BIRTH_DATE_PAGE_ID}") or "") or None
    relacion = (answers.get(f"{prefix}.{_RELACION_PAGE_ID}") or "").strip()
    verdicts: list[ValidationVerdict] = []
    for page_id in (_INSCRIPCION_PAGE_ID, _ACOGIMIENTO_PAGE_ID):
        raw = answers.get(f"{prefix}.{page_id}") or ""
        page_verdicts = _entry_event_page_verdicts(
            raw,
            page_id=page_id,
            # The relationship catalogue is generation-bound and is therefore
            # materialized on the pages by ``build_descendant_group``. The
            # persistence boundary re-checks the same relationship/date
            # coherence for scripted or resumed maps; this substrate validator
            # retains the date checks without opening a second operation.
            permitted=None,
            birth=birth,
            relacion=relacion,
            instance=instance,
            today=today,
        )
        verdicts.extend(page_verdicts)
    return verdicts


register_cross_field_validator(DESCENDANT_ENTRY_EVENT_VALIDATOR_ID, _validate_descendant_entry_event_dates)


#: Art. 58 dependency answers. Tri-state by construction: leaving the page
#: unanswered is NOT a "no", it is an unanswered question, and only an explicit
#: yes can assimilate. An explicit no is offered so the operator can record that
#: the question was put and declined.
_DEPENDENCIA_CHOICES: tuple[FlowChoice, ...] = (
    FlowChoice(value="true", label=_locale_ref(_DEPENDENCIA_CHOICE_TRUE_LOCALE_KEY)),
    FlowChoice(value="false", label=_locale_ref(_DEPENDENCIA_CHOICE_FALSE_LOCALE_KEY)),
)


_DISCAPACIDAD_CHOICES: tuple[FlowChoice, ...] = (
    FlowChoice(value="0", label=_locale_ref(_DISCAPACIDAD_CHOICE_0_LOCALE_KEY)),
    FlowChoice(value="33", label=_locale_ref(_DISCAPACIDAD_CHOICE_33_LOCALE_KEY)),
    FlowChoice(value="65", label=_locale_ref(_DISCAPACIDAD_CHOICE_65_LOCALE_KEY)),
)


#: Art. 61 norma 1a answers. Left unanswered the engine derives the answer
#: from marital status, spouse record and declaration type, and raises a
#: visible advisory saying so; an explicit answer always wins over that.
_PRORRATA_MINIMO_CHOICES: tuple[FlowChoice, ...] = (
    FlowChoice(value="true", label=_locale_ref(_PRORRATA_MINIMO_CHOICE_TRUE_LOCALE_KEY)),
    FlowChoice(value="false", label=_locale_ref(_PRORRATA_MINIMO_CHOICE_FALSE_LOCALE_KEY)),
)


def _build_descendant_pages(operation: PinnedAuthorityOperation) -> tuple[FlowPage, ...]:
    """Build descendant pages from the relationship and entity-type authority."""
    relation_tokens = descendant_relacion_tokens(authority=operation)
    adoption_token = descendant_relacion_adoption_token(authority=operation)
    entitling_relations = descendant_relacion_entitling_tokens(authority=operation)
    relation_choices = tuple(
        FlowChoice(
            value=token.value,
            label=_locale_ref(f"wizard.setup.descendientes.relacion.choices.{token.value}.label"),
        )
        for token in relation_tokens
    )
    acogimiento_visibility = FlowVisibility(
        any_of=tuple(
            FlowCondition(page_id=_RELACION_PAGE_ID, equals=token.value)
            for token in sorted(entitling_relations, key=lambda item: item.value)
        ),
    )
    return (
        FlowPage(
            id=_BIRTH_DATE_PAGE_ID,
            widget=FlowWidgetKind.DATE,
            prompt=_locale_ref(_BIRTH_DATE_PROMPT_LOCALE_KEY),
            format_hint=_locale_ref(FORMAT_DATE_LOCALE_KEY),
            required=True,
            answer_type=str,
        ),
        FlowPage(
            id=_RELACION_PAGE_ID,
            widget=FlowWidgetKind.SELECT,
            prompt=_locale_ref(_RELACION_PROMPT_LOCALE_KEY),
            help=_locale_ref(_RELACION_HELP_LOCALE_KEY),
            choices=relation_choices,
            # Optional with no default: leaving it unanswered means an ordinary
            # descendant, which is both the record's own default and the
            # overwhelming case, so the majority of walks answer nothing here.
            required=False,
            answer_type=str,
        ),
        # Both entry-date pages are GATED on the relación answer rather than shown
        # unconditionally. The gate is per-instance -- the engine resolves a clause
        # against `<group>#<index>.<page-id>` before the bare id -- so each
        # descendant's dates follow that descendant's own relación. Gating is what
        # keeps a tutela guardian or a temporal acogimiento carer from ever seeing a
        # field the statute gives them no entitling value for; the cross-field
        # verdict and the model validator then hold the same line for a scripted or
        # resumed answer map, where visibility alone proves nothing.
        FlowPage(
            id=_INSCRIPCION_PAGE_ID,
            widget=FlowWidgetKind.DATE,
            prompt=_locale_ref(_INSCRIPCION_PROMPT_LOCALE_KEY),
            help=_locale_ref(_INSCRIPCION_HELP_LOCALE_KEY),
            format_hint=_locale_ref(FORMAT_DATE_LOCALE_KEY),
            required=False,
            answer_type=str,
            visible_when=FlowCondition(
                page_id=_RELACION_PAGE_ID,
                equals=adoption_token.value,
            ),
        ),
        FlowPage(
            id=_ACOGIMIENTO_PAGE_ID,
            widget=FlowWidgetKind.DATE,
            prompt=_locale_ref(_ACOGIMIENTO_PROMPT_LOCALE_KEY),
            help=_locale_ref(_ACOGIMIENTO_HELP_LOCALE_KEY),
            format_hint=_locale_ref(FORMAT_DATE_LOCALE_KEY),
            required=False,
            answer_type=str,
            # Shown for an adoptado record too, and that is the cap-not-restart rule
            # made reachable: a fostered-then-adopted child's window is measured
            # from the earlier placement, so without this page the operator could
            # only record the later event and the engine would grant three fresh
            # periods on top of the ones already run.
            visible_when=acogimiento_visibility,
        ),
        FlowPage(
            id=_DISCAPACIDAD_PAGE_ID,
            widget=FlowWidgetKind.SELECT,
            prompt=_locale_ref(_DISCAPACIDAD_PROMPT_LOCALE_KEY),
            choices=_DISCAPACIDAD_CHOICES,
            required=False,
            answer_type=str,
        ),
        FlowPage(
            id=_FALLECIMIENTO_PAGE_ID,
            widget=FlowWidgetKind.DATE,
            prompt=_locale_ref(_FALLECIMIENTO_PROMPT_LOCALE_KEY),
            help=_locale_ref(_FALLECIMIENTO_HELP_LOCALE_KEY),
            format_hint=_locale_ref(FORMAT_DATE_LOCALE_KEY),
            required=False,
            answer_type=str,
        ),
        FlowPage(
            id=_CONVIVENCIA_PAGE_ID,
            widget=FlowWidgetKind.CONFIRM,
            prompt=_locale_ref(_CONVIVENCIA_PROMPT_LOCALE_KEY),
            default="true",
            required=False,
            answer_type=bool,
        ),
        FlowPage(
            # SELECT with an explicit no rather than CONFIRM, because this axis is
            # genuinely tri-state and a CONFIRM cannot express "unanswered". Unset
            # never assimilates; only an explicit yes does. Gated on a NON-cohabiting
            # answer, since the question only arises when cohabitation fails - a
            # cohabiting descendant already qualifies and asking would invite an
            # answer that changes nothing.
            id=_DEPENDENCIA_PAGE_ID,
            widget=FlowWidgetKind.SELECT,
            prompt=_locale_ref(_DEPENDENCIA_PROMPT_LOCALE_KEY),
            help=_locale_ref(_DEPENDENCIA_HELP_LOCALE_KEY),
            choices=_DEPENDENCIA_CHOICES,
            required=False,
            answer_type=str,
            visible_when=FlowCondition(page_id=_CONVIVENCIA_PAGE_ID, equals="false"),
        ),
        FlowPage(
            id=_CUSTODIA_COMPARTIDA_PAGE_ID,
            widget=FlowWidgetKind.CONFIRM,
            prompt=_locale_ref(_CUSTODIA_COMPARTIDA_PROMPT_LOCALE_KEY),
            default="false",
            required=False,
            answer_type=bool,
        ),
        FlowPage(
            # DECIMAL, not INTEGER: the domain field is genuinely Decimal (Art.
            # 58.1's strict-`>` ceiling comparison makes cents legally
            # significant), and the CLI `--descendiente RENTAS=` flag and the
            # checkpoint-persistence re-projection already carry two-decimal
            # precision for this exact field. DECIMAL-widget pages keep
            # `answer_type=str`, per the widget's own canonicalisation contract
            # (the parsed amount rides as its `str(Decimal)` form, mirroring the
            # invoice wizard's `_validate_taxable_base`).
            id=_RENTAS_ANUALES_PAGE_ID,
            widget=FlowWidgetKind.DECIMAL,
            prompt=_locale_ref(_RENTAS_ANUALES_PROMPT_LOCALE_KEY),
            help=_locale_ref(_RENTAS_ANUALES_HELP_LOCALE_KEY),
            format_hint=_locale_ref(FORMAT_AMOUNT_LOCALE_KEY),
            required=False,
            answer_type=str,
            answer_validator_ids=(DESCENDANT_RENTAS_VALIDATOR_ID,),
        ),
        FlowPage(
            id=_DECLARACION_PROPIA_PAGE_ID,
            widget=FlowWidgetKind.CONFIRM,
            prompt=_locale_ref(_DECLARACION_PROPIA_PROMPT_LOCALE_KEY),
            help=_locale_ref(_DECLARACION_PROPIA_HELP_LOCALE_KEY),
            default="false",
            required=False,
            answer_type=bool,
        ),
        FlowPage(
            id=_PRORRATA_MINIMO_PAGE_ID,
            widget=FlowWidgetKind.SELECT,
            prompt=_locale_ref(_PRORRATA_MINIMO_PROMPT_LOCALE_KEY),
            help=_locale_ref(_PRORRATA_MINIMO_HELP_LOCALE_KEY),
            choices=_PRORRATA_MINIMO_CHOICES,
            required=False,
            answer_type=str,
        ),
        FlowPage(
            id=_MESES_MADRE_TRABAJO_PAGE_ID,
            # TEXT rather than INTEGER: the answer names WHICH months qualified, so
            # the same month grammar the guarderia map below uses carries it.
            widget=FlowWidgetKind.TEXT,
            prompt=_locale_ref(_MESES_MADRE_TRABAJO_PROMPT_LOCALE_KEY),
            help=_locale_ref(_MESES_MADRE_TRABAJO_HELP_LOCALE_KEY),
            required=False,
            answer_type=str,
            answer_validator_ids=(DESCENDANT_MESES_VALIDATOR_ID,),
        ),
        FlowPage(
            id=_ALTA_POSTERIOR_NACIMIENTO_MES_PAGE_ID,
            widget=FlowWidgetKind.INTEGER,
            prompt=_locale_ref(_ALTA_POSTERIOR_PROMPT_LOCALE_KEY),
            help=_locale_ref(_ALTA_POSTERIOR_HELP_LOCALE_KEY),
            format_hint=_locale_ref(FORMAT_UNITS_LOCALE_KEY),
            required=False,
            answer_type=int,
            answer_validator_ids=(DESCENDANT_ALTA_POSTERIOR_VALIDATOR_ID,),
        ),
        FlowPage(
            id=_GASTOS_GUARDERIA_PAGE_ID,
            widget=FlowWidgetKind.INTEGER,
            prompt=_locale_ref(_GASTOS_GUARDERIA_PROMPT_LOCALE_KEY),
            help=_locale_ref(_GASTOS_GUARDERIA_HELP_LOCALE_KEY),
            format_hint=_locale_ref(FORMAT_AMOUNT_LOCALE_KEY),
            required=False,
            answer_type=int,
            answer_validator_ids=(DESCENDANT_GASTOS_VALIDATOR_ID,),
        ),
        FlowPage(
            # TEXT, and one page for the WHOLE map. The natural shape would be a
            # month sub-question repeated inside the per-descendant instance, but
            # that is a repeating group nested in a repeating group and this
            # substrate has no primitive for it. So the map is flattened onto one
            # answer carrying the shared `MM:AMOUNT` grammar, and
            # DESCENDANT_GASTOS_MENSUALES_VALIDATOR_ID carries the structure the
            # widget cannot.
            #
            # Shown unconditionally rather than gated on the child's age. The page
            # that needs it most is the period a child turns three, which is
            # computable from the birth date on a sibling page -- but the increase
            # also reaches a child under three all year, whose spend is equally
            # expressible here, and a gate would tell the operator this question
            # does not apply to them in a year where it does.
            id=_GASTOS_GUARDERIA_MENSUALES_PAGE_ID,
            widget=FlowWidgetKind.TEXT,
            prompt=_locale_ref(_GASTOS_MENSUALES_PROMPT_LOCALE_KEY),
            help=_locale_ref(_GASTOS_MENSUALES_HELP_LOCALE_KEY),
            required=False,
            answer_type=str,
            answer_validator_ids=(DESCENDANT_GASTOS_MENSUALES_VALIDATOR_ID,),
        ),
        FlowPage(
            id=_NIF_PAGE_ID,
            widget=FlowWidgetKind.TEXT,
            prompt=_locale_ref(_NIF_PROMPT_LOCALE_KEY),
            format_hint=_locale_ref(FORMAT_TAX_ID_LOCALE_KEY),
            required=False,
            answer_type=str,
            answer_validator_ids=(DESCENDANT_NIF_VALIDATOR_ID,),
        ),
    )


[docs] def build_descendant_count_page(operation: PinnedAuthorityOperation) -> FlowPage: """Build the count page under the caller-owned authority operation.""" natural_person_token = entity_type_natural_person_token(authority=operation) return FlowPage( id=DESCENDANTS_COUNT_PAGE_ID, widget=FlowWidgetKind.INTEGER, prompt=_locale_ref(_COUNT_PROMPT_LOCALE_KEY), help=_locale_ref(_COUNT_HELP_LOCALE_KEY), format_hint=_locale_ref(FORMAT_UNITS_LOCALE_KEY), # Optional with an explicit zero default: a natural-person walk that never # mentions descendants declares zero (the correct tax semantics and the # interactive prefill), and the scripted driver fills the page from this # default, so existing token fixtures need no descendant token. default="0", required=False, visible_when=FlowCondition(page_id="entity-type", equals=natural_person_token.value), answer_type=int, )
[docs] def build_descendant_group(operation: PinnedAuthorityOperation) -> FlowRepeatingGroup: """Build the relationship-aware descendant group under a pinned operation.""" return FlowRepeatingGroup( id=DESCENDANTS_GROUP_ID, title=_locale_ref(_GROUP_TITLE_LOCALE_KEY), count_from=DESCENDANTS_COUNT_PAGE_ID, pages=_build_descendant_pages(operation), )
[docs] def attach_descendant_group( definition: FlowDefinition, *, operation: PinnedAuthorityOperation, ) -> FlowDefinition: """Return ``definition`` with the descendant count page and group in familia. Appends the count-source page and the repeating group to the familia section, in that order, so the substrate's count-source validator sees the INTEGER count page before the group that names it, and names the flow-scope entry-event cross-field validator on the definition so a bad entry date -- or one the declared relación cannot carry -- blocks submit. Raises when the familia section is absent -- a silent no-op would drop the whole descendant surface. Idempotent on the flow validator id: re-applying does not duplicate it. """ count_page = build_descendant_count_page(operation) descendant_group = build_descendant_group(operation) sections: list[FlowSection] = [] attached = False for section in definition.sections: if section.id == _FAMILIA_SECTION_ID: sections.append( section.model_copy( update={"items": (*section.items, count_page, descendant_group)}, ), ) attached = True else: sections.append(section) if not attached: raise ValueError( f"attach_descendant_group: definition {definition.id!r} has no {_FAMILIA_SECTION_ID!r} section", ) flow_validator_ids = definition.flow_validator_ids for validator_id in (DESCENDANT_ENTRY_EVENT_VALIDATOR_ID, DESCENDANT_GUARDERIA_SPEND_VALIDATOR_ID): if validator_id not in flow_validator_ids: flow_validator_ids = (*flow_validator_ids, validator_id) return definition.model_copy( update={"sections": tuple(sections), "flow_validator_ids": flow_validator_ids}, )
__all__ = [ "DESCENDANTS_COUNT_PAGE_ID", "DESCENDANTS_GROUP_ID", "DESCENDANT_ENTRY_EVENT_VALIDATOR_ID", "DESCENDANT_GASTOS_MENSUALES_VALIDATOR_ID", "DESCENDANT_GASTOS_VALIDATOR_ID", "DESCENDANT_GUARDERIA_SPEND_VALIDATOR_ID", "DESCENDANT_MESES_VALIDATOR_ID", "DESCENDANT_NIF_VALIDATOR_ID", "DESCENDANT_PAGE_IDS", "attach_descendant_group", "build_descendant_count_page", "build_descendant_group", ]