"""Coherence fee diagnostic: blind spots, bridges, and fee computation."""

from __future__ import annotations

from dataclasses import dataclass
from fractions import Fraction

from bulla.coboundary import build_coboundary, matrix_rank
from bulla.model import (
    BlindSpot,
    BoundaryObligation,
    Bridge,
    Composition,
    Diagnostic,
    Edge,
    SemanticDimension,
    ToolSpec,
)


@dataclass(frozen=True)
class FeeDecomposition:
    """Result of decomposing a coherence fee over a graph partition.

    ``total_fee`` always equals ``sum(local_fees) + boundary_fee``.
    ``boundary_fee`` equals ``rho_full - rho_obs`` where rho is the
    rank of cross-partition rows modulo internal rows. A nonzero
    ``boundary_fee`` means the partition hides blind spots invisible
    at every level but present in the flat expansion.
    """

    total_fee: int
    local_fees: tuple[int, ...]
    boundary_fee: int
    partition: tuple[frozenset[str], ...]
    boundary_edges: int
    rho_obs: int = 0
    rho_full: int = 0


def diagnose(
    comp: Composition,
    *,
    include_witness_geometry: bool = False,
) -> Diagnostic:
    """Analyse a composition and return its full diagnostic.

    Args:
        comp: composition to diagnose.
        include_witness_geometry: if True and ``coherence_fee > 0``, also
            compute the witness Gram and populate the witness-geometry
            fields on the returned ``Diagnostic`` (leverage scores,
            N_eff, coloops, loops, greedy disclosure set). Defaults to
            False so existing callers see no behavior change.

    Note on `coherence_fee` validity (Sprint 8):
        The fee formula `coherence_fee = h1_obs − h1_internal = rank_internal − rank_obs`
        is only interpretable as a non-negative coherence fee when
        `rank_internal ≥ rank_obs`. This *well-formedness* predicate is
        empirically true for 967/967 real-MCP composition pairs sampled in
        Sprint 8, but fails on ~40% of compositions produced by the broad
        `bulla.model` API (random-stress regime). Callers handling
        compositions of unknown provenance should consult `bulla.regime.classify`
        and check `is_well_formed_for_fee` before interpreting a negative
        `coherence_fee` as a fee.
    """
    tool_map = {t.name: t for t in comp.tools}

    # Naming caveat (Sprint 8 audit): `delta_full` here is built with
    # `use_internal=True`, which selects the *internal-state* fields ONLY,
    # not the union of internal and observable. So `delta_full` is more
    # accurately `delta_internal` — the rank measures obstruction visible
    # from the hidden-state side. The original "full" naming is preserved
    # to avoid a code-wide rename. See `bulla.regime` for the well-formedness
    # predicate (`rank_internal ≥ rank_obs`) that ensures `coherence_fee` is
    # non-negative; in real-MCP corpora this holds in 967/967 pairs sampled.
    delta_obs, v_obs, e_obs = build_coboundary(
        comp.tools, comp.edges, use_internal=False
    )
    delta_full, v_full, _ = build_coboundary(
        comp.tools, comp.edges, use_internal=True
    )

    rank_obs = matrix_rank(delta_obs)
    rank_full = matrix_rank(delta_full)  # actually rank_internal
    dim_c1 = len(e_obs)
    h1_obs = dim_c1 - rank_obs
    h1_full = dim_c1 - rank_full         # actually h1_internal

    blind_spots: list[BlindSpot] = []
    for edge in comp.edges:
        for dim in edge.dimensions:
            if dim.from_field and dim.to_field:
                f_hid = (
                    dim.from_field
                    not in tool_map[edge.from_tool].observable_schema
                )
                t_hid = (
                    dim.to_field
                    not in tool_map[edge.to_tool].observable_schema
                )
                if f_hid or t_hid:
                    blind_spots.append(
                        BlindSpot(
                            dimension=dim.name,
                            edge=f"{edge.from_tool} \u2192 {edge.to_tool}",
                            from_field=dim.from_field,
                            to_field=dim.to_field,
                            from_hidden=f_hid,
                            to_hidden=t_hid,
                            from_tool=edge.from_tool,
                            to_tool=edge.to_tool,
                        )
                    )

    bridges: list[Bridge] = []
    for bs in blind_spots:
        if bs.from_hidden:
            bridges.append(
                Bridge(
                    field=bs.from_field,
                    add_to=(bs.from_tool,),
                    eliminates=bs.dimension,
                )
            )
        if bs.to_hidden:
            bridges.append(
                Bridge(
                    field=bs.to_field,
                    add_to=(bs.to_tool,),
                    eliminates=bs.dimension,
                )
            )

    bridged = list(comp.tools)
    for br in bridges:
        new: dict[str, ToolSpec] = {}
        for t in bridged:
            if t.name in br.add_to and br.field not in t.observable_schema:
                new[t.name] = ToolSpec(
                    t.name, t.internal_state, t.observable_schema + (br.field,)
                )
            else:
                new[t.name] = t
        bridged = [new.get(t.name, t) for t in bridged]

    delta_b, _, _ = build_coboundary(bridged, comp.edges, use_internal=False)
    rank_b = matrix_rank(delta_b)
    h1_b = dim_c1 - rank_b

    betti_1 = max(0, len(comp.edges) - len(comp.tools) + 1)

    n_unbridged = sum(
        1
        for edge in comp.edges
        for dim in edge.dimensions
        if dim.from_field
        and dim.to_field
        and (
            dim.from_field not in tool_map[edge.from_tool].observable_schema
            or dim.to_field not in tool_map[edge.to_tool].observable_schema
        )
    )

    fee = h1_obs - h1_full

    # Witness-geometry is off by default; computed only when the caller
    # explicitly opts in AND the fee is positive (zero-fee compositions
    # have an empty witness matroid with no content to surface).
    wg_hidden_basis: tuple[tuple[str, str], ...] = ()
    wg_leverage: tuple = ()
    wg_n_eff: Fraction | None = None
    wg_coloops: tuple[tuple[str, str], ...] = ()
    wg_loops: tuple[tuple[str, str], ...] = ()
    wg_disclosure: tuple[tuple[str, str], ...] = ()
    if include_witness_geometry and fee > 0:
        # Lazy import keeps import-time cost zero for the default path.
        from bulla.witness_geometry import compute_all as _wg_compute_all

        # compute_all expects raw lists; Composition stores tuples.
        wg = _wg_compute_all(list(comp.tools), list(comp.edges))
        wg_hidden_basis = tuple(tuple(pair) for pair in wg["hidden_basis"])
        wg_leverage = tuple(wg["leverage"])
        wg_n_eff = wg["n_effective"]
        wg_coloops = tuple(tuple(pair) for pair in wg["coloops"])
        wg_loops = tuple(tuple(pair) for pair in wg["loops"])
        wg_disclosure = tuple(tuple(pair) for pair in wg["basis_greedy"])

    return Diagnostic(
        name=comp.name,
        n_tools=len(comp.tools),
        n_edges=len(comp.edges),
        betti_1=betti_1,
        dim_c0_obs=len(v_obs),
        dim_c0_full=len(v_full),
        dim_c1=dim_c1,
        rank_obs=rank_obs,
        rank_full=rank_full,
        h1_obs=h1_obs,
        h1_full=h1_full,
        coherence_fee=fee,
        blind_spots=tuple(blind_spots),
        bridges=tuple(bridges),
        h1_after_bridge=h1_b,
        n_unbridged=n_unbridged,
        hidden_basis=wg_hidden_basis,
        leverage_scores=wg_leverage,
        n_effective=wg_n_eff,
        coloops=wg_coloops,
        loops=wg_loops,
        disclosure_set=wg_disclosure,
    )


def _cross_rank_modulo_internal(
    comp: Composition,
    partition: list[frozenset[str]],
    *,
    use_internal: bool,
) -> int:
    """Rank of cross-partition rows modulo internal rows.

    Builds the full coboundary, splits rows into internal (both
    endpoints in one group) and cross (endpoints in different groups),
    then computes rank([internal; cross]) - rank([internal]).
    """
    delta, _, _ = build_coboundary(
        comp.tools, comp.edges, use_internal=use_internal
    )
    if not delta:
        return 0

    # Row ordering matches _edge_basis: for edge in edges: for dim in edge.dimensions:
    is_row_internal: list[bool] = []
    for edge in comp.edges:
        edge_internal = any(edge.from_tool in g and edge.to_tool in g for g in partition)
        for _ in edge.dimensions:
            is_row_internal.append(edge_internal)

    internal_rows = [
        delta[i] for i, internal in enumerate(is_row_internal) if internal
    ]
    all_rows = delta

    rank_internal = matrix_rank(internal_rows) if internal_rows else 0
    rank_all = matrix_rank(all_rows)
    return rank_all - rank_internal


def decompose_fee(
    comp: Composition,
    partition: list[frozenset[str]],
) -> FeeDecomposition:
    """Decompose a composition's coherence fee over a tool-name partition.

    Each element of *partition* is a frozenset of tool names. Every tool
    must appear in exactly one partition element.

    Returns a ``FeeDecomposition`` where ``total_fee`` (the flat fee)
    always equals ``sum(local_fees) + boundary_fee``.

    ``boundary_fee`` is computed independently as ``rho_full - rho_obs``
    where rho is the rank of cross-partition edge rows modulo internal
    edge rows. Non-negativity (``boundary_fee >= 0``) follows from the
    projection argument: the column-projection from full to observable
    fields preserves linear independence modulo internal rows.
    """
    all_names = frozenset(t.name for t in comp.tools)
    partition_union = frozenset().union(*partition)
    if partition_union != all_names:
        raise ValueError(
            f"Partition must cover all tools. Missing: {all_names - partition_union}, "
            f"extra: {partition_union - all_names}"
        )

    tool_map = {t.name: t for t in comp.tools}
    total_fee = diagnose(comp).coherence_fee

    local_fees: list[int] = []
    for group in partition:
        sub_tools = tuple(tool_map[n] for n in sorted(group))
        sub_edges = tuple(
            e for e in comp.edges
            if e.from_tool in group and e.to_tool in group
        )
        if sub_tools:
            sub_comp = Composition(
                f"sub_{'-'.join(sorted(group))}", sub_tools, sub_edges
            )
            local_fees.append(diagnose(sub_comp).coherence_fee)
        else:
            local_fees.append(0)

    boundary_edge_count = sum(
        1 for e in comp.edges
        if not any(e.from_tool in g and e.to_tool in g for g in partition)
    )

    rho_obs = _cross_rank_modulo_internal(comp, partition, use_internal=False)
    rho_full = _cross_rank_modulo_internal(comp, partition, use_internal=True)
    boundary_fee = rho_full - rho_obs

    assert boundary_fee == total_fee - sum(local_fees), (
        f"Block rank formula disagrees with remainder: "
        f"rho_full-rho_obs={boundary_fee} vs total-local={total_fee - sum(local_fees)}"
    )

    return FeeDecomposition(
        total_fee=total_fee,
        local_fees=tuple(local_fees),
        boundary_fee=boundary_fee,
        partition=tuple(partition),
        boundary_edges=boundary_edge_count,
        rho_obs=rho_obs,
        rho_full=rho_full,
    )


@dataclass(frozen=True)
class DimensionFeeDecomposition:
    """Per-dimension fee decomposition with additivity diagnostics.

    One object answering "what is each dimension's fee, does it explain the
    whole fee, and if not why" — so callers need not compose
    :func:`diagnose`, :func:`decompose_fee_by_dimension`, and
    :func:`has_disjoint_field_decomposition` by hand.

    Attributes:
      by_dimension: dimension name -> ``fee_d`` (non-negative), insertion
        ordered by first appearance in the edge list.
      total_fee: the composition's coherence fee
        (``rank(delta_full) - rank(delta_obs)``).
      residual: ``sum(by_dimension) - total_fee``. By Lemma 1 of the
        Per-Dimension Additivity theorem this equals ``Delta_full - Delta_obs``,
        the **cross-dimensional interaction score**: ``0`` means the dimensions
        are modular (the per-dimension fees explain the whole fee); a nonzero
        value means hidden state couples dimensions that the row partition
        treats as independent. Sign is not constrained in general.
      dfd_holds: whether Disjoint Field Decomposition holds — a *sufficient*
        condition for ``residual == 0`` (DFD ``=>`` additive; the converse is
        not guaranteed, since ``residual`` can vanish without DFD).
      shared_columns: ``{(tool, field): {dim names}}`` for every column shared
        by two or more dimensions (empty iff ``dfd_holds``).
    """

    by_dimension: dict[str, int]
    total_fee: int
    residual: int
    dfd_holds: bool
    shared_columns: dict[tuple[str, str], frozenset[str]]

    @property
    def is_additive(self) -> bool:
        """True iff the per-dimension fees sum exactly to the total fee."""
        return self.residual == 0


def decompose_fee_by_dimension(comp: Composition) -> DimensionFeeDecomposition:
    """Decompose the coherence fee over semantic *dimension* names.

    Where :func:`decompose_fee` partitions by tool group, this partitions
    the coboundary's *rows* by their semantic dimension. Each row of the
    coboundary is an ``(edge, dimension)`` pair (see
    ``coboundary._edge_basis``), and the row basis is identical for the
    observable and full operators. For each dimension name ``d`` the
    per-dimension fee is the independent rank contribution of that
    dimension's rows::

        fee_d = rank(delta_full[rows of d]) - rank(delta_obs[rows of d])

    Returns a :class:`DimensionFeeDecomposition` carrying the per-dimension
    fees, the total fee, the additivity ``residual`` (the cross-dimensional
    interaction score ``sum(fee_d) - total_fee``), and the DFD status.

    Additivity (``residual == 0``) holds when the dimension blocks act on
    disjoint field supports — the Disjoint Field Decomposition (DFD) regime
    (see :func:`has_disjoint_field_decomposition` and
    ``papers/coherence-cliff/results/per_dimension_additivity_theorem.md``).
    DFD is sufficient for ``residual == 0`` but not necessary. Per-dimension
    fees are individually non-negative.
    """
    from bulla.coboundary import build_coboundary, matrix_rank

    delta_obs, _v_obs, e_basis = build_coboundary(
        list(comp.tools), list(comp.edges), use_internal=False
    )
    delta_full, _v_full, e_basis_full = build_coboundary(
        list(comp.tools), list(comp.edges), use_internal=True
    )
    # Row bases depend only on edges, not on use_internal, so they coincide.
    assert e_basis == e_basis_full, "obs/full row bases must coincide"

    dim_order: list[str] = []
    seen: set[str] = set()
    for _label, dim_name in e_basis:
        if dim_name not in seen:
            seen.add(dim_name)
            dim_order.append(dim_name)

    by_dimension: dict[str, int] = {}
    for d in dim_order:
        rows_obs = [delta_obs[i] for i, (_l, dn) in enumerate(e_basis) if dn == d]
        rows_full = [delta_full[i] for i, (_l, dn) in enumerate(e_basis) if dn == d]
        by_dimension[d] = matrix_rank(rows_full) - matrix_rank(rows_obs)

    total_fee = matrix_rank(delta_full) - matrix_rank(delta_obs)
    residual = sum(by_dimension.values()) - total_fee
    violations = disjoint_field_decomposition_violations(comp)
    return DimensionFeeDecomposition(
        by_dimension=by_dimension,
        total_fee=total_fee,
        residual=residual,
        dfd_holds=not violations,
        shared_columns={col: frozenset(dims) for col, dims in violations.items()},
    )


def dimension_column_supports(comp: Composition) -> dict[str, set[tuple[str, str]]]:
    """Map each semantic dimension name to the set of ``(tool, field)`` columns
    its edges are incident to (in the full coboundary).

    A dimension-``d`` row for edge ``e`` is incident to
    ``(e.from_tool, dim.from_field)`` and ``(e.to_tool, dim.to_field)``.
    """
    supports: dict[str, set[tuple[str, str]]] = {}
    for edge in comp.edges:
        for dim in edge.dimensions:
            cols = supports.setdefault(dim.name, set())
            if dim.from_field:
                cols.add((edge.from_tool, dim.from_field))
            if dim.to_field:
                cols.add((edge.to_tool, dim.to_field))
    return supports


def disjoint_field_decomposition_violations(
    comp: Composition,
) -> dict[tuple[str, str], set[str]]:
    """Return the columns shared by more than one dimension (DFD violations).

    A composition satisfies *Disjoint Field Decomposition* (DFD) when distinct
    semantic dimensions have disjoint ``(tool, field)`` column supports. This
    returns ``{(tool, field): {dim names}}`` for every column touched by two or
    more dimensions; an empty dict means DFD holds.
    """
    supports = dimension_column_supports(comp)
    col_to_dims: dict[tuple[str, str], set[str]] = {}
    for dim_name, cols in supports.items():
        for col in cols:
            col_to_dims.setdefault(col, set()).add(dim_name)
    return {col: dims for col, dims in col_to_dims.items() if len(dims) > 1}


def has_disjoint_field_decomposition(comp: Composition) -> bool:
    """True iff distinct dimensions have disjoint column supports (DFD).

    Under DFD the per-dimension restriction makes both coboundary operators
    block-diagonal (dimension-disjoint rows x DFD-disjoint columns), so their
    ranks add and :func:`decompose_fee_by_dimension` satisfies
    ``sum(fee_d) == coherence_fee`` exactly (Per-Dimension Additivity theorem).
    """
    return not disjoint_field_decomposition_violations(comp)


@dataclass(frozen=True)
class OpenPort:
    """An unconnected port in a partial composition.

    Represents a future edge to an as-yet-unspecified tool.
    ``dimensions`` lists the semantic dimensions that will flow
    across this edge, with ``from_field`` on the known tool and
    ``to_field`` on the placeholder.
    """

    from_tool: str
    placeholder_name: str
    dimensions: tuple[SemanticDimension, ...]


@dataclass(frozen=True)
class ConditionalDiagnostic:
    """Diagnostic for a partial composition with open ports.

    ``baseline_fee`` is the fee of the known subgraph alone.
    ``worst_case_fee`` is the fee if placeholders disclose nothing.
    ``obligations`` list which fields each placeholder must expose
    for the fee to drop. ``structural_unknowns`` counts open-port
    dimension slots (distinct from epistemic unknowns).
    ``extended_comp`` is the composition with placeholder tools,
    stored so ``resolve_conditional`` can swap placeholders without
    requiring the caller to reconstruct it.
    """

    baseline_diag: Diagnostic
    extended_diag: Diagnostic
    baseline_fee: int
    worst_case_fee: int
    obligations: tuple[BoundaryObligation, ...]
    structural_unknowns: int
    extended_comp: Composition | None = None


def prescriptive_disclosure(
    comp: Composition, fee: int
) -> list[tuple[str, str]]:
    """Return the minimum disclosure set if fee > 0, else empty list.

    This is the canonical lazy guard: when ``fee`` is already known to be
    zero, it skips the coboundary matrix construction entirely.  Both
    the MCP surface (``serve.py``) and the CLI (``bulla gauge``) should
    call this rather than duplicating the ``if fee > 0`` check.
    """
    if fee <= 0:
        return []
    return minimum_disclosure_set(comp)


def minimum_disclosure_set(
    comp: Composition,
) -> list[tuple[str, str]]:
    """Find the minimum set of (tool, field) disclosures to reduce the fee to zero.

    Returns a list of ``(tool_name, field_name)`` pairs. Disclosing each
    field (adding it to the tool's observable schema) is sufficient to
    eliminate all blind spots.  The set has exactly ``fee`` elements — it
    is a basis for the quotient space
    ``col(delta_full) / col(delta_obs)``.

    Greedy column selection finds one such basis.  The result is minimal:
    removing any single disclosure leaves the fee nonzero.

    The returned set is a valid minimum disclosure set but not necessarily
    unique. Different tool orderings may yield different sets of the same
    cardinality.
    """
    delta_obs, v_obs, _ = build_coboundary(
        comp.tools, comp.edges, use_internal=False
    )
    delta_full, v_full, _ = build_coboundary(
        comp.tools, comp.edges, use_internal=True
    )
    rank_obs = matrix_rank(delta_obs)
    rank_full = matrix_rank(delta_full)
    fee = rank_full - rank_obs
    if fee == 0:
        return []

    obs_set = set(v_obs)
    hidden_cols = [
        (col_idx, tool_name, field_name)
        for col_idx, (tool_name, field_name) in enumerate(v_full)
        if (tool_name, field_name) not in obs_set
    ]

    current = [row[:] for row in delta_obs]
    current_rank = rank_obs
    disclosures: list[tuple[str, str]] = []

    # Greedy works because matroid rank is submodular.
    for col_idx, tool_name, field_name in hidden_cols:
        augmented = [row + [delta_full[r][col_idx]] for r, row in enumerate(current)]
        aug_rank = matrix_rank(augmented)
        if aug_rank > current_rank:
            current = augmented
            current_rank = aug_rank
            disclosures.append((tool_name, field_name))
            if current_rank == rank_full:
                break

    return disclosures


def boundary_obligations_from_decomposition(
    comp: Composition,
    partition: list[frozenset[str]],
    diag: Diagnostic,
) -> tuple[BoundaryObligation, ...]:
    """Extract boundary obligations from cross-partition blind spots.

    For each blind spot on a cross-partition edge where a field is
    hidden, produce a ``BoundaryObligation``.  The ``placeholder_tool``
    is the server group name (derived from tool name prefix), not the
    specific tool name.

    This does NOT use ``conditional_diagnose`` (which requires explicit
    open ports).  It uses the existing decomposition + blind spot data
    from a full-composition diagnostic.

    Deduplicates on ``(placeholder_tool, dimension, field)``; the first
    ``source_edge`` encountered is kept. ``source_edge`` is informational
    provenance, not semantic identity.
    """
    tool_to_group: dict[str, str] = {}
    for group_idx, group in enumerate(partition):
        prefix = None
        for t_name in sorted(group):
            parts = t_name.split("__", 1)
            if len(parts) == 2:
                prefix = parts[0]
                break
        group_label = prefix if prefix else f"group_{group_idx}"
        for t_name in group:
            tool_to_group[t_name] = group_label

    seen: dict[tuple[str, str, str], BoundaryObligation] = {}
    for bs in diag.blind_spots:
        if not bs.from_tool or not bs.to_tool:
            continue
        g_from = tool_to_group.get(bs.from_tool)
        g_to = tool_to_group.get(bs.to_tool)
        if g_from is None or g_to is None or g_from == g_to:
            continue

        edge_label = f"{bs.from_tool} -> {bs.to_tool}"
        if bs.from_hidden:
            key = (g_from, bs.dimension, bs.from_field)
            if key not in seen:
                seen[key] = BoundaryObligation(
                    placeholder_tool=g_from,
                    dimension=bs.dimension,
                    field=bs.from_field,
                    source_edge=edge_label,
                )
        if bs.to_hidden:
            key = (g_to, bs.dimension, bs.to_field)
            if key not in seen:
                seen[key] = BoundaryObligation(
                    placeholder_tool=g_to,
                    dimension=bs.dimension,
                    field=bs.to_field,
                    source_edge=edge_label,
                )

    return tuple(seen.values())


def check_obligations(
    obligations: tuple[BoundaryObligation, ...],
    comp: Composition,
) -> tuple[
    tuple[BoundaryObligation, ...],
    tuple[BoundaryObligation, ...],
    tuple[BoundaryObligation, ...],
]:
    """Check which obligations the current composition satisfies.

    Returns ``(met, unmet, irrelevant)``:

    - **met**: field is in some tool's ``observable_schema``
    - **unmet**: field is in some tool's ``internal_state`` but NOT
      in ``observable_schema``
    - **irrelevant**: no tool in the composition has the field at all

    v0.25.0: three-way classification.  Future: add ``contradictory``
    when multi-parent merges produce conflicting obligations.
    """
    all_observable: set[str] = set()
    all_internal: set[str] = set()
    for t in comp.tools:
        all_observable.update(t.observable_schema)
        all_internal.update(t.internal_state)

    met: list[BoundaryObligation] = []
    unmet: list[BoundaryObligation] = []
    irrelevant: list[BoundaryObligation] = []
    for obl in obligations:
        if obl.field in all_observable:
            met.append(obl)
        elif obl.field in all_internal:
            unmet.append(obl)
        else:
            irrelevant.append(obl)

    return tuple(met), tuple(unmet), tuple(irrelevant)


def satisfies_obligations(
    tool: ToolSpec,
    obligations: tuple[BoundaryObligation, ...],
) -> tuple[bool, list[str]]:
    """Check whether a tool meets a set of boundary obligations.

    Checks fields only — ignores ``obl.placeholder_tool``.  The caller
    is responsible for filtering obligations to those relevant to the
    tool being checked (e.g. by placeholder name).

    Returns ``(True, [])`` if every obligated field is in the tool's
    observable schema, otherwise ``(False, unmet)`` where *unmet*
    lists human-readable descriptions of unsatisfied obligations.
    """
    unmet = [
        f"{obl.dimension}: {obl.field} not in {tool.name}.observable_schema"
        for obl in obligations
        if obl.field not in tool.observable_schema
    ]
    return (len(unmet) == 0, unmet)


def conditional_diagnose(
    comp: Composition,
    open_ports: list[OpenPort],
) -> ConditionalDiagnostic:
    """Diagnose a partial composition with open ports.

    Desugars to the existing kernel: creates placeholder tools with
    everything hidden, extends the graph, runs ``diagnose`` twice
    (known subgraph and extended graph), and reads off boundary
    obligations from the blind spots on placeholder edges.
    """
    baseline_diag = diagnose(comp)

    placeholders: dict[str, ToolSpec] = {}
    new_edges: list[Edge] = []

    for port in open_ports:
        fields = tuple(d.to_field for d in port.dimensions if d.to_field)
        if port.placeholder_name not in placeholders:
            placeholders[port.placeholder_name] = ToolSpec(
                name=port.placeholder_name,
                internal_state=fields,
                observable_schema=(),
            )
        else:
            existing = placeholders[port.placeholder_name]
            merged = set(existing.internal_state)
            merged.update(fields)
            placeholders[port.placeholder_name] = ToolSpec(
                name=port.placeholder_name,
                internal_state=tuple(sorted(merged)),
                observable_schema=(),
            )

        new_edges.append(Edge(
            from_tool=port.from_tool,
            to_tool=port.placeholder_name,
            dimensions=port.dimensions,
        ))

    extended_tools = comp.tools + tuple(placeholders.values())
    extended_edges = comp.edges + tuple(new_edges)
    extended_comp = Composition(
        name=f"{comp.name}_extended",
        tools=extended_tools,
        edges=extended_edges,
    )

    extended_diag = diagnose(extended_comp)

    obligations: list[BoundaryObligation] = []
    for bs in extended_diag.blind_spots:
        if bs.to_tool in placeholders and bs.to_hidden:
            obligations.append(BoundaryObligation(
                placeholder_tool=bs.to_tool,
                dimension=bs.dimension,
                field=bs.to_field,
            ))

    structural_unknowns = sum(
        len(port.dimensions) for port in open_ports
    )

    return ConditionalDiagnostic(
        baseline_diag=baseline_diag,
        extended_diag=extended_diag,
        baseline_fee=baseline_diag.coherence_fee,
        worst_case_fee=extended_diag.coherence_fee,
        obligations=tuple(obligations),
        structural_unknowns=structural_unknowns,
        extended_comp=extended_comp,
    )


@dataclass(frozen=True)
class Resolution:
    """Result of resolving placeholders in a conditional diagnostic.

    ``fee_delta`` is ``worst_case_fee - resolved_fee`` and is always
    non-negative: a real tool with any observable fields is at least
    as informative as a placeholder with none.
    """

    resolved_diag: Diagnostic
    resolved_fee: int
    fee_delta: int
    met_obligations: tuple[BoundaryObligation, ...]
    remaining_obligations: tuple[BoundaryObligation, ...]


def resolve_conditional(
    cond: ConditionalDiagnostic,
    resolutions: dict[str, ToolSpec],
) -> Resolution:
    """Resolve one or more placeholders in a conditional diagnostic.

    Accepts a ``ConditionalDiagnostic`` (from ``conditional_diagnose``)
    and a mapping of placeholder names to real ``ToolSpec`` objects.
    Rebuilds the composition with the real tools swapped in, runs
    ``diagnose``, and partitions the original obligations into met and
    remaining.

    Supports partial resolution: unresolved placeholders stay as-is.
    """
    if cond.extended_comp is None:
        raise ValueError(
            "ConditionalDiagnostic has no extended_comp; "
            "use conditional_diagnose from bulla >= 0.13.0"
        )

    ext_tool_names = {t.name for t in cond.extended_comp.tools}
    for name in resolutions:
        if name not in ext_tool_names:
            raise ValueError(
                f"Placeholder '{name}' not found in extended composition. "
                f"Available tools: {sorted(ext_tool_names)}"
            )

    resolved_tools = tuple(
        resolutions[t.name] if t.name in resolutions else t
        for t in cond.extended_comp.tools
    )
    resolved_comp = Composition(
        name=f"{cond.extended_comp.name}_resolved",
        tools=resolved_tools,
        edges=cond.extended_comp.edges,
    )

    resolved_diag = diagnose(resolved_comp)

    met: list[BoundaryObligation] = []
    remaining: list[BoundaryObligation] = []
    for obl in cond.obligations:
        if obl.placeholder_tool in resolutions:
            real_tool = resolutions[obl.placeholder_tool]
            if obl.field in real_tool.observable_schema:
                met.append(obl)
            else:
                remaining.append(obl)
        else:
            remaining.append(obl)

    fee_delta = cond.worst_case_fee - resolved_diag.coherence_fee
    assert fee_delta >= 0, (
        f"Fee increased after resolution: worst_case={cond.worst_case_fee}, "
        f"resolved={resolved_diag.coherence_fee}"
    )

    return Resolution(
        resolved_diag=resolved_diag,
        resolved_fee=resolved_diag.coherence_fee,
        fee_delta=fee_delta,
        met_obligations=tuple(met),
        remaining_obligations=tuple(remaining),
    )
