//! Executable Adobe compatibility rules.
//!
//! This module turns documented compat-rule metadata into running code. Each
//! rule has:
//!
//! - A pure decision function that takes inputs by value and returns a typed
//!   outcome. No I/O, no engine state mutation.
//! - A `(Phase, Reason)` trace anchor emitted when the rule's decision is
//!   non-trivial.
//! - Unit tests that pin the decision logic against the rule's
//!   `positive_examples` and `counterexamples` from the registry.
//! - A regression guard hooked into an existing corpus test.
//!
//! ## Migrated rules (UX1, M5.2, M5.2b, M5.3)
//!
//! - `CapSuppressionByFormDomPageAreaCount` (UX1) — `cap_suppression_by_form_dom`.
//! - `SuppressEmptyPagesOnlyWhenRealDataBound` (M5.2) —
//!   `suppress_empty_pages_only_when_real_data_bound`.
//! - `ExcludeBindNoneFieldsFromPageDataSuppression` (M5.2) —
//!   `exclude_bind_none_fields_from_page_data_suppression`.
//! - `TrimStaticXfafExcessPagesWhenLayoutIsSinglePageAndFormDomAllows`
//!   (M5.2; brief alias *StaticXfafExcessPageTrimWithFormDomGuard*) —
//!   `static_xfaf_excess_page_trim_with_form_dom_guard`.
//! - `IgnoreInvisibleServerMetadataBindingsForDataBoundSignal` (M5.2b) —
//!   `ignore_invisible_server_metadata_bindings`.
//! - `ExcludeNonDataWidgetsFromPageSuppression` (M5.2b) —
//!   `exclude_non_data_widgets_from_page_suppression`.
//! - `RepeatingSubformInstanceCountClampedToOccurRange` (M5.3) —
//!   `repeating_subform_instance_count_clamped_to_occur_range`.
//!
//! Every documented Adobe-compat rule in the registry has an executable
//! companion.

use xfa_layout_engine::trace::{sites as trace_sites, Reason};

use super::registry::AdobeCompatRuleId;

/// Result of the `CapSuppressionByFormDomPageAreaCount` rule.
///
/// Encodes how many pages page-suppression may drop while preserving the
/// invariant that the surviving page count is at least the form DOM's
/// declared page-area count.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CapDecision {
    /// Upper bound on the number of pages page-suppression may drop.
    ///
    /// `usize::MAX` when the form DOM provides no guidance (uncapped).
    pub max_suppress: usize,
    /// Stable rule identifier for trace and telemetry consumers.
    pub rule: AdobeCompatRuleId,
}

impl CapDecision {
    /// Constructor for the uncapped (no form-DOM guidance) case.
    pub const fn uncapped() -> Self {
        Self {
            max_suppress: usize::MAX,
            rule: AdobeCompatRuleId::CapSuppressionByFormDomPageAreaCount,
        }
    }

    /// Constructor for a numerically capped value.
    pub const fn capped_at(max_suppress: usize) -> Self {
        Self {
            max_suppress,
            rule: AdobeCompatRuleId::CapSuppressionByFormDomPageAreaCount,
        }
    }
}

/// Apply rule `cap_suppression_by_form_dom_page_area_count`.
///
/// Inputs:
///
/// - `layout_pages` — the number of pages the layout engine produced
///   before suppression runs.
/// - `form_dom_page_count` — the number of `<pageArea>` elements found in
///   the saved form DOM, when present. `None` means the PDF carries no
///   form DOM (no Adobe Reader saved-state), so the cap does not apply.
///
/// Output:
///
/// - `max_suppress` — upper bound on the count of pages the suppression
///   loop may drop. The loop is responsible for comparing
///   `suppressed < max_suppress` before each drop.
///
/// ## Trace anchor
///
/// When the form-DOM cap fires (i.e. `form_dom_page_count` is `Some` and
/// would constrain suppression), the function emits one
/// `(suppress, SuppressCappedByFormDom)` event with the inputs and the
/// resulting `max_suppress`. When the rule is inactive (uncapped, no
/// form DOM, or already under-paginated), no event is emitted — silence
/// is the trace anchor that says "rule did not fire".
///
/// ## Behaviour preservation
///
/// The decision is bit-for-bit identical to the prior inline match in
/// `pdf_xfa::flatten`: this function is a refactor, not a fix. The
/// regression guard `corpus_322faac4_seventeen_pages` continues to
/// produce the same page count after the migration.
pub fn cap_suppression_by_form_dom(
    layout_pages: usize,
    form_dom_page_count: Option<usize>,
) -> CapDecision {
    match form_dom_page_count {
        Some(target) if target < layout_pages => {
            let max = layout_pages - target;
            trace_sites::suppress(
                Reason::SuppressCappedByFormDom,
                u32::try_from(target).unwrap_or(u32::MAX),
                format!("form_dom_pages={target} layout_pages={layout_pages} max_suppress={max}"),
            );
            CapDecision::capped_at(max)
        }
        Some(target) => {
            // Form DOM declares >= layout pages — already at or above the
            // target. Suppressing further would widen the gap. The cap is
            // effectively 0; emit the trace anchor so consumers can prove
            // the rule was consulted.
            trace_sites::suppress(
                Reason::SuppressCappedByFormDom,
                u32::try_from(target).unwrap_or(u32::MAX),
                format!("form_dom_pages={target} layout_pages={layout_pages} max_suppress=0"),
            );
            CapDecision::capped_at(0)
        }
        None => CapDecision::uncapped(),
    }
}

// ── M5.2 — SuppressEmptyPagesOnlyWhenRealDataBound ─────────────────────────

/// Result of the `SuppressEmptyPagesOnlyWhenRealDataBound` preflight rule.
///
/// Encodes whether the page-suppression heuristic is allowed to run on this
/// flatten. The rule fires once, before the per-page scan.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SuppressPreflightDecision {
    /// `true` when suppression is allowed to inspect pages and drop the
    /// data-empty ones; `false` when no real data binding occurred and
    /// every template page must be preserved.
    pub run_suppression: bool,
    /// Stable rule identifier.
    pub rule: AdobeCompatRuleId,
}

/// Apply rule `suppress_empty_pages_only_when_real_data_bound`.
///
/// Inputs:
///
/// - `layout_pages` — number of pages the layout engine produced. The
///   suppression heuristic only runs when more than one page exists (a
///   one-page form has nothing to drop).
/// - `any_data_bound` — whether `FormMerger` bound at least one visible
///   data field from the DataDom during the bind phase. When false, the
///   "data-empty page" signal is meaningless because every field's value
///   comes from a template default.
///
/// Output:
///
/// - `run_suppression` — `true` iff `layout_pages > 1 && any_data_bound`.
///
/// ## Trace anchor
///
/// Emits exactly one event:
///
/// - `(suppress, suppress_gated_by_data_bound_signal)` when the rule
///   allows the heuristic to run.
/// - `(suppress, suppress_skipped_no_data_bound_signal)` when the rule
///   blocks the heuristic (one-page form or no real data binding).
///
/// ## Behaviour preservation
///
/// The decision is bit-for-bit identical to the inline guard
/// `if layout.pages.len() > 1 && tree.any_data_bound { … }` previously
/// living in `pdf_xfa::flatten`. Corpus regression guards (e.g.
/// `bind_none_page_is_not_dropped`, `corpus_322faac4_seventeen_pages`)
/// continue to produce the same page counts after the migration.
pub fn suppress_empty_pages_only_when_real_data_bound(
    layout_pages: usize,
    any_data_bound: bool,
) -> SuppressPreflightDecision {
    let run = layout_pages > 1 && any_data_bound;
    let reason = if run {
        Reason::SuppressGatedByDataBoundSignal
    } else {
        Reason::SuppressSkippedNoDataBoundSignal
    };
    trace_sites::suppress(
        reason,
        u32::try_from(layout_pages).unwrap_or(u32::MAX),
        format!(
            "layout_pages={layout_pages} any_data_bound={any_data_bound} run_suppression={run}"
        ),
    );
    SuppressPreflightDecision {
        run_suppression: run,
        rule: AdobeCompatRuleId::SuppressEmptyPagesOnlyWhenRealDataBound,
    }
}

// ── M5.2 — ExcludeBindNoneFieldsFromPageDataSuppression ────────────────────

/// A field's classification under the bind-none rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindNoneClassification {
    /// Field counts as a data field for page-suppression purposes.
    DataField,
    /// Field has `<bind match="none">` and is excluded from the
    /// page data-field count. Static template defaults only.
    ExcludedBindNone,
    /// Field is a non-data widget (signature, button, barcode) — handled
    /// by the sibling rule `ExcludeNonDataWidgetsFromPageSuppression`.
    /// This rule does not own that decision but reports it for caller
    /// convenience so the page-scan can short-circuit.
    ExcludedNonDataWidget,
}

/// Apply rule `exclude_bind_none_fields_from_page_data_suppression` to a
/// single field's metadata snapshot.
///
/// Inputs:
///
/// - `is_field_node` — true when the FormTree node is `FormNodeType::Field`;
///   otherwise the rule is not consulted (returns `DataField` so the
///   caller's recursion proceeds, but no field-level decision is made —
///   non-fields are filtered by the outer loop).
/// - `is_non_data_widget` — true for signature / button / barcode field
///   kinds (handled by the sibling rule; surfaced here for clarity).
/// - `data_bind_none` — value of `FormNodeMeta::data_bind_none`.
///
/// Output:
///
/// - `BindNoneClassification`.
///
/// ## Trace anchor
///
/// This rule fires per field; emitting on every call would flood the
/// sink. The rule itself stays silent. Callers are expected to maintain a
/// per-flatten counter and emit one summary event
/// `(suppress, bind_none_field_excluded_from_data_check)` with the total
/// exclusion count once the page scan completes — that summary lives in
/// `pdf_xfa::flatten`. Tests verify both halves.
///
/// ## Behaviour preservation
///
/// Equivalent to the inline expression in `page_has_fields` (`flatten.rs`):
///
/// ```text
/// matches!(node.node_type, FormNodeType::Field { .. })
///     && !matches!(meta.field_kind, Signature | Button | Barcode)
///     && !meta.data_bind_none
/// ```
///
/// `DataField` ⇔ that expression is `true`; otherwise it is `false`.
pub fn exclude_bind_none_fields_from_page_data_suppression(
    is_field_node: bool,
    is_non_data_widget: bool,
    data_bind_none: bool,
) -> BindNoneClassification {
    if !is_field_node {
        // Caller invariant: only consulted for field nodes. Treat
        // non-fields as "data" so the recursive walk falls through to
        // children (matches the original `is_data_field || …` shape).
        return BindNoneClassification::DataField;
    }
    if is_non_data_widget {
        return BindNoneClassification::ExcludedNonDataWidget;
    }
    if data_bind_none {
        return BindNoneClassification::ExcludedBindNone;
    }
    BindNoneClassification::DataField
}

/// Emit the once-per-flatten summary event for rule
/// `ExcludeBindNoneFieldsFromPageDataSuppression`.
///
/// Caller pattern (from `pdf_xfa::flatten`):
///
/// 1. Initialise a counter to zero.
/// 2. For each field inspected during the page-suppression scan,
///    increment when [`exclude_bind_none_fields_from_page_data_suppression`]
///    returns `ExcludedBindNone`.
/// 3. After the scan, call [`emit_bind_none_summary`] with the count.
///
/// The summary fires only when at least one exclusion occurred — silence
/// is the trace anchor for "rule did not fire on this document".
pub fn emit_bind_none_summary(excluded_count: usize) {
    if excluded_count == 0 {
        return;
    }
    trace_sites::suppress(
        Reason::BindNoneFieldExcludedFromDataCheck,
        u32::try_from(excluded_count).unwrap_or(u32::MAX),
        format!("excluded_count={excluded_count}"),
    );
}

// ── M5.2 — StaticXfafExcessPageTrimWithFormDomGuard ────────────────────────
//
// The brief calls this rule "StaticXfafExcessPageTrimWithFormDomGuard". In
// the registry it is named
// `TrimStaticXfafExcessPagesWhenLayoutIsSinglePageAndFormDomAllows`
// (already present, locked by the pinned-summary test). Same behaviour,
// two names. The migration uses the existing registry RuleId so the
// pinned summary stays stable.

/// Result of the static-XFAF excess-trim rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StaticTrimDecision {
    /// `true` when surplus host pages may be deleted from the host PDF
    /// after layout; `false` when the form-DOM guard blocks the trim.
    pub allow_trim: bool,
    /// Whether the conservative "single-page collapse" relaxation
    /// applies (only meaningful when `allow_trim` is true).
    pub single_page_collapse: bool,
    /// Stable rule identifier.
    pub rule: AdobeCompatRuleId,
}

/// Apply rule `static_xfaf_excess_page_trim_with_form_dom_guard`.
///
/// Inputs:
///
/// - `is_static_form` — `baseProfile="interactiveForms"` was detected on
///   the template (XFA-F §7.6).
/// - `template_has_dynamic_logic` — template contains `<script>` or a
///   FormCalc content type.
/// - `n_layout` — number of pages the layout engine produced.
/// - `form_dom_page_count` — `<pageArea>` count from the saved form DOM,
///   or `None` if no form DOM is present.
///
/// Output:
///
/// - `allow_trim` — true iff `is_static_form` AND the form-DOM guard
///   permits trimming AND (form DOM is absent OR `fdp <= n_layout` OR
///   the single-page-collapse relaxation fires).
///
/// ## Trace anchor
///
/// - `(suppress, static_xfaf_trim_allowed)` when the rule allows the
///   trim. The phase is `suppress` rather than `emit` because trimming
///   removes pages from the output, matching the M1 trace vocabulary's
///   page-suppression semantics. The decision string records which
///   branch fired.
/// - `(suppress, static_xfaf_trim_blocked)` when the form DOM (or the
///   dynamic-template hint) blocks the trim. Useful for documenting that
///   the rule was consulted and chose not to fire.
///
/// Silence (returned `allow_trim=false`) only occurs when the form is
/// not static at all — the rule short-circuits without consulting any
/// guard. That branch emits nothing because the rule is structurally
/// inapplicable.
///
/// ## Behaviour preservation
///
/// The decision is bit-identical to the prior inline expression in
/// `pdf_xfa::flatten`:
///
/// ```text
/// let template_has_dynamic_logic = template_xml.contains("<script")
///     || template_xml.contains(r#"contentType=\"application/x-formcalc\""#);
/// let static_collapses_to_one_page =
///     is_static_form && !template_has_dynamic_logic && n_layout == 1;
/// let static_can_trim = is_static_form
///     && match form_dom_pages {
///         Some(fdp) => fdp <= n_layout || static_collapses_to_one_page,
///         None => true,
///     };
/// ```
pub fn static_xfaf_excess_page_trim_with_form_dom_guard(
    is_static_form: bool,
    template_has_dynamic_logic: bool,
    n_layout: usize,
    form_dom_page_count: Option<usize>,
) -> StaticTrimDecision {
    let rule_id =
        AdobeCompatRuleId::TrimStaticXfafExcessPagesWhenLayoutIsSinglePageAndFormDomAllows;
    if !is_static_form {
        // Rule structurally inapplicable — not a static XFAF form.
        return StaticTrimDecision {
            allow_trim: false,
            single_page_collapse: false,
            rule: rule_id,
        };
    }
    let single_page_collapse = !template_has_dynamic_logic && n_layout == 1;
    // Phase 2 Wave 9 — `None`-arm hardened to `false`. Wave 7 changed
    // the `None` arm from unconditional `true` to `n_layout == 1`
    // because the M7.1 case `corpus_7dbbe9d9_static_xfaf_one_page`
    // appeared to require trim when the layout collapsed to a single
    // page. Wave 9 re-checked 7dbbe9d9 against the corpus manifest
    // and the pdfRest oracle:
    //
    //   - corpus manifest: `oracle_page_count = 2`, `page_count_input = 2`
    //   - pdfRest mirror : both `pdfrest_page-1.png` and
    //                       `pdfrest_page-2.png` exist for 7dbbe9d9
    //
    // The M7.1 case was authored to pin the engine's then-current
    // behaviour, not the oracle truth. Across **all four** static-
    // XFAF docs that hit `form_dom == None && n_layout == 1`
    // (01bf93cc, 0b86389a, 141f12df, 7dbbe9d9), pdfRest preserves
    // host PDF pages 2..N. The "stale placeholder" assumption that
    // motivated the Wave 7 single-page exception was empirically
    // wrong.
    //
    // With no form DOM to corroborate trim intent, the safe default
    // is to **preserve every host page**. The rule still trims when
    // an explicit form DOM declares fewer pages than the host PDF
    // carries (the `Some(fdp)` arm is unchanged).
    //
    // M7.1's `corpus_7dbbe9d9_static_xfaf_one_page` GateCase has been
    // updated to align with the corpus manifest + pdfRest oracle:
    // expected page count is now 2 and the rule expectation is
    // `static_xfaf_trim_blocked must_fire=true` rather than `_allowed`.
    let allow_trim = match form_dom_page_count {
        Some(fdp) => fdp <= n_layout || single_page_collapse,
        None => false,
    };
    let reason = if allow_trim {
        Reason::StaticXfafTrimAllowed
    } else {
        Reason::StaticXfafTrimBlocked
    };
    trace_sites::suppress(
        reason,
        u32::try_from(n_layout).unwrap_or(u32::MAX),
        format!(
            "is_static={is_static_form} dynamic_logic={template_has_dynamic_logic} n_layout={n_layout} form_dom={form_dom_page_count:?} single_page_collapse={single_page_collapse} allow_trim={allow_trim}"
        ),
    );
    StaticTrimDecision {
        allow_trim,
        single_page_collapse,
        rule: rule_id,
    }
}

// ── M5.2b — IgnoreInvisibleServerMetadataBindingsForDataBoundSignal ────────

/// Decision returned by the invisible-binding rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InvisibleBindingDecision {
    /// Field is visible (or `presence` attribute is absent / unknown).
    /// Binding sets the global `any_data_bound` signal.
    AcceptedVisible,
    /// Field's `presence` attribute is `invisible`, `hidden`, or
    /// `inactive`. The binding does NOT set the global data-bound
    /// signal — Acrobat treats such bindings as server-infrastructure
    /// metadata, not real user input.
    IgnoredInvisible,
}

/// Apply rule `ignore_invisible_server_metadata_bindings_for_data_bound_signal`.
///
/// Inputs:
///
/// - `presence_attr` — raw value of the XFA `presence` attribute. Empty
///   string when the attribute is absent.
///
/// Output:
///
/// - `IgnoredInvisible` when `presence_attr` is one of
///   `"invisible" | "hidden" | "inactive"`.
/// - `AcceptedVisible` otherwise.
///
/// The function itself is silent on the trace channel; the per-field
/// outcome is too chatty for a meaningful anchor. Callers accumulate a
/// per-merge counter and call [`emit_invisible_binding_summary`] once
/// `merge()` finishes. The summary is the rule's trace anchor.
///
/// ## Behaviour preservation
///
/// Equivalent to the inline expression in `pdf_xfa::merger::parse_field`:
///
/// ```text
/// let presence = attr(elem, "presence").unwrap_or("");
/// if !matches!(presence, "invisible" | "hidden" | "inactive") {
///     self.form_tree.any_data_bound = true;
/// }
/// ```
///
/// The rule returns `AcceptedVisible` iff the inline `!matches!(…)`
/// branch fires.
pub fn ignore_invisible_server_metadata_bindings(presence_attr: &str) -> InvisibleBindingDecision {
    if matches!(presence_attr, "invisible" | "hidden" | "inactive") {
        InvisibleBindingDecision::IgnoredInvisible
    } else {
        InvisibleBindingDecision::AcceptedVisible
    }
}

/// Emit the once-per-merge summary event for rule
/// `IgnoreInvisibleServerMetadataBindingsForDataBoundSignal`.
///
/// Caller pattern (from `pdf_xfa::merger::FormMerger::merge`):
///
/// 1. Initialise a counter to zero.
/// 2. For each field whose `presence` is checked during the merge,
///    increment when [`ignore_invisible_server_metadata_bindings`]
///    returns `IgnoredInvisible`.
/// 3. Just before returning from `merge`, call
///    [`emit_invisible_binding_summary`] with the count.
///
/// Silence on zero count means the rule did not fire.
pub fn emit_invisible_binding_summary(ignored_count: usize) {
    if ignored_count == 0 {
        return;
    }
    trace_sites::bind(
        "root",
        Reason::InvisibleFieldBindingIgnored,
        format!("ignored_count={ignored_count}"),
    );
}

// ── M5.2b — ExcludeNonDataWidgetsFromPageSuppression ───────────────────────

/// Classification under the non-data-widget rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WidgetClassification {
    /// Field carries a user-editable data value — counts as a data
    /// field for page-suppression purposes.
    DataField,
    /// Field is a signature, button, or barcode widget — Acrobat
    /// treats it as structural. Excluded from the per-page data-field
    /// count so a signature-only page is never dropped as
    /// "data-empty".
    ExcludedNonDataWidget,
}

/// Apply rule `exclude_non_data_widgets_from_page_suppression`.
///
/// Inputs:
///
/// - `field_kind_is_non_data_widget` — `true` when the field's
///   `FieldKind` is `Signature`, `Button`, or `Barcode`. Caller
///   computes this boolean to avoid the rule taking a dependency on the
///   `xfa-layout-engine::form::FieldKind` enum.
///
/// Output:
///
/// - `ExcludedNonDataWidget` when the field is a non-data widget.
/// - `DataField` otherwise.
///
/// Silent per-call; caller emits the per-flatten summary via
/// [`emit_non_data_widget_summary`].
///
/// ## Behaviour preservation
///
/// Equivalent to the inline expression in `pdf_xfa::flatten::page_has_fields`:
///
/// ```text
/// matches!(meta.field_kind, FieldKind::Signature | FieldKind::Button | FieldKind::Barcode)
/// ```
///
/// The rule's `ExcludedNonDataWidget` outcome corresponds to the
/// inline expression being `true`.
pub fn exclude_non_data_widgets_from_page_suppression(
    field_kind_is_non_data_widget: bool,
) -> WidgetClassification {
    if field_kind_is_non_data_widget {
        WidgetClassification::ExcludedNonDataWidget
    } else {
        WidgetClassification::DataField
    }
}

/// Emit the once-per-flatten summary event for rule
/// `ExcludeNonDataWidgetsFromPageSuppression`. Silent on zero count.
pub fn emit_non_data_widget_summary(excluded_count: usize) {
    if excluded_count == 0 {
        return;
    }
    trace_sites::suppress(
        Reason::NonDataWidgetExcludedFromDataCheck,
        u32::try_from(excluded_count).unwrap_or(u32::MAX),
        format!("excluded_count={excluded_count}"),
    );
}

// ── M5.3 — RepeatingSubformInstanceCountClampedToOccurRange ────────────────

/// Outcome of the occur-range clamp rule on a single repeating-subform
/// expansion.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OccurClampOutcome {
    /// `data_count > occur.max` → instance count was reduced to `occur.max`.
    ClampedByMax,
    /// `data_count < occur.min` → instance count was raised to `occur.min`.
    LiftedByMin,
    /// `occur.min <= data_count <= occur.max` (or `occur.max` was
    /// `None`/`-1` and `data_count >= occur.min`) — the rule returned
    /// the data-driven count unchanged.
    PassthroughInRange,
}

/// Apply rule `repeating_subform_instance_count_clamped_to_occur_range`.
///
/// Inputs:
///
/// - `data_count` — number of dataset records matched for the repeating
///   subform (after SOM resolution).
/// - `occur_min` — value of the template's `<occur min="…">` attribute
///   (defaults to 1 per XFA §4.4.3).
/// - `occur_max` — value of `<occur max="…">`. `None` represents
///   `max="-1"` (unbounded); any concrete value is the cap.
///
/// Output:
///
/// - `(count, outcome)` — `count` is the final instance count to
///   materialise. `outcome` reports which branch fired.
///
/// ## Trace anchor
///
/// Per-call. Reuses the pre-existing M1.5 occur-phase Reason tags:
///
/// - `(occur, data_count_clamped_by_occur_max)` when the cap fires.
/// - `(occur, data_count_lifted_by_occur_min)` when the floor fires.
/// - Silent when the rule passes the data count through unchanged.
///
/// Silence on the pass-through branch is intentional: the M1.5
/// vocabulary has no "in-range" reason variant, and the rule's value
/// signal is precisely when it deviates from the data-driven count.
///
/// ## Behaviour preservation
///
/// Bit-identical to the inline expression in `pdf_xfa::merger::expand_repeating_subform_instances`:
///
/// ```text
/// let min = occur.min;
/// let max = occur.max.unwrap_or(data_count).max(min);
/// let count = data_count.clamp(min, max);
/// ```
///
/// Regression guards `merger::tests::repeating_subform_clamps_to_occur_max`,
/// `merger::tests::repeating_subform_unbounded_uses_data_count`, and
/// `merger::tests::repeating_subform_respects_occur_min` continue to
/// produce their original instance counts.
pub fn repeating_subform_instance_count_clamped_to_occur_range(
    som: &str,
    data_count: u32,
    occur_min: u32,
    occur_max: Option<u32>,
) -> (u32, OccurClampOutcome) {
    let effective_max = occur_max.unwrap_or(data_count).max(occur_min);
    let count = data_count.clamp(occur_min, effective_max);

    let outcome = if data_count > effective_max {
        OccurClampOutcome::ClampedByMax
    } else if data_count < occur_min {
        OccurClampOutcome::LiftedByMin
    } else {
        OccurClampOutcome::PassthroughInRange
    };

    match outcome {
        OccurClampOutcome::ClampedByMax => {
            trace_sites::occur(som, Reason::DataCountClampedByOccurMax, count as i64);
        }
        OccurClampOutcome::LiftedByMin => {
            trace_sites::occur(som, Reason::DataCountLiftedByOccurMin, count as i64);
        }
        OccurClampOutcome::PassthroughInRange => {
            // Silent on pass-through; the rule had no effect.
        }
    }

    (count, outcome)
}

// ── M5.3b — BindNoneSubformDoesNotAutoExpand ───────────────────────────────

/// Decision returned by the bind-none occur rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindNoneExpansionGate {
    /// Subform has `<bind match="none">`. The rule blocks dataset-driven
    /// expansion; the caller must keep the subform as a single template
    /// instance.
    Blocked,
    /// Subform does not have `<bind match="none">`. The rule allows
    /// dataset-driven expansion. Other gates (name empty, non-repeating
    /// occur) may still cause the caller to skip expansion.
    Allowed,
}

/// Apply rule `bind_none_subform_does_not_auto_expand`.
///
/// Inputs:
///
/// - `som` — SOM path / name of the subform being inspected (used as
///   the trace event's `som` field).
/// - `occur_is_repeating` — whether `<occur>` permits multiple
///   instances (`max > 1` or `max = -1`).
/// - `name_is_empty` — whether the subform's `name` attribute is empty.
///   An unnamed subform is never auto-expanded regardless of this rule.
/// - `bind_none` — whether the subform carries `<bind match="none">`.
///
/// Output:
///
/// - `Blocked` when `bind_none == true`; `Allowed` otherwise.
///
/// ## Trace anchor
///
/// `(occur, bind_none_subform_expansion_skipped)` fires per subform
/// **only when the rule actively prevents expansion** that would
/// otherwise have run — i.e. `bind_none && occur_is_repeating && !name_is_empty`.
/// The rule stays silent when:
///
/// - `bind_none == false` (rule structurally inapplicable).
/// - `bind_none == true` but `occur` is not repeating, or `name` is empty
///   (rule applied but had no observable effect because the caller
///   would have skipped expansion anyway).
///
/// ## Behaviour preservation
///
/// The caller's existing guard
/// `if occur.is_repeating() && !name.is_empty() && !bind_none { … }`
/// (`crates/pdf-xfa/src/merger.rs`, child-iteration loop) is replaced
/// by `gate == Allowed && occur.is_repeating() && !name.is_empty()`.
/// The defensive backstop inside `expand_repeating_subform_instances`
/// (early-return when `bind_none == true`) is preserved unchanged; it
/// can no longer fire on the standard call path, but the inside-out
/// check remains as a hardening guard.
pub fn bind_none_subform_does_not_auto_expand(
    som: &str,
    occur_is_repeating: bool,
    name_is_empty: bool,
    bind_none: bool,
) -> BindNoneExpansionGate {
    if bind_none {
        if occur_is_repeating && !name_is_empty {
            // The rule actively prevents an expansion that would have
            // produced multiple instances. Emit the trace anchor.
            trace_sites::occur(som, Reason::BindNoneSubformExpansionSkipped, 1);
        }
        BindNoneExpansionGate::Blocked
    } else {
        BindNoneExpansionGate::Allowed
    }
}

// ── M5.3d — FormDomDrivenRepeatInstanceReplication ─────────────────────────

/// Decision returned by the form-DOM-driven repeat replication rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FormDomReplicationDecision {
    /// Number of template instances to clone to match the form DOM's
    /// recorded instance count. Zero when the form DOM does not request
    /// additional instances (form DOM count `<=` template default).
    pub clones_to_add: usize,
}

/// Apply rule `form_dom_driven_repeat_instance_replication`.
///
/// Inputs:
///
/// - `som` — SOM-style name / SOM path of the repeating subform group.
/// - `form_dom_instance_count` — number of `<subform name="X">` siblings
///   recorded in the saved form DOM for this name.
/// - `template_default_count` — number of instances the template's
///   initial expansion produced (data-driven count after `<occur>`
///   clamping).
///
/// Output:
///
/// - `clones_to_add` = `max(0, form_dom_count - template_default_count)`
///   when `template_default_count > 0`. Returns 0 when the template
///   produced no instance at all (the caller's existing guard) or when
///   the form DOM does not exceed the default count.
///
/// ## Trace anchor
///
/// `(occur, subform_materialised_from_data)` fires when the rule adds
/// at least one clone, with `count` set to the *final* instance count
/// (`form_dom_instance_count`). This reuses the existing M1.5 Reason
/// tag — the rule reports each "instance materialised from form-DOM
/// data" outcome under the canonical XFA Reason vocabulary. Silence
/// on zero-clones is intentional: the rule had no value-add over the
/// template default.
///
/// ## Behaviour preservation
///
/// Bit-identical to the inline expression in
/// `pdf_xfa::flatten::apply_form_dom_presence`:
///
/// ```text
/// if xml_count > existing_count && existing_count > 0 {
///     let clones_needed = xml_count - existing_count;
///     // … clone subtree clones_needed times …
///     trace_sites::occur(gname, SubformMaterialisedFromData, xml_count);
/// }
/// ```
///
/// The rule wraps the `if`/`clones_needed`/trace block into a typed
/// decision. The caller still owns the actual `clone_subtree` /
/// `form_children.insert(…)` mutations.
pub fn form_dom_driven_repeat_instance_replication(
    som: &str,
    form_dom_instance_count: usize,
    template_default_count: usize,
) -> FormDomReplicationDecision {
    let clones_to_add =
        if form_dom_instance_count > template_default_count && template_default_count > 0 {
            form_dom_instance_count - template_default_count
        } else {
            0
        };

    if clones_to_add > 0 {
        trace_sites::occur(
            som,
            Reason::SubformMaterialisedFromData,
            form_dom_instance_count as i64,
        );
    }

    FormDomReplicationDecision { clones_to_add }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::rc::Rc;
    use xfa_layout_engine::trace::{with_sink, RecordingSink};

    #[test]
    fn no_form_dom_is_uncapped() {
        let decision = cap_suppression_by_form_dom(7, None);
        assert_eq!(decision.max_suppress, usize::MAX);
        assert_eq!(
            decision.rule,
            AdobeCompatRuleId::CapSuppressionByFormDomPageAreaCount
        );
    }

    #[test]
    fn form_dom_smaller_than_layout_caps_to_difference() {
        // Positive example doc 322faac4: form DOM declares 17 pages, layout
        // produced 7 — the cap fires at max_suppress = 17 - 7? No: the cap
        // semantics are "form_dom_pages < layout_pages", so this branch is
        // the inverse: form DOM declares fewer pages than layout.
        let decision = cap_suppression_by_form_dom(10, Some(7));
        assert_eq!(decision.max_suppress, 3);
    }

    #[test]
    fn form_dom_at_or_above_layout_caps_to_zero() {
        // The 322faac4 case: form DOM declares 17, layout produced 7 — the
        // rule's "otherwise" branch fires and caps at 0 so suppression
        // never reduces the page count further.
        let decision = cap_suppression_by_form_dom(7, Some(17));
        assert_eq!(decision.max_suppress, 0);
    }

    #[test]
    fn form_dom_equal_to_layout_caps_to_zero() {
        let decision = cap_suppression_by_form_dom(7, Some(7));
        assert_eq!(decision.max_suppress, 0);
    }

    #[test]
    fn trace_anchor_fires_when_form_dom_present() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = cap_suppression_by_form_dom(7, Some(17));
        });
        let events = sink.events();
        assert_eq!(events.len(), 1, "expected one trace event");
        assert_eq!(events[0].phase.tag(), "suppress");
        assert_eq!(events[0].reason.tag(), "suppress_capped_by_form_dom");
        let decision = events[0].decision.as_deref().unwrap_or("");
        assert!(
            decision.contains("form_dom_pages=17"),
            "decision should report form_dom_pages: {decision}"
        );
        assert!(
            decision.contains("max_suppress=0"),
            "decision should report max_suppress: {decision}"
        );
    }

    #[test]
    fn trace_anchor_silent_when_no_form_dom() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = cap_suppression_by_form_dom(7, None);
        });
        assert!(
            sink.events().is_empty(),
            "rule must not emit when form DOM is absent"
        );
    }

    // ── SuppressEmptyPagesOnlyWhenRealDataBound ────────────────────────

    #[test]
    fn suppress_preflight_allows_when_multi_page_and_data_bound() {
        let d = suppress_empty_pages_only_when_real_data_bound(7, true);
        assert!(d.run_suppression);
        assert_eq!(
            d.rule,
            AdobeCompatRuleId::SuppressEmptyPagesOnlyWhenRealDataBound
        );
    }

    #[test]
    fn suppress_preflight_blocks_when_no_data_bound() {
        // Bind-only template (no datasets) — XDP_BIND_NONE_… shape:
        // suppression must not run even with multi-page layout.
        let d = suppress_empty_pages_only_when_real_data_bound(7, false);
        assert!(!d.run_suppression);
    }

    #[test]
    fn suppress_preflight_blocks_when_single_page() {
        let d = suppress_empty_pages_only_when_real_data_bound(1, true);
        assert!(!d.run_suppression);
    }

    #[test]
    fn suppress_preflight_trace_fires_on_both_branches() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = suppress_empty_pages_only_when_real_data_bound(7, true);
            let _ = suppress_empty_pages_only_when_real_data_bound(7, false);
        });
        let events = sink.events();
        assert_eq!(events.len(), 2);
        assert_eq!(
            events[0].reason.tag(),
            "suppress_gated_by_data_bound_signal"
        );
        assert_eq!(
            events[1].reason.tag(),
            "suppress_skipped_no_data_bound_signal"
        );
    }

    // ── ExcludeBindNoneFieldsFromPageDataSuppression ───────────────────

    #[test]
    fn bind_none_classifies_regular_field_as_data() {
        let c = exclude_bind_none_fields_from_page_data_suppression(true, false, false);
        assert_eq!(c, BindNoneClassification::DataField);
    }

    #[test]
    fn bind_none_classifies_bind_none_field_as_excluded() {
        let c = exclude_bind_none_fields_from_page_data_suppression(true, false, true);
        assert_eq!(c, BindNoneClassification::ExcludedBindNone);
    }

    #[test]
    fn bind_none_classifies_non_data_widget_first() {
        // Signature/button/barcode short-circuits before bind=none. The
        // classification is `ExcludedNonDataWidget` regardless of
        // `data_bind_none`.
        let c = exclude_bind_none_fields_from_page_data_suppression(true, true, true);
        assert_eq!(c, BindNoneClassification::ExcludedNonDataWidget);
    }

    #[test]
    fn bind_none_summary_silent_when_count_zero() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            emit_bind_none_summary(0);
        });
        assert!(
            sink.events().is_empty(),
            "summary must be silent when no field was excluded"
        );
    }

    #[test]
    fn bind_none_summary_fires_when_count_positive() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            emit_bind_none_summary(3);
        });
        let events = sink.events();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].phase.tag(), "suppress");
        assert_eq!(
            events[0].reason.tag(),
            "bind_none_field_excluded_from_data_check"
        );
        let decision = events[0].decision.as_deref().unwrap_or("");
        assert!(
            decision.contains("excluded_count=3"),
            "summary must report exclusion count: {decision}"
        );
    }

    // ── StaticXfafExcessPageTrimWithFormDomGuard ───────────────────────

    #[test]
    fn static_trim_short_circuits_when_not_static() {
        let d = static_xfaf_excess_page_trim_with_form_dom_guard(
            /*is_static_form=*/ false, /*template_has_dynamic_logic=*/ false,
            /*n_layout=*/ 5, /*form_dom=*/ None,
        );
        assert!(!d.allow_trim);
        assert!(!d.single_page_collapse);
    }

    #[test]
    fn static_trim_blocked_when_no_form_dom_present() {
        // Phase 2 Wave 9: when no form DOM is present, the rule must
        // never trim host pages — host PDF pages are authoritative
        // without a saved form DOM to corroborate trim intent. The
        // function still emits the `static_xfaf_trim_blocked` trace
        // anchor so consumers can prove the rule was consulted.
        //
        // Replaces Wave 7's `n_layout == 1` floor. Wave 7's exception
        // was authored to preserve M7.1's
        // `corpus_7dbbe9d9_static_xfaf_one_page` expectation
        // (expected_pages=1, _allowed must_fire=true). Wave 9
        // re-checked 7dbbe9d9 against the corpus manifest and the
        // pdfRest oracle (both say 2 pages) and concluded the M7.1
        // expectation was misaligned with the oracle truth; the
        // M7.1 GateCase has been updated to expected_pages=2 +
        // _blocked must_fire=true.
        for n_layout in [1_usize, 2, 3, 5, 11] {
            let d_static =
                static_xfaf_excess_page_trim_with_form_dom_guard(true, false, n_layout, None);
            assert!(
                !d_static.allow_trim,
                "n_layout={n_layout} (static, no form DOM): expected no trim, got allow_trim=true"
            );
            let d_dynamic =
                static_xfaf_excess_page_trim_with_form_dom_guard(true, true, n_layout, None);
            assert!(
                !d_dynamic.allow_trim,
                "n_layout={n_layout} (dynamic-logic substring, no form DOM): expected no trim"
            );
        }
    }

    #[test]
    fn static_trim_allows_when_form_dom_matches_or_under() {
        // Form DOM declares <= n_layout pages — trim is safe.
        let d = static_xfaf_excess_page_trim_with_form_dom_guard(true, false, 3, Some(2));
        assert!(d.allow_trim);
    }

    #[test]
    fn static_trim_blocked_when_form_dom_demands_more_pages() {
        // 322faac4 shape: form DOM declares 17, layout produced 7. The
        // form DOM is authoritative; trim must not fire.
        let d = static_xfaf_excess_page_trim_with_form_dom_guard(true, false, 7, Some(17));
        assert!(!d.allow_trim);
    }

    #[test]
    fn static_trim_single_page_collapse_relaxation() {
        // fe5de953 shape: form DOM declares 2 pages, layout collapses to 1,
        // no dynamic logic in template — the narrow one-page-collapse
        // relaxation allows trim.
        let d = static_xfaf_excess_page_trim_with_form_dom_guard(true, false, 1, Some(2));
        assert!(d.allow_trim);
        assert!(d.single_page_collapse);
    }

    #[test]
    fn static_trim_single_page_collapse_blocked_by_dynamic_logic() {
        // Single-page layout but template has dynamic logic (script /
        // FormCalc): the relaxation does NOT apply, and the form DOM's
        // higher page count blocks the trim.
        let d = static_xfaf_excess_page_trim_with_form_dom_guard(true, true, 1, Some(2));
        assert!(!d.allow_trim);
    }

    #[test]
    fn static_trim_trace_anchor_fires_on_allow_and_block() {
        // After Wave 9 the `None` arm always blocks, so an "allow"
        // case must come from a `Some(fdp)` where `fdp <= n_layout`.
        // The "block" case stays as `Some(fdp) where fdp > n_layout`.
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = static_xfaf_excess_page_trim_with_form_dom_guard(true, false, 7, Some(2));
            let _ = static_xfaf_excess_page_trim_with_form_dom_guard(true, false, 7, Some(17));
        });
        let events = sink.events();
        assert_eq!(events.len(), 2);
        assert_eq!(events[0].reason.tag(), "static_xfaf_trim_allowed");
        assert_eq!(events[1].reason.tag(), "static_xfaf_trim_blocked");
    }

    #[test]
    fn static_trim_silent_when_rule_inapplicable() {
        // Not a static form — rule short-circuits without emitting.
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = static_xfaf_excess_page_trim_with_form_dom_guard(false, false, 3, Some(7));
        });
        assert!(sink.events().is_empty());
    }

    // ── IgnoreInvisibleServerMetadataBindingsForDataBoundSignal ────────

    #[test]
    fn invisible_binding_accepts_visible() {
        // Empty `presence` attribute → visible by default.
        assert_eq!(
            ignore_invisible_server_metadata_bindings(""),
            InvisibleBindingDecision::AcceptedVisible
        );
        // Explicit visible.
        assert_eq!(
            ignore_invisible_server_metadata_bindings("visible"),
            InvisibleBindingDecision::AcceptedVisible
        );
        // Unknown value falls through as visible — matches the inline
        // expression's `!matches!(…)` behaviour.
        assert_eq!(
            ignore_invisible_server_metadata_bindings("future-value"),
            InvisibleBindingDecision::AcceptedVisible
        );
    }

    #[test]
    fn invisible_binding_ignores_invisible_hidden_inactive() {
        for presence in ["invisible", "hidden", "inactive"] {
            assert_eq!(
                ignore_invisible_server_metadata_bindings(presence),
                InvisibleBindingDecision::IgnoredInvisible,
                "presence={presence} must be IgnoredInvisible"
            );
        }
    }

    #[test]
    fn invisible_binding_rule_per_call_silent() {
        // Per-field calls do not emit; only the summary helper does.
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = ignore_invisible_server_metadata_bindings("invisible");
            let _ = ignore_invisible_server_metadata_bindings("visible");
        });
        assert!(
            sink.events().is_empty(),
            "per-field decisions must not emit; summary helper is the trace anchor"
        );
    }

    #[test]
    fn invisible_binding_summary_silent_on_zero() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            emit_invisible_binding_summary(0);
        });
        assert!(sink.events().is_empty());
    }

    #[test]
    fn invisible_binding_summary_fires_on_positive() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            emit_invisible_binding_summary(4);
        });
        let events = sink.events();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].phase.tag(), "bind");
        assert_eq!(events[0].reason.tag(), "invisible_field_binding_ignored");
        assert!(events[0]
            .decision
            .as_deref()
            .unwrap_or("")
            .contains("ignored_count=4"));
    }

    // ── ExcludeNonDataWidgetsFromPageSuppression ───────────────────────

    #[test]
    fn non_data_widget_classifies_data_field() {
        let c = exclude_non_data_widgets_from_page_suppression(false);
        assert_eq!(c, WidgetClassification::DataField);
    }

    #[test]
    fn non_data_widget_classifies_widget() {
        let c = exclude_non_data_widgets_from_page_suppression(true);
        assert_eq!(c, WidgetClassification::ExcludedNonDataWidget);
    }

    #[test]
    fn non_data_widget_per_call_silent() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = exclude_non_data_widgets_from_page_suppression(true);
            let _ = exclude_non_data_widgets_from_page_suppression(false);
        });
        assert!(sink.events().is_empty());
    }

    #[test]
    fn non_data_widget_summary_silent_on_zero() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            emit_non_data_widget_summary(0);
        });
        assert!(sink.events().is_empty());
    }

    #[test]
    fn non_data_widget_summary_fires_on_positive() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            emit_non_data_widget_summary(2);
        });
        let events = sink.events();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].phase.tag(), "suppress");
        assert_eq!(
            events[0].reason.tag(),
            "non_data_widget_excluded_from_data_check"
        );
        assert!(events[0]
            .decision
            .as_deref()
            .unwrap_or("")
            .contains("excluded_count=2"));
    }

    // ── RepeatingSubformInstanceCountClampedToOccurRange ───────────────

    #[test]
    fn occur_clamp_passthrough_when_in_range_with_bounded_max() {
        // 2 data records, min=0, max=5 → in range, passthrough.
        let (count, outcome) = repeating_subform_instance_count_clamped_to_occur_range(
            "form1.Orders.Order",
            2,
            0,
            Some(5),
        );
        assert_eq!(count, 2);
        assert_eq!(outcome, OccurClampOutcome::PassthroughInRange);
    }

    #[test]
    fn occur_clamp_passthrough_when_unbounded() {
        // 5 data records, min=0, max=None (unbounded) → passthrough.
        let (count, outcome) =
            repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 5, 0, None);
        assert_eq!(count, 5);
        assert_eq!(outcome, OccurClampOutcome::PassthroughInRange);
    }

    #[test]
    fn occur_clamp_caps_when_data_exceeds_max() {
        // 5 data records, min=0, max=2 → ClampedByMax, count=2.
        let (count, outcome) =
            repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 5, 0, Some(2));
        assert_eq!(count, 2);
        assert_eq!(outcome, OccurClampOutcome::ClampedByMax);
    }

    #[test]
    fn occur_clamp_lifts_when_data_below_min() {
        // 1 data record, min=3, max=None → LiftedByMin, count=3.
        let (count, outcome) =
            repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 1, 3, None);
        assert_eq!(count, 3);
        assert_eq!(outcome, OccurClampOutcome::LiftedByMin);
    }

    #[test]
    fn occur_clamp_lifts_when_data_zero_and_min_one() {
        // 0 data records, min=1, max=None → LiftedByMin, count=1.
        let (count, outcome) =
            repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 0, 1, None);
        assert_eq!(count, 1);
        assert_eq!(outcome, OccurClampOutcome::LiftedByMin);
    }

    #[test]
    fn occur_clamp_max_below_min_still_honours_min() {
        // Pathological template: occur.max=0 but data_count=1 and min=1.
        // The effective_max is `max.unwrap_or(data_count).max(min)` =
        // `0.max(1)` = 1, so the count stays at 1 and the rule classifies
        // the input as PassthroughInRange (data_count is within
        // [min, effective_max]).
        let (count, outcome) =
            repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 1, 1, Some(0));
        assert_eq!(count, 1);
        assert_eq!(outcome, OccurClampOutcome::PassthroughInRange);
    }

    #[test]
    fn occur_clamp_trace_anchor_fires_clamped_by_max() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = repeating_subform_instance_count_clamped_to_occur_range(
                "Orders.Order",
                5,
                0,
                Some(2),
            );
        });
        let events = sink.events();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].phase.tag(), "occur");
        assert_eq!(events[0].reason.tag(), "data_count_clamped_by_occur_max");
        assert_eq!(events[0].som.as_deref(), Some("Orders.Order"));
        assert!(events[0].input.as_deref().unwrap_or("").contains("count=2"));
    }

    #[test]
    fn occur_clamp_trace_anchor_fires_lifted_by_min() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ =
                repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 1, 3, None);
        });
        let events = sink.events();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].reason.tag(), "data_count_lifted_by_occur_min");
        assert!(events[0].input.as_deref().unwrap_or("").contains("count=3"));
    }

    #[test]
    fn occur_clamp_trace_silent_on_passthrough() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = repeating_subform_instance_count_clamped_to_occur_range(
                "Orders.Order",
                2,
                0,
                Some(5),
            );
            let _ =
                repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 3, 0, None);
        });
        assert!(
            sink.events().is_empty(),
            "rule must be silent when data count is in range and not clamped"
        );
    }

    // ── BindNoneSubformDoesNotAutoExpand ───────────────────────────────

    #[test]
    fn bind_none_allows_when_bind_not_none() {
        let g = bind_none_subform_does_not_auto_expand("Orders.Order", true, false, false);
        assert_eq!(g, BindNoneExpansionGate::Allowed);
    }

    #[test]
    fn bind_none_blocks_when_bind_none() {
        let g = bind_none_subform_does_not_auto_expand("Orders.Order", true, false, true);
        assert_eq!(g, BindNoneExpansionGate::Blocked);
    }

    #[test]
    fn bind_none_blocks_even_when_unnamed() {
        // The rule still returns Blocked structurally; the caller's
        // structural gates (name empty, occur not repeating) are
        // separate concerns. The rule's outcome is only "is the
        // subform bind=none, yes or no".
        let g = bind_none_subform_does_not_auto_expand("Orders.Order", true, true, true);
        assert_eq!(g, BindNoneExpansionGate::Blocked);
    }

    #[test]
    fn bind_none_trace_fires_only_when_expansion_would_have_run() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            // Repeating + named + bind=none → rule actively blocks.
            let _ = bind_none_subform_does_not_auto_expand("Orders.Order", true, false, true);
        });
        let events = sink.events();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].phase.tag(), "occur");
        assert_eq!(
            events[0].reason.tag(),
            "bind_none_subform_expansion_skipped"
        );
        assert_eq!(events[0].som.as_deref(), Some("Orders.Order"));
    }

    #[test]
    fn bind_none_trace_silent_when_bind_not_none() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = bind_none_subform_does_not_auto_expand("Orders.Order", true, false, false);
        });
        assert!(sink.events().is_empty());
    }

    #[test]
    fn bind_none_trace_silent_when_occur_not_repeating() {
        // bind=none but non-repeating occur → rule applies structurally
        // but had no observable effect (caller would have skipped
        // expansion anyway). Silent.
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = bind_none_subform_does_not_auto_expand("Orders.Order", false, false, true);
        });
        assert!(sink.events().is_empty());
    }

    #[test]
    fn bind_none_trace_silent_when_name_empty() {
        // bind=none on unnamed subform → caller skipped anyway. Silent.
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = bind_none_subform_does_not_auto_expand("", true, true, true);
        });
        assert!(sink.events().is_empty());
    }

    // ── FormDomDrivenRepeatInstanceReplication ─────────────────────────

    #[test]
    fn form_dom_replication_clones_difference_when_form_dom_higher() {
        let d = form_dom_driven_repeat_instance_replication("Orders.Order", 5, 2);
        assert_eq!(d.clones_to_add, 3);
    }

    #[test]
    fn form_dom_replication_zero_when_equal() {
        let d = form_dom_driven_repeat_instance_replication("Orders.Order", 3, 3);
        assert_eq!(d.clones_to_add, 0);
    }

    #[test]
    fn form_dom_replication_zero_when_form_dom_smaller() {
        let d = form_dom_driven_repeat_instance_replication("Orders.Order", 1, 4);
        assert_eq!(d.clones_to_add, 0);
    }

    #[test]
    fn form_dom_replication_zero_when_template_default_is_zero() {
        // Mirrors the existing caller guard `existing_count > 0`. When
        // the template produced zero instances (e.g. a bind=none
        // subform handled by another rule), the form-DOM replication
        // rule must not fire because there is no template to clone.
        let d = form_dom_driven_repeat_instance_replication("Orders.Order", 5, 0);
        assert_eq!(d.clones_to_add, 0);
    }

    #[test]
    fn form_dom_replication_trace_fires_on_positive_clones() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = form_dom_driven_repeat_instance_replication("Orders.Order", 5, 2);
        });
        let events = sink.events();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].phase.tag(), "occur");
        assert_eq!(events[0].reason.tag(), "subform_materialised_from_data");
        assert_eq!(events[0].som.as_deref(), Some("Orders.Order"));
        // The trace event reports the final instance count (form DOM
        // count), not just the number of clones added — matches the
        // UX1 wire pattern.
        assert!(events[0].input.as_deref().unwrap_or("").contains("count=5"));
    }

    #[test]
    fn form_dom_replication_trace_silent_on_zero_clones() {
        let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
        with_sink(sink.clone(), || {
            let _ = form_dom_driven_repeat_instance_replication("Orders.Order", 3, 3);
            let _ = form_dom_driven_repeat_instance_replication("Orders.Order", 1, 4);
            let _ = form_dom_driven_repeat_instance_replication("Orders.Order", 5, 0);
        });
        assert!(
            sink.events().is_empty(),
            "rule must stay silent when no clones are required"
        );
    }
}
