//! Grammar-directed Text Projection for primitive operation kinds.
//!
//! Operation payload records are an implementation detail: the grammar inlines
//! their fields after the Operation Catalog name, in canonical encoder order.

use epiphany_core::textvalue::{Sexp, TextError, TextValue};
use epiphany_determinism::{sorted_canonical, CanonicalEncode};
use unicode_normalization::UnicodeNormalization;

use crate::payload::{
    ChangeRegionTimeModelOp, CreateAnalysisLayerOp, CreateCrossCuttingOp, CreateInstrumentOp,
    CreateMeasureOp, CreatePartDefinitionOp, CreateRegionOp, CreateRepeatStructureOp,
    CreateStaffGroupOp, CreateStaffInstanceOp, CreateStaffOp, CreateViewOp, CreateVoiceOp,
    DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp,
    DeleteRepeatStructureOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp,
    InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp,
    OperationKind, OperationKindTag, RespellPitchOp, SetCanvasLayoutDefaultsOp, SetMetadataOp,
    SetMetricGridOp, SetSpellingPrecedenceOp, SetStaffLayoutOp, SetTempoSegmentOp,
    SetTimeSignatureOp, SetTuningContextOp, SetUserPageBreakOp, SetUserSystemBreakOp,
    TransactionDescriptor, TransposeIntervalOp, TransposeOp,
};
use crate::support::OperationKindRegistryId;

/// Builds one operation production from its generated catalog name and its
/// positionally inlined payload fields.
fn production(tag: OperationKindTag, fields: Vec<Sexp>) -> Sexp {
    let mut items = Vec::with_capacity(fields.len() + 1);
    items.push(Sexp::sym(tag.catalog_name()));
    items.extend(fields);
    Sexp::List(items)
}

fn class_of(s: &Sexp) -> &'static str {
    match s {
        Sexp::List(_) => "list",
        Sexp::Symbol(_) => "symbol",
        Sexp::Int(_) => "integer",
        Sexp::Bytes(_) => "byte string",
        Sexp::Str(_) => "string",
    }
}

/// Resolves a production head through the generated tag vocabulary. Registered
/// kinds carry their real id in the first field, so a zero-valued tag is used
/// only to select the exhaustive parsing arm.
fn production_tag(s: &Sexp) -> Result<OperationKindTag, TextError> {
    let items = s.as_list().ok_or(TextError::Expected {
        expected: "operation kind",
        found: class_of(s),
    })?;
    let head = items
        .first()
        .and_then(Sexp::as_symbol)
        .ok_or(TextError::Syntax(
            "an operation kind is headed by its catalog name",
        ))?;

    if let Some(tag) = OperationKindTag::PAYLOAD_FREE
        .iter()
        .copied()
        .find(|tag| tag.catalog_name() == head)
    {
        return Ok(tag);
    }

    let registered = OperationKindTag::Registered(OperationKindRegistryId::from_raw(0));
    if registered.catalog_name() == head {
        return Ok(registered);
    }

    Err(TextError::UnknownConstructor {
        type_name: "OperationKind",
        found: head.to_owned(),
    })
}

fn fields(s: &Sexp, tag: OperationKindTag, arity: usize) -> Result<&[Sexp], TextError> {
    s.expect_struct(tag.catalog_name(), arity)
}
/// Reads the grammar's opaque `bytes` terminal. `Vec<u8>`'s generic TextValue
/// implementation denotes a sequence of integers, which is a different
/// production and therefore must not be used for registered payload bytes.
fn parse_bytes(s: &Sexp) -> Result<Vec<u8>, TextError> {
    match s {
        Sexp::Bytes(bytes) => Ok(bytes.clone()),
        _ => Err(TextError::Expected {
            expected: "byte string",
            found: class_of(s),
        }),
    }
}

/// Projects and strictly parses all 35 `kind` productions from
/// `spec/text_projection.tex`.
impl TextValue for OperationKind {
    fn project(&self) -> Sexp {
        match self {
            OperationKind::InsertEvent(op) => production(
                self.tag(),
                vec![op.staff_instance.project(), op.event.project()],
            ),
            OperationKind::DeleteEvent(op) => production(
                self.tag(),
                vec![op.event.project(), op.tuplet_compensation.project()],
            ),
            OperationKind::RespellPitch(op) => {
                production(self.tag(), vec![op.pitch.project(), op.spelling.project()])
            }
            OperationKind::CreateCrossCutting(op) => {
                production(self.tag(), vec![op.structure.project()])
            }
            OperationKind::ChangeRegionTimeModel(op) => production(
                self.tag(),
                vec![
                    op.region.project(),
                    op.new_time_model.project(),
                    sorted_canonical(op.declared_incompatible.clone()).project(),
                    op.remapping.project(),
                ],
            ),
            OperationKind::SetUserSystemBreak(op) => production(
                self.tag(),
                vec![
                    op.region.project(),
                    op.anchor.project(),
                    op.present.project(),
                ],
            ),
            OperationKind::DeclareTransaction(op) => production(
                self.tag(),
                vec![
                    op.id.project(),
                    Sexp::Str(op.label.nfc().collect()),
                    op.category.project(),
                ],
            ),
            OperationKind::Registered(id, bytes) => {
                production(self.tag(), vec![id.project(), Sexp::Bytes(bytes.clone())])
            }
            OperationKind::ModifyEvent(op) => production(self.tag(), vec![op.event.project()]),
            OperationKind::Transpose(op) => production(
                self.tag(),
                vec![
                    sorted_canonical(op.targets.clone()).project(),
                    op.chromatic_steps.project(),
                ],
            ),
            OperationKind::InsertIdentifiedPitch(op) => {
                production(self.tag(), vec![op.event.project(), op.pitch.project()])
            }
            OperationKind::DeleteIdentifiedPitch(op) => {
                production(self.tag(), vec![op.pitch.project()])
            }
            OperationKind::ModifyIdentifiedPitch(op) => {
                production(self.tag(), vec![op.pitch.project(), op.value.project()])
            }
            OperationKind::DeleteCrossCutting(op) => {
                production(self.tag(), vec![op.structure.project()])
            }
            OperationKind::ModifyCrossCutting(op) => {
                production(self.tag(), vec![op.structure.project()])
            }
            OperationKind::CreateRegion(op) => production(self.tag(), vec![op.region.project()]),
            OperationKind::DeleteRegion(op) => production(self.tag(), vec![op.region.project()]),
            OperationKind::CreateStaffInstance(op) => {
                production(self.tag(), vec![op.region.project(), op.instance.project()])
            }
            OperationKind::DeleteStaffInstance(op) => {
                production(self.tag(), vec![op.staff_instance.project()])
            }
            OperationKind::CreateVoice(op) => production(
                self.tag(),
                vec![op.staff_instance.project(), op.voice.project()],
            ),
            OperationKind::DeleteVoice(op) => production(self.tag(), vec![op.voice.project()]),
            OperationKind::SetMetadata(op) => production(self.tag(), vec![op.metadata.project()]),
            OperationKind::SetMetricGrid(op) => {
                production(self.tag(), vec![op.region.project(), op.grid.project()])
            }
            OperationKind::SetUserPageBreak(op) => production(
                self.tag(),
                vec![
                    op.region.project(),
                    op.anchor.project(),
                    op.present.project(),
                ],
            ),
            OperationKind::CreateStaff(op) => production(self.tag(), vec![op.staff.project()]),
            OperationKind::SetTimeSignature(op) => production(
                self.tag(),
                vec![
                    op.region.project(),
                    op.anchor.project(),
                    op.time_signature.project(),
                ],
            ),
            OperationKind::SetTempoSegment(op) => production(
                self.tag(),
                vec![
                    op.region.project(),
                    op.start.project(),
                    op.segment.project(),
                ],
            ),
            OperationKind::SetStaffLayout(op) => production(
                self.tag(),
                vec![
                    op.staff_instance.project(),
                    op.instrument_override.project(),
                    op.staff_lines_override.project(),
                    op.visible.project(),
                ],
            ),
            OperationKind::CreateRepeatStructure(op) => {
                production(self.tag(), vec![op.repeat.project()])
            }
            OperationKind::DeleteRepeatStructure(op) => {
                production(self.tag(), vec![op.repeat.project()])
            }
            OperationKind::TransposeInterval(op) => production(
                self.tag(),
                vec![op.targets.project(), op.interval.project()],
            ),
            OperationKind::CreateInstrument(op) => {
                production(self.tag(), vec![op.instrument.project()])
            }
            OperationKind::SetCanvasLayoutDefaults(op) => {
                production(self.tag(), vec![op.layout_defaults.project()])
            }
            OperationKind::SetSpellingPrecedence(op) => {
                production(self.tag(), vec![op.precedence.project()])
            }
            OperationKind::SetTuningContext(op) => {
                production(self.tag(), vec![op.settings.project()])
            }
            OperationKind::CreateStaffGroup(op) => production(self.tag(), vec![op.group.project()]),
            OperationKind::CreatePartDefinition(op) => {
                production(self.tag(), vec![op.part.project()])
            }
            OperationKind::CreateAnalysisLayer(op) => {
                production(self.tag(), vec![op.layer.project()])
            }
            OperationKind::CreateView(op) => production(self.tag(), vec![op.view.project()]),
            OperationKind::CreateMeasure(op) => production(
                self.tag(),
                vec![op.instance.project(), op.measure.project()],
            ),
        }
    }

    fn parse(s: &Sexp) -> Result<Self, TextError> {
        let tag = production_tag(s)?;
        Ok(match tag {
            OperationKindTag::InsertEvent => {
                let [staff_instance, event] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::InsertEvent(InsertEventOp {
                    staff_instance: TextValue::parse(staff_instance)?,
                    event: TextValue::parse(event)?,
                })
            }
            OperationKindTag::DeleteEvent => {
                let [event, tuplet_compensation] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::DeleteEvent(DeleteEventOp {
                    event: TextValue::parse(event)?,
                    tuplet_compensation: TextValue::parse(tuplet_compensation)?,
                })
            }
            OperationKindTag::RespellPitch => {
                let [pitch, spelling] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::RespellPitch(RespellPitchOp {
                    pitch: TextValue::parse(pitch)?,
                    spelling: TextValue::parse(spelling)?,
                })
            }
            OperationKindTag::CreateCrossCutting => {
                let [structure] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
                    structure: TextValue::parse(structure)?,
                })
            }
            OperationKindTag::ChangeRegionTimeModel => {
                let [region, new_time_model, declared_incompatible, remapping] = fields(s, tag, 4)?
                else {
                    unreachable!("the arity-4 check returned four fields")
                };
                let declared_incompatible: Vec<epiphany_core::EventId> =
                    Vec::parse(declared_incompatible)?;
                // The encoder sorts this sequence. Reject disorder before storing
                // the order-preserving Vec, rather than accepting text that the
                // next projection would silently normalize.
                if declared_incompatible
                    .windows(2)
                    .any(|w| w[0].to_canonical_bytes() > w[1].to_canonical_bytes())
                {
                    return Err(TextError::NotStrictlyIncreasing(
                        "declared incompatible event ids",
                    ));
                }
                OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp {
                    region: TextValue::parse(region)?,
                    new_time_model: TextValue::parse(new_time_model)?,
                    declared_incompatible,
                    remapping: TextValue::parse(remapping)?,
                })
            }
            OperationKindTag::SetUserSystemBreak => {
                let [region, anchor, present] = fields(s, tag, 3)? else {
                    unreachable!("the arity-3 check returned three fields")
                };
                OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
                    region: TextValue::parse(region)?,
                    anchor: TextValue::parse(anchor)?,
                    present: TextValue::parse(present)?,
                })
            }
            OperationKindTag::DeclareTransaction => {
                let [id, label, category] = fields(s, tag, 3)? else {
                    unreachable!("the arity-3 check returned three fields")
                };
                let label = String::parse(label)?;
                // TransactionDescriptor's encoder normalizes this sole text
                // field. Mirror envdecode's inequality guard so parsing rejects
                // a spelling that the next projection would normalize.
                if label.nfc().collect::<String>() != label {
                    return Err(TextError::NotCanonical("transaction label is not NFC"));
                }
                OperationKind::DeclareTransaction(TransactionDescriptor {
                    id: TextValue::parse(id)?,
                    label,
                    category: TextValue::parse(category)?,
                })
            }
            OperationKindTag::Registered(_) => {
                let [id, bytes] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::Registered(TextValue::parse(id)?, parse_bytes(bytes)?)
            }
            OperationKindTag::ModifyEvent => {
                let [event] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::ModifyEvent(ModifyEventOp {
                    event: TextValue::parse(event)?,
                })
            }
            OperationKindTag::Transpose => {
                let [targets, chromatic_steps] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                let targets: Vec<epiphany_core::PitchId> = Vec::parse(targets)?;
                // This frozen payload is a sorted multiset, not a set. Mirror
                // envdecode's strict-decrease comparison so duplicate targets
                // remain legal and are replayed repeatedly.
                if targets
                    .windows(2)
                    .any(|w| w[0].to_canonical_bytes() > w[1].to_canonical_bytes())
                {
                    return Err(TextError::NotStrictlyIncreasing("transpose targets"));
                }
                OperationKind::Transpose(TransposeOp {
                    targets,
                    chromatic_steps: TextValue::parse(chromatic_steps)?,
                })
            }
            OperationKindTag::InsertIdentifiedPitch => {
                let [event, pitch] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp {
                    event: TextValue::parse(event)?,
                    pitch: TextValue::parse(pitch)?,
                })
            }
            OperationKindTag::DeleteIdentifiedPitch => {
                let [pitch] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp {
                    pitch: TextValue::parse(pitch)?,
                })
            }
            OperationKindTag::ModifyIdentifiedPitch => {
                let [pitch, value] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
                    pitch: TextValue::parse(pitch)?,
                    value: TextValue::parse(value)?,
                })
            }
            OperationKindTag::DeleteCrossCutting => {
                let [structure] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp {
                    structure: TextValue::parse(structure)?,
                })
            }
            OperationKindTag::ModifyCrossCutting => {
                let [structure] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp {
                    structure: TextValue::parse(structure)?,
                })
            }
            OperationKindTag::InsertRegion => {
                let [region] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::CreateRegion(CreateRegionOp {
                    region: TextValue::parse(region)?,
                })
            }
            OperationKindTag::DeleteRegion => {
                let [region] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::DeleteRegion(DeleteRegionOp {
                    region: TextValue::parse(region)?,
                })
            }
            OperationKindTag::InsertStaffInstance => {
                let [region, instance] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::CreateStaffInstance(CreateStaffInstanceOp {
                    region: TextValue::parse(region)?,
                    instance: TextValue::parse(instance)?,
                })
            }
            OperationKindTag::DeleteStaffInstance => {
                let [staff_instance] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::DeleteStaffInstance(DeleteStaffInstanceOp {
                    staff_instance: TextValue::parse(staff_instance)?,
                })
            }
            OperationKindTag::CreateVoice => {
                let [staff_instance, voice] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::CreateVoice(CreateVoiceOp {
                    staff_instance: TextValue::parse(staff_instance)?,
                    voice: TextValue::parse(voice)?,
                })
            }
            OperationKindTag::DeleteVoice => {
                let [voice] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::DeleteVoice(DeleteVoiceOp {
                    voice: TextValue::parse(voice)?,
                })
            }
            OperationKindTag::SetMetadata => {
                let [metadata] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::SetMetadata(SetMetadataOp {
                    metadata: TextValue::parse(metadata)?,
                })
            }
            OperationKindTag::SetMetricGrid => {
                let [region, grid] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::SetMetricGrid(SetMetricGridOp {
                    region: TextValue::parse(region)?,
                    grid: TextValue::parse(grid)?,
                })
            }
            OperationKindTag::SetUserPageBreak => {
                let [region, anchor, present] = fields(s, tag, 3)? else {
                    unreachable!("the arity-3 check returned three fields")
                };
                OperationKind::SetUserPageBreak(SetUserPageBreakOp {
                    region: TextValue::parse(region)?,
                    anchor: TextValue::parse(anchor)?,
                    present: TextValue::parse(present)?,
                })
            }
            OperationKindTag::InsertStaff => {
                let [staff] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::CreateStaff(CreateStaffOp {
                    staff: TextValue::parse(staff)?,
                })
            }
            OperationKindTag::SetTimeSignature => {
                let [region, anchor, time_signature] = fields(s, tag, 3)? else {
                    unreachable!("the arity-3 check returned three fields")
                };
                OperationKind::SetTimeSignature(SetTimeSignatureOp {
                    region: TextValue::parse(region)?,
                    anchor: TextValue::parse(anchor)?,
                    time_signature: TextValue::parse(time_signature)?,
                })
            }
            OperationKindTag::SetTempoSegment => {
                let [region, start, segment] = fields(s, tag, 3)? else {
                    unreachable!("the arity-3 check returned three fields")
                };
                OperationKind::SetTempoSegment(SetTempoSegmentOp {
                    region: TextValue::parse(region)?,
                    start: TextValue::parse(start)?,
                    segment: TextValue::parse(segment)?,
                })
            }
            OperationKindTag::SetStaffLayout => {
                let [staff_instance, instrument_override, staff_lines_override, visible] =
                    fields(s, tag, 4)?
                else {
                    unreachable!("the arity-4 check returned four fields")
                };
                OperationKind::SetStaffLayout(SetStaffLayoutOp {
                    staff_instance: TextValue::parse(staff_instance)?,
                    instrument_override: TextValue::parse(instrument_override)?,
                    staff_lines_override: TextValue::parse(staff_lines_override)?,
                    visible: TextValue::parse(visible)?,
                })
            }
            OperationKindTag::CreateRepeatStructure => {
                let [repeat] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::CreateRepeatStructure(CreateRepeatStructureOp {
                    repeat: TextValue::parse(repeat)?,
                })
            }
            OperationKindTag::DeleteRepeatStructure => {
                let [repeat] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp {
                    repeat: TextValue::parse(repeat)?,
                })
            }
            OperationKindTag::TransposeInterval => {
                let [targets, interval] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::TransposeInterval(TransposeIntervalOp {
                    // CanonicalSet's TextValue parser performs the grammar's
                    // strictly-increasing set check before BTreeSet construction.
                    targets: TextValue::parse(targets)?,
                    interval: TextValue::parse(interval)?,
                })
            }
            OperationKindTag::CreateInstrument => {
                let [instrument] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::CreateInstrument(CreateInstrumentOp {
                    instrument: TextValue::parse(instrument)?,
                })
            }
            OperationKindTag::SetCanvasLayoutDefaults => {
                let [layout_defaults] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::SetCanvasLayoutDefaults(SetCanvasLayoutDefaultsOp {
                    layout_defaults: TextValue::parse(layout_defaults)?,
                })
            }
            OperationKindTag::SetSpellingPrecedence => {
                let [precedence] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::SetSpellingPrecedence(SetSpellingPrecedenceOp {
                    precedence: TextValue::parse(precedence)?,
                })
            }
            OperationKindTag::SetTuningContext => {
                let [settings] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::SetTuningContext(SetTuningContextOp {
                    settings: TextValue::parse(settings)?,
                })
            }
            OperationKindTag::CreateStaffGroup => {
                let [group] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::CreateStaffGroup(CreateStaffGroupOp {
                    group: TextValue::parse(group)?,
                })
            }
            OperationKindTag::CreatePartDefinition => {
                let [part] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::CreatePartDefinition(CreatePartDefinitionOp {
                    part: TextValue::parse(part)?,
                })
            }
            OperationKindTag::CreateAnalysisLayer => {
                let [layer] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::CreateAnalysisLayer(CreateAnalysisLayerOp {
                    layer: TextValue::parse(layer)?,
                })
            }
            OperationKindTag::CreateView => {
                let [view] = fields(s, tag, 1)? else {
                    unreachable!("the arity-1 check returned one field")
                };
                OperationKind::CreateView(CreateViewOp {
                    view: TextValue::parse(view)?,
                })
            }
            OperationKindTag::CreateMeasure => {
                let [instance, measure] = fields(s, tag, 2)? else {
                    unreachable!("the arity-2 check returned two fields")
                };
                OperationKind::CreateMeasure(CreateMeasureOp {
                    instance: TextValue::parse(instance)?,
                    measure: TextValue::parse(measure)?,
                })
            }
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::envdecode::tests::sample_kind;
    use crate::payload::{OperationKind, OperationKindTag};
    use epiphany_core::textvalue::read_sexp;

    fn all_tags() -> impl Iterator<Item = OperationKindTag> {
        OperationKindTag::PAYLOAD_FREE
            .iter()
            .copied()
            .chain(std::iter::once(OperationKindTag::Registered(
                OperationKindRegistryId::from_raw(0),
            )))
    }

    fn round_trip(value: &OperationKind) {
        let text = value.project().render();
        let sexp = read_sexp(&text).expect("the projection is valid canonical text");
        let parsed = OperationKind::parse(&sexp).expect("the projection parses");
        assert_eq!(&parsed, value);
        assert_eq!(parsed.project().render(), text);
    }

    fn swap_first_two_in_sequence(s: &mut Sexp, field: usize) {
        let Sexp::List(production) = s else {
            panic!("operation projection is a list")
        };
        let Sexp::List(sequence) = &mut production[field] else {
            panic!("selected field is a sequence")
        };
        assert!(sequence.len() >= 2, "fixture has two ordered elements");
        sequence.swap(0, 1);
    }

    fn duplicate_first_in_sequence(s: &mut Sexp, field: usize) {
        let Sexp::List(production) = s else {
            panic!("operation projection is a list")
        };
        let Sexp::List(sequence) = &mut production[field] else {
            panic!("selected field is a sequence")
        };
        assert!(sequence.len() >= 2, "fixture has two ordered elements");
        sequence[1] = sequence[0].clone();
    }

    #[test]
    fn every_operation_kind_round_trips_with_canonical_text() {
        let tags: Vec<_> = all_tags().collect();
        assert_eq!(tags.len(), 40, "the grammar has 40 kind productions");
        for tag in tags {
            round_trip(&sample_kind(tag));
        }
    }

    #[test]
    fn operation_kind_rejects_unknown_constructor_and_wrong_arity() {
        let unknown = read_sexp("(unknown-operation #x00)").expect("well-formed text");
        assert!(OperationKind::parse(&unknown).is_err());

        let sample = sample_kind(OperationKindTag::DeleteRegion);
        let Sexp::List(mut items) = sample.project() else {
            panic!("operation projection is a list")
        };
        items.push(Sexp::int(0));
        assert!(OperationKind::parse(&Sexp::List(items)).is_err());
    }

    /// (s9, round-trip half) Genesis tranche G2a: a `set-canvas-layout-
    /// defaults` production carrying `set-spelling-precedence`'s field shape
    /// (relabeled under the other kind's tag) must not parse — the two
    /// carried types have incompatible projected shapes (`CanvasLayoutDefaults`
    /// projects a 2-field struct; `SpellingPrecedence` projects a 1-field
    /// struct wrapping a sequence), so mislabeling one as the other is
    /// rejected rather than mis-round-tripped.
    ///
    /// **Mutation (required, and run):** make the parse arm silently accept a
    /// mismatched shape — `TextValue::parse(layout_defaults).unwrap_or_default()`
    /// in place of the `?` — which is exactly the defect this test names. The
    /// assertion fires. An earlier note here claimed no mutation was needed
    /// because the test *is* the reject-path exercise; that reasoning was
    /// wrong. Being a reject-path test does not establish that the rejection
    /// is caused by the mislabeling rather than by something incidental, and
    /// only running the mutation shows the assertion is not vacuous.
    #[test]
    fn one_settings_kind_production_under_the_others_tag_is_rejected() {
        let precedence_sample = sample_kind(OperationKindTag::SetSpellingPrecedence);
        let Sexp::List(mut items) = precedence_sample.project() else {
            panic!("operation projection is a list")
        };
        // Relabel the head symbol from `set-spelling-precedence` to
        // `set-canvas-layout-defaults`, keeping SpellingPrecedence's field
        // shape underneath.
        items[0] = Sexp::sym(OperationKindTag::SetCanvasLayoutDefaults.catalog_name());
        let mislabeled = Sexp::List(items);
        assert!(
            OperationKind::parse(&mislabeled).is_err(),
            "a production under the wrong kind's tag must not parse"
        );
    }

    #[test]
    fn transaction_label_projects_nfc_and_rejects_non_nfc_text() {
        let mut sample = sample_kind(OperationKindTag::DeclareTransaction);
        let OperationKind::DeclareTransaction(descriptor) = &mut sample else {
            panic!("tag fixture constructs its corresponding operation")
        };
        descriptor.label = "e\u{301}".to_owned();

        let Sexp::List(mut items) = sample.project() else {
            panic!("operation projection is a list")
        };
        assert_eq!(items[2], Sexp::Str("\u{e9}".to_owned()));

        items[2] = Sexp::Str("e\u{301}".to_owned());
        assert!(OperationKind::parse(&Sexp::List(items)).is_err());
    }

    #[test]
    fn registered_payload_uses_and_requires_the_bytes_terminal() {
        let sample = sample_kind(OperationKindTag::Registered(
            OperationKindRegistryId::from_raw(0),
        ));
        let Sexp::List(mut items) = sample.project() else {
            panic!("operation projection is a list")
        };
        assert!(matches!(
            items.as_slice(),
            [Sexp::Symbol(_), Sexp::Bytes(_), Sexp::Bytes(_)]
        ));

        items[2] = Sexp::List(vec![Sexp::int(1), Sexp::int(2)]);
        assert!(OperationKind::parse(&Sexp::List(items)).is_err());
    }

    #[test]
    fn transpose_multiset_rejects_strict_decrease_but_accepts_duplicate() {
        let sample = sample_kind(OperationKindTag::Transpose);
        let mut decreasing = sample.project();
        swap_first_two_in_sequence(&mut decreasing, 1);
        assert!(OperationKind::parse(&decreasing).is_err());

        let mut duplicated = sample.project();
        duplicate_first_in_sequence(&mut duplicated, 1);
        let parsed = OperationKind::parse(&duplicated).expect("duplicates are legal in a multiset");
        assert_eq!(parsed.project(), duplicated);
    }

    #[test]
    fn change_region_declared_incompatible_rejects_decrease_but_accepts_duplicate() {
        let sample = sample_kind(OperationKindTag::ChangeRegionTimeModel);
        let mut decreasing = sample.project();
        swap_first_two_in_sequence(&mut decreasing, 3);
        assert!(OperationKind::parse(&decreasing).is_err());

        let mut duplicated = sample.project();
        duplicate_first_in_sequence(&mut duplicated, 3);
        let parsed = OperationKind::parse(&duplicated).expect("non-decreasing permits duplicates");
        assert_eq!(parsed.project(), duplicated);
    }

    #[test]
    fn transpose_interval_targets_reject_duplicate_and_decrease() {
        let sample = sample_kind(OperationKindTag::TransposeInterval);

        let mut duplicated = sample.project();
        duplicate_first_in_sequence(&mut duplicated, 1);
        assert!(OperationKind::parse(&duplicated).is_err());

        let mut decreasing = sample.project();
        swap_first_two_in_sequence(&mut decreasing, 1);
        assert!(OperationKind::parse(&decreasing).is_err());
    }
}
