//! Chapter 4 accidental, glyph, and engraving vocabulary (`core_spec.tex`
//! §"Accidental Registries", `sec:tuning:accidentals`, `:3054`-`:3234`; §"Glyph
//! References and SMuFL", `sec:tuning:smufl`, `:3235`-`:3277`).
//!
//! **Scope**, per `spec/CONTRACT_PUSH4B_ACCIDENTALS.md` (Push 4b tranche 3a):
//! the type surface, in memory, with two real consumers —
//! [`resolve_accidental`] (accidental resolution, honoring the precedence
//! "an override wins over an addition wins over the base registry") and
//! [`accidental_modification_compatible_with_space`]
//! (`req:tuning:accidental-modification-compatibility`, wired into
//! [`crate::invariants::check_invariants`]). Glyph and engraving metadata
//! ([`GlyphReference`], [`AccidentalEngraving`], [`AccidentalCombination`]) are
//! *carried* by both consumers — resolution returns them as part of the
//! resolved [`AccidentalDefinition`], and the compatibility check reads past
//! them to `modification` — but neither consumer *reads* them for their own
//! sake. Their deep consumer is the engraver, out of `epiphany-core`, a later
//! tranche; this module does not fabricate one to manufacture coverage.
//!
//! **No `Codec` impl exists, or may be added, for anything in this module**
//! (same discipline as `pitch_space.rs` and `tuning.rs`, Ruling C,
//! `spec/PLAN_PUSH4B_TUNING.md`): these types stay in memory this tranche.
//! [`crate::graph::ScoreTuningContext::accidental_extensions`] and `::smufl`
//! reference them without putting them on the wire (schema major 3, Push 4b
//! tranche 3b).
//!
//! ## Three ratified corrections (P13-S10/S11/S12, ratified 2026-07-23)
//!
//! * **S10** — [`PitchSpaceModification::Cents`] carries a
//!   [`CanonicalF64`], not a raw `f64`: a raw `f64` is unencodable in
//!   canonical state (`serialize.rs:110` decodes floats only through
//!   `CanonicalF64::from_le_bytes`; there is no `Codec for f64`).
//! * **S11** — [`AnchorPoint`] is referenced by the specification (`:3166`)
//!   but defined nowhere in it, and this crate cannot depend on
//!   `epiphany-layout-ir`. It is core-native here, over [`SpaceUnit`], with a
//!   pinned frame — see its doc comment.
//! * **S12** — [`SmuflVersion`] stores its minor fraction-normalized to
//!   hundredths (`minor_centi`), not literally, so derived `Ord` agrees with
//!   SMuFL's real release order. `epiphany_layout_ir` defined its own,
//!   differently-shaped, literal-minor `SmuflVersion` until Push 4b tranche
//!   3b-ii unified the two onto this type — see this type's doc comment.

use core::num::NonZeroU32;

use epiphany_determinism::CanonicalF64;

use crate::graph::SpaceUnit;
use crate::pitch::{
    AccidentalGroupId, AccidentalId, AccidentalRegistryId, CustomGlyphId, ModificationRegistryId,
    PitchSpaceId,
};
use crate::pitch_space::{built_in_position_structure, PositionStructure};

// ===========================================================================
// Glyph references (Chapter 4 §"Glyph References and SMuFL", `:3244`).
// ===========================================================================

/// A glyph reference (Chapter 4 §"Glyph References and SMuFL", `:3244`).
/// Recursive, and **this chapter's own** — do not confuse with
/// `epiphany_layout_ir::GlyphReference` (`glyph.rs:50`), a glyph *name*
/// (`Cow<'static, str>`), a rendering/layout concern. Same name, unrelated
/// types (Push 4b Ruling D's correction: "they are homonyms, not shared
/// types"); `epiphany-core` cannot depend on `epiphany-layout-ir` in any
/// case, so within this crate there is no ambiguity.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub enum GlyphReference {
    /// SMuFL codepoint, resolved against the active SMuFL font.
    Smufl(u32),
    /// Custom glyph defined by the score or a plugin.
    Custom(CustomGlyphId),
    /// Composite glyph: multiple glyph references rendered as a single
    /// accidental. Used for compound HEJI symbols and similar.
    Composite(Vec<GlyphReference>),
}

// ===========================================================================
// Pitch space modifications (Chapter 4 §"Pitch Space Modifications", `:3097`).
// ===========================================================================

/// A position modification an accidental applies (Chapter 4 §"Pitch Space
/// Modifications", `:3097`).
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub enum PitchSpaceModification {
    /// CMN-style integer offset in chromatic steps of the enclosing pitch
    /// space (`req:pitch:alteration-unit`).
    CmnChromatic(i8),
    /// Integer step offset in an EDO position space.
    EdoSteps(i16),
    /// Modification expressed as an exact rational ratio. Used for JI
    /// accidentals such as the HEJI syntonic-comma symbols (81/80) and
    /// septimal-comma symbols (64/63).
    JiRatio {
        numerator: i32,
        denominator: NonZeroU32,
    },
    /// Modification expressed in cents. Used for Sagittal-style precise
    /// microtonal accidentals.
    ///
    /// **S10 correction**: `CanonicalF64`, not a raw `f64`, per
    /// `req:determinism:canonical-floating-point` ("finite IEEE 754
    /// binary64") — a raw `f64` cannot be canonical state at all, since there
    /// is no `Codec for f64` and the byte layer decodes floats only through
    /// `CanonicalF64::from_le_bytes`.
    Cents(CanonicalF64),
    /// Modification defined by a grammar plugin.
    Registered(ModificationRegistryId),
}

// ===========================================================================
// Accidental engraving metadata (Chapter 4 §"Accidental Engraving Metadata",
// `:3150`, `:3159`).
// ===========================================================================

/// A bounding box over canonical space units, used for engraving metadata
/// that lives in canonical score state (Chapter 4 §"Accidental Engraving
/// Metadata", `:3150`). Distinct from Chapter 7's `BoundingBox` (built on
/// `StaffSpace`, single precision, for the non-canonical resolved-layout
/// cache, `epiphany_layout_ir::spatial::BoundingBox`): this type's edges are
/// [`SpaceUnit`] (`CanonicalF64`), per
/// `req:determinism:canonical-floating-point`.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct EngravingBoundingBox {
    pub left: SpaceUnit,
    pub right: SpaceUnit,
    pub top: SpaceUnit,
    pub bottom: SpaceUnit,
}

/// Where a glyph attaches to the note: typically the geometric center or a
/// custom anchor for compound glyphs (Chapter 4 §"Accidental Engraving
/// Metadata", `:3166`).
///
/// **S11 correction**: the specification *references* this type (`:3166`,
/// `AccidentalEngraving::anchor`) but never defines it, and `epiphany-core`
/// cannot depend on `epiphany-layout-ir`. Defined core-native here, over
/// [`SpaceUnit`].
///
/// **Frame** (ratified alongside S11 — `EngravingBoundingBox` is "relative to
/// the glyph's anchor point" (`:3160`), so the anchor itself needs an
/// unambiguous origin): `x`/`y` are in **canonical space units**, **y-up**,
/// relative to **the glyph's coordinate origin**.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct AnchorPoint {
    pub x: SpaceUnit,
    pub y: SpaceUnit,
}

/// Engraving metadata for an accidental (Chapter 4 §"Accidental Engraving
/// Metadata", `:3159`). Reachable from canonical score state — via
/// [`AccidentalDefinition`] inside [`ScoreAccidentalExtensions`], which hangs
/// off [`crate::graph::ScoreTuningContext`] — so every field is
/// canonical-safe: [`EngravingBoundingBox`], [`AnchorPoint`], and
/// `advance_width` are all [`SpaceUnit`] (`CanonicalF64`)-based, never
/// Chapter 7's single-precision `StaffSpace`.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct AccidentalEngraving {
    /// Bounding box in canonical space units, relative to the glyph's anchor
    /// point.
    pub bounding_box: EngravingBoundingBox,
    /// Where the glyph attaches to the note.
    pub anchor: AnchorPoint,
    /// Advance width for horizontal spacing computations.
    pub advance_width: SpaceUnit,
    /// Stacking order when multiple accidentals attach to one note. Lower
    /// values are placed closer to the notehead.
    pub stacking_order: i32,
    /// Whether this glyph should be drawn with parentheses by default (e.g.,
    /// editorial or cautionary accidentals).
    pub default_parenthesized: bool,
}

// ===========================================================================
// Combination behavior (Chapter 4 §"Combination Behavior", `:3210`).
// ===========================================================================

/// Combination behavior with other accidentals on the same note (Chapter 4
/// §"Combination Behavior", `:3210`). Most accidentals do not combine; some
/// systems (HEJI, certain microtonal notations) permit stacking multiple
/// glyphs to express compound modifications.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub enum AccidentalCombination {
    /// Stands alone; replaces any prior accidental on the same note.
    Solitary,
    /// May stack with members of the listed compatibility groups. Stacking
    /// order is determined by the engraving metadata.
    Stacking {
        compatible_groups: Vec<AccidentalGroupId>,
    },
}

// ===========================================================================
// Accidental definitions and score-local extensions (Chapter 4
// §"Accidental Registries" / "Score-Local Extensions", `:3073`, `:3228`).
// ===========================================================================

/// An accidental: a glyph, a position modification, and engraving metadata
/// (Chapter 4 §"Accidental Registries", `:3073`).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct AccidentalDefinition {
    pub id: AccidentalId,
    /// Canonical name. Used for serialization, accessibility, and
    /// foreign-format export.
    pub name: String,
    /// Glyph reference, typically SMuFL.
    pub glyph: GlyphReference,
    /// The position modification this accidental applies.
    pub modification: PitchSpaceModification,
    /// Engraving metadata.
    pub engraving: AccidentalEngraving,
    /// Combination behavior with other accidentals.
    pub combination: AccidentalCombination,
}

/// A score-local extension of a referenced accidental registry (Chapter 4
/// §"Score-Local Extensions", `:3228`). A score MAY extend a referenced
/// accidental registry with additional accidental definitions, provided the
/// registry's `extensible` flag is true (out of this tranche's scope: the
/// registry *body*, `extensible` flag included, is not built here — see
/// [`resolve_accidental`]'s doc comment). "Extensions are stored on the score
/// and override or augment the base registry during resolution" (`:3224`).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ScoreAccidentalExtensions {
    pub base: AccidentalRegistryId,
    pub additions: Vec<AccidentalDefinition>,
    pub overrides: Vec<AccidentalDefinition>,
}

// ===========================================================================
// SMuFL versioning (Chapter 4 §"SMuFL Versioning", `:3260`-`:3277`).
// ===========================================================================

/// A SMuFL version, stored so derived `Ord` agrees with SMuFL's real release
/// history (Chapter 4 §"SMuFL Versioning").
///
/// **S12 correction.** SMuFL versions are decimal fractions — 1.12, 1.18,
/// 1.20, 1.3, 1.4, released in that order — so storing the minor *literally*
/// and deriving `Ord` over `(major, minor)` sorts wrong: 1.3 as `(1, 3)` would
/// sort *before* 1.12 as `(1, 12)`, even though 1.12 shipped first.
/// `minor_centi` instead stores the fractional part normalized to hundredths
/// — 1.12 -> `12`, 1.18 -> `18`, 1.20 -> `20`, 1.3 -> `30`, 1.4 -> `40` — so
/// derived `Ord` on `(major, minor_centi)` is correct.
///
/// Construct through [`SmuflVersion::from_decimal`] rather than building the
/// literal fields directly, so a caller cannot accidentally pass a literal
/// minor digit where a normalized one is required (`from_decimal(1, "3")` and
/// a mistaken direct `SmuflVersion { major: 1, minor_centi: 3 }` would
/// otherwise look interchangeable and are not: the former is 1.3, the latter
/// is nonsensical).
///
/// This is also `epiphany_layout_ir::SmuflVersion`: Push 4b tranche 3b-ii
/// (P13-S12) unified layout-ir's former literal-minor homonym onto this type
/// (`glyph.rs` re-exports it), so the glyph catalog's version and the tuning
/// context's version are the same type.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct SmuflVersion {
    pub major: u16,
    /// The fractional part, normalized to hundredths. **Do not construct
    /// this field directly with a literal minor digit** — use
    /// [`SmuflVersion::from_decimal`].
    pub minor_centi: u16,
}

impl SmuflVersion {
    /// Builds a `SmuflVersion` from its conventional decimal notation,
    /// normalizing the minor digits to hundredths: one digit is scaled by 10
    /// (`from_decimal(1, "4")` -> 1.4 -> `minor_centi: 40`), two digits are
    /// taken as-is (`from_decimal(1, "12")` -> 1.12 -> `minor_centi: 12`).
    ///
    /// `minor_digits` is the literal digit string that would follow the
    /// decimal point (so `"4"` for 1.4, `"12"` for 1.12, `"3"` for 1.3) —
    /// *not* a numeric value to be stored as-is; `from_decimal(1, "3")` and
    /// `from_decimal(1, "30")` both denote 1.3 and produce the same
    /// `minor_centi: 30`. Returns `None` if `minor_digits` is empty, longer
    /// than two characters, or contains anything but ASCII digits — SMuFL
    /// versions in the wild (1.12, 1.18, 1.20, 1.3, 1.4, ...) never need a
    /// third fractional digit.
    pub fn from_decimal(major: u16, minor_digits: &str) -> Option<Self> {
        if !(1..=2).contains(&minor_digits.len())
            || !minor_digits.bytes().all(|b| b.is_ascii_digit())
        {
            return None;
        }
        let value: u16 = minor_digits.parse().ok()?;
        let minor_centi = if minor_digits.len() == 1 {
            value * 10
        } else {
            value
        };
        Some(SmuflVersion { major, minor_centi })
    }
}

/// A score's declared SMuFL version requirements (Chapter 4 §"SMuFL
/// Versioning", `:3269`).
///
/// `req:tuning:smufl-version-fallback`: "Every score MUST declare the SMuFL
/// version it targets" — this struct is that declaration. The requirement's
/// other clause (resolving an absent codepoint MUST produce a deterministic
/// fallback, never a silent failure) is a rendering-time behavior with no
/// consumer in `epiphany-core`; implementing it is the engraver's job, out of
/// this crate, a later tranche — this struct only carries the declaration
/// the engraver will need to consult.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
pub struct SmuflVersionRequirement {
    /// Minimum SMuFL version required by this score.
    pub minimum: SmuflVersion,
    /// SMuFL version this score was authored against. Used for detecting
    /// whether newer glyphs are in use.
    pub authored_against: SmuflVersion,
}

impl Default for SmuflVersionRequirement {
    /// `1.4` for both fields — the SMuFL version this repository already
    /// targets (`epiphany_layout_ir::glyph::GlyphCatalogIdentity`'s default,
    /// `glyph.rs:120`), so this default aligns with what Push 4b tranche 3b
    /// will unify against. Used as
    /// [`crate::graph::ScoreTuningContext`]'s `smufl` default when decoding a
    /// wire stream that predates this field (schema major <3).
    fn default() -> Self {
        let v1_4 = SmuflVersion::from_decimal(1, "4").expect("1.4 is a valid SMuFL version");
        SmuflVersionRequirement {
            minimum: v1_4,
            authored_against: v1_4,
        }
    }
}

// ===========================================================================
// Consumer (a): accidental resolution (Chapter 4 §"Score-Local Extensions",
// `:3224`).
// ===========================================================================

/// Resolves an [`AccidentalId`] against a score's accidental extensions,
/// honoring the precedence Chapter 4 states outright (`:3224`): "Extensions
/// are stored on the score and override or augment the base registry during
/// resolution" — an `overrides` entry wins over an `additions` entry wins
/// over the base registry.
///
/// `base_registry` is the resolved contents of `extensions.base`'s catalog
/// body. `epiphany-core` has no built-in catalog of accidental-registry
/// bodies this tranche (the same deferred-data-catalog discipline as
/// `crate::pitch_space`'s six underdetermined pitch spaces and
/// `crate::tuning`'s partial tuning catalog: inventing registry contents the
/// specification does not pin would be exactly the `NOTEHEAD_ANCHORS`
/// failure this project exists to avoid), so callers supply the base
/// registry's contents directly rather than this function reaching into a
/// catalog that does not exist.
///
/// Returns `None` when `id` is not found in `overrides`, `additions`, or
/// `base_registry`.
pub fn resolve_accidental<'a>(
    base_registry: &'a [AccidentalDefinition],
    extensions: &'a ScoreAccidentalExtensions,
    id: &AccidentalId,
) -> Option<&'a AccidentalDefinition> {
    extensions
        .overrides
        .iter()
        .find(|d| &d.id == id)
        .or_else(|| extensions.additions.iter().find(|d| &d.id == id))
        .or_else(|| base_registry.iter().find(|d| &d.id == id))
}

// ===========================================================================
// Consumer (b): the modification-compatibility invariant
// (`req:tuning:accidental-modification-compatibility`, `core_spec.tex:3120`).
// ===========================================================================

/// Whether `modification` is expressible in the interval algebra of `space`
/// (`req:tuning:accidental-modification-compatibility`, `core_spec.tex:3120`:
/// "An accidental's modification MUST be expressible in the interval algebra
/// of every pitch space that references its registry ... Implementations
/// MUST reject scores referencing an accidental in a space whose algebra does
/// not admit the modification").
///
/// `space` is resolved structurally against the built-in catalog
/// ([`built_in_position_structure`], Push 4b tranche 1) — the same lookup
/// `Pitch::transposed` uses, so this consumer and that one agree on what
/// "the algebra of a pitch space" means.
///
/// The requirement states two rules by name — `CmnChromatic` only in spaces
/// "with `DiatonicChromatic` or compatible algebra" (matched here against
/// [`PositionStructure::DiatonicOverChromatic`], the position-structure
/// family that algebra describes); `EdoSteps` only in `Chromatic` or
/// `Registered` spaces — and this tranche's contract directs extending "the
/// same shape" to `JiRatio` <-> [`PositionStructure::JiLattice`] (also
/// admitting `Registered`, matching `EdoSteps`'s explicit inclusion of it).
/// `Cents` and `Registered` modifications have no requirement-stated
/// constraint of their own — inventing one *would be* the `NOTEHEAD_ANCHORS`
/// failure this project has already paid for twice — so they are accepted
/// whenever `space` itself resolves.
///
/// An unresolvable `space` (an identifier outside the built-in catalog, or
/// one of the six catalog-named-but-underdetermined spaces
/// `built_in_position_structure` deliberately returns `None` for) fails
/// closed for *every* modification kind, `Cents` and `Registered` included:
/// nothing can be shown expressible in an algebra that cannot itself be
/// established (`req:pitch:space-capability-refusal`'s discipline, applied
/// here as tranche 1 applied it to `Pitch::transposed`).
pub fn accidental_modification_compatible_with_space(
    modification: &PitchSpaceModification,
    space: &PitchSpaceId,
) -> bool {
    let Some(structure) = built_in_position_structure(space) else {
        return false;
    };
    match modification {
        PitchSpaceModification::CmnChromatic(_) => {
            matches!(structure, PositionStructure::DiatonicOverChromatic { .. })
        }
        PitchSpaceModification::EdoSteps(_) => matches!(
            structure,
            PositionStructure::Chromatic { .. } | PositionStructure::Registered(_)
        ),
        PitchSpaceModification::JiRatio { .. } => matches!(
            structure,
            PositionStructure::JiLattice { .. } | PositionStructure::Registered(_)
        ),
        PitchSpaceModification::Cents(_) | PitchSpaceModification::Registered(_) => true,
    }
}

// ===========================================================================
// Test-only fixtures, shared with `codec.rs`, `textvalue_graph.rs`, and
// `invariants.rs`'s test modules so each does not hand-roll its own minimal
// `AccidentalDefinition`.
// ===========================================================================

#[cfg(test)]
pub(crate) fn fixture_engraving() -> AccidentalEngraving {
    let su = |x: f64| SpaceUnit(CanonicalF64::new(x).expect("test fixture value is finite"));
    AccidentalEngraving {
        bounding_box: EngravingBoundingBox {
            left: su(-0.5),
            right: su(0.5),
            top: su(1.0),
            bottom: su(-1.0),
        },
        anchor: AnchorPoint {
            x: su(0.0),
            y: su(0.0),
        },
        advance_width: su(1.0),
        stacking_order: 0,
        default_parenthesized: false,
    }
}

#[cfg(test)]
pub(crate) fn fixture_definition(
    id: &str,
    modification: PitchSpaceModification,
) -> AccidentalDefinition {
    AccidentalDefinition {
        id: AccidentalId::new(id),
        name: id.to_string(),
        glyph: GlyphReference::Smufl(0xE262),
        modification,
        engraving: fixture_engraving(),
        combination: AccidentalCombination::Solitary,
    }
}

#[cfg(test)]
pub(crate) fn fixture_extensions(
    base: &str,
    modification: PitchSpaceModification,
) -> ScoreAccidentalExtensions {
    ScoreAccidentalExtensions {
        base: AccidentalRegistryId::new(base),
        additions: vec![fixture_definition("test-accidental", modification)],
        overrides: Vec::new(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // --- S12: SmuflVersion ordering. -----------------------------------

    #[test]
    fn smufl_version_from_decimal_normalizes_one_and_two_digit_minors() {
        assert_eq!(
            SmuflVersion::from_decimal(1, "4"),
            Some(SmuflVersion {
                major: 1,
                minor_centi: 40
            })
        );
        assert_eq!(
            SmuflVersion::from_decimal(1, "3"),
            Some(SmuflVersion {
                major: 1,
                minor_centi: 30
            })
        );
        assert_eq!(
            SmuflVersion::from_decimal(1, "12"),
            Some(SmuflVersion {
                major: 1,
                minor_centi: 12
            })
        );
        // "3" and "30" both denote 1.3.
        assert_eq!(
            SmuflVersion::from_decimal(1, "3"),
            SmuflVersion::from_decimal(1, "30")
        );
    }

    #[test]
    fn smufl_version_from_decimal_rejects_malformed_input() {
        assert_eq!(SmuflVersion::from_decimal(1, ""), None);
        assert_eq!(SmuflVersion::from_decimal(1, "123"), None);
        assert_eq!(SmuflVersion::from_decimal(1, "x"), None);
    }

    #[test]
    fn smufl_version_orders_the_real_release_sequence() {
        // The anchor: this is SMuFL's actual release order. A test that
        // would PASS under literal-minor storage (where 1.3 and 1.4 sort
        // before 1.12) would not lock S12 at all — see the mutation in
        // DECISIONS.md, which reproduces exactly that failure mode and
        // confirms this test dies under it.
        let v = |minor: &str| SmuflVersion::from_decimal(1, minor).unwrap();
        let sequence = [v("12"), v("18"), v("20"), v("3"), v("4")];
        for pair in sequence.windows(2) {
            assert!(
                pair[0] < pair[1],
                "{:?} did not sort before {:?} in SMuFL's real release order",
                pair[0],
                pair[1]
            );
        }
    }

    // --- S10: PitchSpaceModification::Cents. ---------------------------

    #[test]
    fn cents_round_trips_a_finite_value_and_guards_non_finite() {
        let cents = CanonicalF64::new(23.46)
            .map(PitchSpaceModification::Cents)
            .expect("23.46 is finite");
        match cents {
            PitchSpaceModification::Cents(c) => assert_eq!(c.get(), 23.46),
            other => panic!("expected Cents, got {other:?}"),
        }
        // The S10 guard: a non-finite value can never become a `Cents`
        // payload, because `CanonicalF64::new` is the only way to produce
        // one and it rejects NaN/infinity outright.
        assert!(CanonicalF64::new(f64::NAN)
            .map(PitchSpaceModification::Cents)
            .is_none());
        assert!(CanonicalF64::new(f64::INFINITY)
            .map(PitchSpaceModification::Cents)
            .is_none());
        assert!(CanonicalF64::new(f64::NEG_INFINITY)
            .map(PitchSpaceModification::Cents)
            .is_none());
    }

    // --- Consumer (a): resolution precedence. ---------------------------

    #[test]
    fn resolution_precedence_overrides_beats_additions_beats_base() {
        let base = vec![fixture_definition(
            "flat",
            PitchSpaceModification::CmnChromatic(-1),
        )];
        let mut extensions =
            fixture_extensions("cmn-accidentals", PitchSpaceModification::CmnChromatic(1));
        extensions.additions = vec![fixture_definition(
            "flat",
            PitchSpaceModification::CmnChromatic(-2),
        )];
        extensions.overrides = vec![fixture_definition(
            "flat",
            PitchSpaceModification::CmnChromatic(-3),
        )];

        let id = AccidentalId::new("flat");
        // Overrides wins over additions wins over base.
        let resolved = resolve_accidental(&base, &extensions, &id).expect("resolves");
        assert_eq!(
            resolved.modification,
            PitchSpaceModification::CmnChromatic(-3),
            "an overrides entry must shadow an additions entry for the same id"
        );

        // Drop the overrides entry: additions should now win.
        extensions.overrides.clear();
        let resolved = resolve_accidental(&base, &extensions, &id).expect("resolves");
        assert_eq!(
            resolved.modification,
            PitchSpaceModification::CmnChromatic(-2),
            "an additions entry must shadow the base registry for the same id"
        );

        // Drop the additions entry too: an id with no extension resolves to
        // the base registry.
        extensions.additions.clear();
        let resolved = resolve_accidental(&base, &extensions, &id).expect("resolves");
        assert_eq!(
            resolved.modification,
            PitchSpaceModification::CmnChromatic(-1),
            "a base-registry id with no extension must resolve to the base"
        );

        // An id present nowhere resolves to nothing.
        assert!(
            resolve_accidental(&base, &extensions, &AccidentalId::new("nonexistent")).is_none()
        );
    }

    // --- Consumer (b): the modification-compatibility predicate. -------

    #[test]
    fn cmn_chromatic_is_compatible_only_with_diatonic_over_chromatic() {
        let cmn_chromatic = PitchSpaceModification::CmnChromatic(1);
        assert!(accidental_modification_compatible_with_space(
            &cmn_chromatic,
            &PitchSpaceId::new("cmn-12")
        ));
        // A test that only checked the accept case would miss the reject.
        assert!(!accidental_modification_compatible_with_space(
            &cmn_chromatic,
            &PitchSpaceId::new("edo-31")
        ));
    }

    #[test]
    fn edo_steps_is_compatible_with_chromatic_and_registered_not_diatonic() {
        let edo_steps = PitchSpaceModification::EdoSteps(1);
        assert!(accidental_modification_compatible_with_space(
            &edo_steps,
            &PitchSpaceId::new("edo-31")
        ));
        assert!(!accidental_modification_compatible_with_space(
            &edo_steps,
            &PitchSpaceId::new("cmn-12")
        ));
    }

    #[test]
    fn ji_ratio_is_compatible_only_with_ji_lattice() {
        // None of the built-in `ji-*limit` spaces structurally resolve this
        // tranche (`built_in_position_structure` returns `None` for all
        // three, tranche 1's honest gap), so both sides of this rule are
        // exercised against an unresolvable space and a resolvable
        // non-`JiLattice` one.
        let ji_ratio = PitchSpaceModification::JiRatio {
            numerator: 81,
            denominator: NonZeroU32::new(80).unwrap(),
        };
        assert!(!accidental_modification_compatible_with_space(
            &ji_ratio,
            &PitchSpaceId::new("cmn-12")
        ));
        assert!(!accidental_modification_compatible_with_space(
            &ji_ratio,
            &PitchSpaceId::new("ji-5limit")
        ));
    }

    #[test]
    fn cents_and_registered_are_unconstrained_on_a_resolved_space() {
        let cents = PitchSpaceModification::Cents(CanonicalF64::new(23.46).unwrap());
        let registered =
            PitchSpaceModification::Registered(ModificationRegistryId::new("sagittal"));
        for space in ["cmn-12", "edo-31"] {
            assert!(accidental_modification_compatible_with_space(
                &cents,
                &PitchSpaceId::new(space)
            ));
            assert!(accidental_modification_compatible_with_space(
                &registered,
                &PitchSpaceId::new(space)
            ));
        }
    }

    #[test]
    fn every_modification_kind_fails_closed_on_an_unresolvable_space() {
        let unknown = PitchSpaceId::new("not-a-built-in-space");
        assert!(!accidental_modification_compatible_with_space(
            &PitchSpaceModification::CmnChromatic(1),
            &unknown
        ));
        assert!(!accidental_modification_compatible_with_space(
            &PitchSpaceModification::EdoSteps(1),
            &unknown
        ));
        assert!(!accidental_modification_compatible_with_space(
            &PitchSpaceModification::JiRatio {
                numerator: 3,
                denominator: NonZeroU32::new(2).unwrap()
            },
            &unknown
        ));
        assert!(!accidental_modification_compatible_with_space(
            &PitchSpaceModification::Cents(CanonicalF64::new(1.0).unwrap()),
            &unknown
        ));
        assert!(!accidental_modification_compatible_with_space(
            &PitchSpaceModification::Registered(ModificationRegistryId::new("x")),
            &unknown
        ));
    }
}
