"""Diagnostic construction-affordance catalog.

Formalizes the constructional-affordance pattern proved by PR #835 so future
diagnostic families do not become scattered local parser patches.

This module contains declarations and proposal factories only.  It does NOT:
- claim CGA/substrate geometric retrieval (no manifold calls here);
- label local regex matching as substrate cognition;
- add any acquisition/transaction or transfer/loss/rate/comparison families;
- affect serving (all entries have ``serving_allowed=False``).

Every entry in this catalog is ``diagnostic_only=True``.  The catalog expresses
what a construction *means*, what roles it requires, what hazards it carries, and
what target semantics close it.  The assessment functions that check actual
ProblemFrame evidence live in ``generate/problem_frame_contracts.py``.

Design doctrine (from ADR-0223):
    Words and chunks are probes into the semantic substrate.
    They surface candidate affordances and relation families.
    Semantic closeness proposes.
    Exact span bindings ground.
    Organ-specific contracts determine.
    Unsupported or ambiguous cases refuse.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar, Literal

if TYPE_CHECKING:
    from generate.kernel_facts import SourceSpan


# ---------------------------------------------------------------------------
# Frozen typed structures
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class RoleObligation:
    """A single role obligation for a construction.

    Args:
        role:        Role name that must be bound (e.g. ``"base_quantity"``).
        required:    If True, absence blocks contract closure.  If False, the
                     role is advisory only.
        description: Human-readable description of what this role represents.
    """

    role: str
    required: bool
    description: str


@dataclass(frozen=True, slots=True)
class ConstructionHazard:
    """An ambiguity or confuser hazard associated with a construction family.

    Args:
        hazard_category: Matches ``KernelHazard.category`` in the frame.
        blocking:        If True, the presence of this hazard blocks closure.
        description:     Explanation of what the hazard represents.
    """

    hazard_category: str
    blocking: bool
    description: str


@dataclass(frozen=True, slots=True)
class ConstructionSignature:
    """The relational signature of a construction: its type, organ, and roles.

    Args:
        relation_type:  The ``BoundRelation.relation_type`` value that carries
                        this construction (e.g. ``"decrease_to_fraction"``).
        candidate_organ: The assessment organ identifier produced by the
                         corresponding contract (e.g. ``"fraction_decrease"``).
        required_roles:  Roles that must be bound for the contract to be
                         declared runnable.
        optional_roles:  Roles that improve precision but are not blocking.
    """

    relation_type: str
    candidate_organ: str
    required_roles: tuple[RoleObligation, ...]
    optional_roles: tuple[RoleObligation, ...]


@dataclass(frozen=True, slots=True)
class ConstructionFamily:
    """A reviewed construction-affordance family declaration.

    Args:
        family_id:       Stable dotted identifier, e.g.
                         ``"proportional_change.decrease_to_fraction"``.
        display_name:    Short human-readable label.
        signature:       Relational signature (roles, organ, type).
        hazards:         Known hazards and confusers for this family.
        target_semantics: Required (operator, state, direction) values for the
                         bound question target (free-form strings matching
                         ``BoundQuestionTarget`` fields).
        contract_labels: Stable blocker code strings used in
                         ``ContractAssessment.missing_bindings``.
        diagnostic_only: Always True in this PR; the construction may produce
                         diagnostic assessments but must not change serving.
        serving_allowed: Always False in this PR.  No construction may be
                         promoted to serving without a separate reviewed PR.
    """

    family_id: str
    display_name: str
    signature: ConstructionSignature
    hazards: tuple[ConstructionHazard, ...]
    target_semantics: tuple[str, ...]
    contract_labels: tuple[str, ...]
    diagnostic_only: bool
    serving_allowed: bool

    def __post_init__(self) -> None:
        if self.serving_allowed:
            raise ValueError(
                f"ConstructionFamily {self.family_id!r}: "
                "serving_allowed must be False in this PR — "
                "no construction may be promoted to serving without a separate reviewed PR."
            )
        if not self.diagnostic_only:
            raise ValueError(
                f"ConstructionFamily {self.family_id!r}: "
                "diagnostic_only must be True — "
                "catalog constructions do not authorize serving."
            )


@dataclass(frozen=True, slots=True)
class ConstructionContract:
    """Registry entry binding a ConstructionFamily to its assessment function.

    Args:
        family:          The construction family declaration.
        assess_fn_name:  Name of the assessment function in
                         ``generate.problem_frame_contracts`` (for introspection
                         only; the function is not called through this struct).
    """

    family: ConstructionFamily
    assess_fn_name: str


@dataclass(frozen=True, slots=True)
class ConstructionProposal:
    """Lightweight diagnostic-only trace of a construction proposal.

    Represents the evidence chain::

        chunk/surface evidence
        → proposed construction family
        → proposed relation type
        → role obligations
        → hazards
        → status

    A proposal is created from exact surface evidence before contract
    assessment.  It does not affect serving.  Its initial ``status`` is
    ``"proposed"``; assessment remains a separate, downstream judgment.

    Args:
        family_id:       Catalog family identifier, e.g.
                         ``"proportional_change.decrease_to_fraction"``.
        relation_type:   The ``BoundRelation.relation_type`` that was searched.
        candidate_organ: The organ this proposal targets.
        evidence_spans:  Source spans that motivated the proposal.
        status:          Always ``"proposed"``.  Runnable/refused authority
                         belongs exclusively to ``ContractAssessment``.
        role_obligations: Catalog-declared roles that downstream binding and
                          assessment must ground or explicitly refuse.
        diagnostic_only: True for every proposal in the current catalog.
        serving_allowed: False for every proposal in the current catalog.
    """

    family_id: str
    relation_type: str
    candidate_organ: str
    evidence_spans: tuple[SourceSpan, ...]
    status: Literal["proposed"]
    role_obligations: tuple[RoleObligation, ...] = ()
    diagnostic_only: bool = True
    serving_allowed: bool = False

    _VALID_STATUS: ClassVar[Literal["proposed"]] = "proposed"

    def __post_init__(self) -> None:
        if self.status != self._VALID_STATUS:
            raise ValueError(
                "ConstructionProposal.status must remain 'proposed'; "
                "ContractAssessment is the sole runnable/refused authority"
            )
        if not self.diagnostic_only or self.serving_allowed:
            raise ValueError(
                "ConstructionProposal must remain diagnostic-only and serving-disallowed"
            )

    @property
    def missing_roles(self) -> tuple[str, ...]:
        """Compatibility view; assessment blockers are not proposal state."""

        return ()

    @property
    def active_hazards(self) -> tuple[str, ...]:
        """Compatibility view; assessment hazards are not proposal state."""

        return ()


# ---------------------------------------------------------------------------
# Catalog entries
# ---------------------------------------------------------------------------

_DECREASE_TO_FRACTION_FAMILY = ConstructionFamily(
    family_id="proportional_change.decrease_to_fraction",
    display_name="Proportional decrease to fraction",
    signature=ConstructionSignature(
        relation_type="decrease_to_fraction",
        candidate_organ="fraction_decrease",
        required_roles=(
            RoleObligation(
                "base_quantity",
                required=True,
                description="The quantity whose value will decrease (the current/initial state).",
            ),
            RoleObligation(
                "scale",
                required=True,
                description=(
                    "Fraction p/q in (0, 1) exclusive: the new value is scale × base_quantity. "
                    "Values of 0, 1, or > 1 are rejected."
                ),
            ),
            RoleObligation(
                "state_entity",
                required=True,
                description=(
                    "The entity (object or actor) whose state undergoes the decrease. "
                    "Must match the question-target entity for state_entity_continuity."
                ),
            ),
            RoleObligation(
                "transition",
                required=True,
                description='Span anchor for the "decrease to" trigger phrase.',
            ),
        ),
        optional_roles=(
            RoleObligation(
                "unit",
                required=False,
                description=(
                    "Unit of the base quantity. When present, unit_continuity is checked: "
                    "the unit binding on base_quantity must match this role."
                ),
            ),
        ),
    ),
    hazards=(
        ConstructionHazard(
            hazard_category="unbound_base_quantity",
            blocking=True,
            description=(
                "The base quantity cannot be uniquely identified. "
                "Multiple candidates or no 'current/now' sentence context."
            ),
        ),
        ConstructionHazard(
            hazard_category="percent_change_vs_percent_of",
            blocking=False,
            description=(
                "Percent-change surfaces may co-occur; they do not trigger this construction "
                "because this family only recognises exact fraction (p/q) scale triggers."
            ),
        ),
    ),
    target_semantics=(
        "operator:difference",
        "state:delta",
        "direction:decrease",
    ),
    contract_labels=(
        "decrease_relation_ambiguous",
        "base_quantity_unbound",
        "scale_unbound",
        "state_entity_unbound",
        "base_quantity_provenance_missing",
        "scale_provenance_missing",
        "unit_continuity_unproven",
        "delta_decrease_target_unbound",
        "delta_decrease_target_required",
        "state_entity_continuity_unproven",
        "scale_out_of_range",
    ),
    diagnostic_only=True,
    serving_allowed=False,
)

_PERCENT_PARTITION_FAMILY = ConstructionFamily(
    family_id="partition.percent_partition",
    display_name="Percent-based partition of a whole",
    signature=ConstructionSignature(
        relation_type="percent_of",  # primary relation type checked in contracts
        candidate_organ="percent_partition",
        required_roles=(
            RoleObligation(
                "whole",
                required=True,
                description=(
                    "The original numeric whole that the partition subdivides. "
                    "Must appear before the partition event in text."
                ),
            ),
            RoleObligation(
                "part",
                required=True,
                description=(
                    "A distinct subgroup of the whole. At least two distinct "
                    "subgroup-part IDs are required to prove complementary coverage."
                ),
            ),
            RoleObligation(
                "scale",
                required=True,
                description=(
                    "Percentage or fraction linking part to whole via percent_of relation. "
                    "Each distinct part must have its own linked scale."
                ),
            ),
        ),
        optional_roles=(),
    ),
    hazards=(
        ConstructionHazard(
            hazard_category="unbound_base_quantity",
            blocking=True,
            description=(
                "No numeric whole quantity is bound before the partition event. "
                "Without a provenance-bearing original whole, closure is refused."
            ),
        ),
        ConstructionHazard(
            hazard_category="percent_change_vs_percent_of",
            blocking=True,
            description=(
                "Percent-change surface ambiguity: 'percent' could indicate a change "
                "rather than a partition fraction. Topology proof resolves this."
            ),
        ),
    ),
    target_semantics=(
        "operator:count",
        "state:aggregate",
        "direction:forward",
    ),
    contract_labels=(
        "grounded_partition_subgroup",
        "grounded_whole_entity",
        "original_whole_unbound",
        "multiple_original_whole_candidates",
        "partition_subgroups_not_distinct",
        "percent_subgroup_links_incomplete",
        "grounded_question_target",
        "forward_aggregate_target_required",
        "inverse_topology_unlicensed",
    ),
    diagnostic_only=True,
    serving_allowed=False,
)

_QUANTITY_ENTITY_FAMILY = ConstructionFamily(
    family_id="binding.quantity_entity",
    display_name="Local quantity-entity binding",
    signature=ConstructionSignature(
        relation_type="quantity_entity",
        candidate_organ="quantity_entity_binding",
        required_roles=(
            RoleObligation(
                "quantity",
                required=True,
                description="Exactly one source-grounded scalar mention.",
            ),
            RoleObligation(
                "entity",
                required=True,
                description="Exactly one local source-grounded entity mention.",
            ),
            RoleObligation(
                "quantity_kind",
                required=True,
                description="A positively grounded count or measurement disposition.",
            ),
            RoleObligation(
                "provenance_span",
                required=True,
                description="Exact non-synthetic quantity, entity, and optional unit spans.",
            ),
            RoleObligation(
                "local_binding_relation",
                required=True,
                description="The unique local quantity_entity MentionBinding edge.",
            ),
        ),
        optional_roles=(
            RoleObligation(
                "unit",
                required=False,
                description=(
                    "An exact grounded unit bound to the same quantity when the "
                    "quantity kind is measurement."
                ),
            ),
        ),
    ),
    hazards=(
        ConstructionHazard(
            hazard_category="quantity_entity_ambiguous",
            blocking=True,
            description="More than one local scalar, entity, or binding remains plausible.",
        ),
        ConstructionHazard(
            hazard_category="quantity_kind_unresolved",
            blocking=True,
            description="Count or measurement kind is not positively grounded.",
        ),
        ConstructionHazard(
            hazard_category="quantity_entity_nonlocal",
            blocking=True,
            description="The binding requires pronoun repair or a cross-sentence leap.",
        ),
    ),
    target_semantics=(
        "locality:single_sentence",
        "authority:diagnostic_only",
    ),
    contract_labels=(
        "quantity_entity_proposal_required",
        "quantity_unbound",
        "entity_unbound",
        "quantity_ambiguous",
        "entity_ambiguous",
        "local_binding_relation_unbound",
        "local_binding_relation_ambiguous",
        "quantity_kind_unresolved",
        "unit_kind_conflict",
        "provenance_span_inexact",
        "quantity_entity_nonlocal",
        "competing_family_context",
        "percent_change_vs_percent_of",
    ),
    diagnostic_only=True,
    serving_allowed=False,
)

_UNARY_DELTA_FAMILY = ConstructionFamily(
    family_id="state_change.unary_delta",
    display_name="Unary gained/lost delta",
    signature=ConstructionSignature(
        relation_type="unary_delta",
        candidate_organ="unary_delta_transition",
        required_roles=(
            RoleObligation(
                "action_cue",
                required=True,
                description="Exact local gained/lost cue span from the original text.",
            ),
            RoleObligation(
                "delta_quantity",
                required=True,
                description="Exactly one grounded scalar quantity attached to the event.",
            ),
            RoleObligation(
                "changed_object",
                required=True,
                description="Exactly one grounded local object whose quantity changed.",
            ),
            RoleObligation(
                "direction",
                required=True,
                description="Increase for gained and decrease for lost.",
            ),
        ),
        optional_roles=(),
    ),
    hazards=(
        ConstructionHazard(
            hazard_category="quantity_entity_nonlocal",
            blocking=True,
            description=(
                "Pronoun repair or cross-sentence binding would be required to "
                "decide the changed object."
            ),
        ),
        ConstructionHazard(
            hazard_category="percent_change_vs_percent_of",
            blocking=True,
            description=(
                "Percent/rate/comparison phrasing would widen this slice beyond "
                "a local unary gained/lost event."
            ),
        ),
    ),
    target_semantics=(
        "authority:diagnostic_only",
        "cue_inventory:gained|lost",
        "locality:single_clause",
    ),
    contract_labels=(
        "unary_delta_proposal_required",
        "unary_delta_relation_ambiguous",
        "action_cue_unbound",
        "delta_quantity_unbound",
        "delta_quantity_ambiguous",
        "changed_object_unbound",
        "changed_object_ambiguous",
        "local_binding_relation_unbound",
        "direction_unbound",
        "quantity_kind_unresolved",
        "unit_object_conflict",
        "provenance_span_inexact",
        "quantity_entity_nonlocal",
        "pronoun_antecedent_unresolved",
        "event_assertion_unlicensed",
        "passive_voice_unsupported",
        "multiple_actor_surface",
    ),
    diagnostic_only=True,
    serving_allowed=False,
)

# The catalog.  Keys are family_id strings.  Sorted for deterministic iteration.
_CATALOG: dict[str, ConstructionFamily] = {
    _DECREASE_TO_FRACTION_FAMILY.family_id: _DECREASE_TO_FRACTION_FAMILY,
    _PERCENT_PARTITION_FAMILY.family_id: _PERCENT_PARTITION_FAMILY,
    _QUANTITY_ENTITY_FAMILY.family_id: _QUANTITY_ENTITY_FAMILY,
    _UNARY_DELTA_FAMILY.family_id: _UNARY_DELTA_FAMILY,
}

# Secondary indices for O(1) lookup by organ or relation type.
_BY_ORGAN: dict[str, ConstructionFamily] = {
    family.signature.candidate_organ: family
    for family in _CATALOG.values()
}
_BY_RELATION_TYPE: dict[str, ConstructionFamily] = {
    family.signature.relation_type: family
    for family in _CATALOG.values()
}

_PROPOSAL_FIRST_FAMILIES: frozenset[str] = frozenset({
    "binding.quantity_entity",
    "proportional_change.decrease_to_fraction",
    "partition.percent_partition",
    "state_change.unary_delta",
})


# ---------------------------------------------------------------------------
# Public accessors
# ---------------------------------------------------------------------------


def lookup_family(family_id: str) -> ConstructionFamily | None:
    """Return the ConstructionFamily for *family_id*, or None if not registered."""
    return _CATALOG.get(family_id)


def lookup_by_organ(candidate_organ: str) -> ConstructionFamily | None:
    """Return the ConstructionFamily whose signature.candidate_organ matches."""
    return _BY_ORGAN.get(candidate_organ)


def lookup_by_relation_type(relation_type: str) -> ConstructionFamily | None:
    """Return the ConstructionFamily whose signature.relation_type matches.

    Note: ``percent_partition`` uses two relation types (``percent_of`` and
    ``subgroup_partition``) in its assessment logic, but the catalog entry
    declares the primary relation type (``percent_of``) as the lookup key.
    """
    return _BY_RELATION_TYPE.get(relation_type)


def all_diagnostic_families() -> tuple[ConstructionFamily, ...]:
    """Return all registered diagnostic families in deterministic (family_id) order."""
    return tuple(_CATALOG[key] for key in sorted(_CATALOG))


def propose_construction(
    family_id: str,
    evidence_spans: tuple[SourceSpan, ...],
) -> ConstructionProposal:
    """Create a catalog-backed proposal from pre-assessment surface evidence.

    This factory deliberately has no assessment inputs.  It records the
    construction hypothesis and its catalog obligations; bound relations and
    ``ContractAssessment`` remain responsible for grounding and determination.

    Raises:
        KeyError: If *family_id* is not registered in the catalog.
    """
    family = _CATALOG[family_id]
    return ConstructionProposal(
        family_id=family.family_id,
        relation_type=family.signature.relation_type,
        candidate_organ=family.signature.candidate_organ,
        evidence_spans=evidence_spans,
        status="proposed",
        role_obligations=(
            *family.signature.required_roles,
            *family.signature.optional_roles,
        ),
        diagnostic_only=family.diagnostic_only,
        serving_allowed=family.serving_allowed,
    )


def make_proposal(
    family_id: str,
    evidence_spans: tuple[SourceSpan, ...],
    assessment_runnable: bool,
    missing_roles: tuple[str, ...],
    active_hazards: tuple[str, ...],
) -> ConstructionProposal:
    """Reject assessment-backed proposal synthesis.

    Every catalog family is proposal-first.  The assessment-shaped signature
    remains temporarily for callers that need a loud migration failure, but no
    ``ConstructionProposal`` may encode assessment output.

    Args:
        family_id:           Catalog family identifier.
        evidence_spans:      Source spans from the contract assessment.
        assessment_runnable: Whether the corresponding ContractAssessment.runnable
                             was True.
        missing_roles:       ContractAssessment.missing_bindings contents.
        active_hazards:      ContractAssessment.unresolved_hazards contents.

    Raises:
        KeyError: If *family_id* is not registered in the catalog.
        ValueError: For every catalog family; use ``propose_construction``
            before assessment instead.
    """
    _ = (evidence_spans, assessment_runnable, missing_roles, active_hazards)
    if family_id not in _CATALOG:
        raise KeyError(family_id)
    raise ValueError(
        f"{family_id} is proposal-first; use propose_construction before assessment"
    )
