use super::*;
use crate::model::{
    Accidental, AlignedSymbolKind, LyricControl, RestVisibility, SlurRole, TieRole, TimedEvent,
    TimedEventKind, TupletRole,
};
use crate::options::ParseOptions;
use crate::parse::{parse_document, parse_tune_report_from_document};
use crate::syntax::{
    AnnotationPlacement, DecorationKind, MalformedSyntax, MalformedSyntaxKind, OctaveMark,
    QuotedTextKind, VariantEndingPart,
};

fn events_for(source: &str) -> (Vec<Event>, Vec<Diagnostic>) {
    let document = parse_document(source, ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);
    (
        report.value.expect("expected tune").events,
        report.diagnostics,
    )
}

fn count_diagnostics(diagnostics: &[Diagnostic], code: &'static str) -> usize {
    diagnostics
        .iter()
        .filter(|diagnostic| diagnostic.code == code)
        .count()
}

#[test]
fn normalizes_pitch_case_and_mixed_octave_marks() {
    let (events, diagnostics) = events_for("X:1\nL:1/8\nK:C\nC C' c C,',\n");

    assert!(diagnostics.is_empty());
    let octaves = events
        .iter()
        .filter_map(|event| match event {
            Event::Note { octave, .. } => Some(*octave),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(octaves, vec![4, 5, 5, 3]);
}

#[test]
fn recovers_standalone_octave_marks_without_attaching_to_neighbor_notes() {
    let document_report = parse_document("X:1\nL:1/8\nK:C\n' , C\n", ParseOptions::default());
    assert_eq!(
        count_diagnostics(&document_report.diagnostics, "abc.music.malformed_octave"),
        2
    );

    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let malformed = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::Malformed(item) => Some(item),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(malformed.len(), 2);
    assert!(malformed.iter().all(|item| !item.span.is_empty()));

    let tune_report = parse_tune_report_from_document(&document_report.value);
    let events = tune_report.value.expect("expected tune").events;
    assert!(matches!(
        events.as_slice(),
        [Event::Note {
            step: 'C',
            octave: 4,
            accidental: None,
            ..
        }]
    ));
}

#[test]
fn preserves_explicit_accidentals_in_semantic_events() {
    let (events, diagnostics) = events_for("X:1\nL:1/8\nK:C\n^C _D =E ^^F __G\n");

    assert!(diagnostics.is_empty());
    let accidentals = events
        .iter()
        .filter_map(|event| match event {
            Event::Note { accidental, .. } => Some(*accidental),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(
        accidentals,
        vec![
            Some(Accidental::Sharp),
            Some(Accidental::Flat),
            Some(Accidental::Natural),
            Some(Accidental::DoubleSharp),
            Some(Accidental::DoubleFlat),
        ]
    );
}

#[test]
fn recovers_dangling_accidentals_without_leaking_into_later_notes() {
    let document_report = parse_document("X:1\nL:1/8\nK:C\n^ _ = C\n", ParseOptions::default());
    assert_eq!(
        count_diagnostics(
            &document_report.diagnostics,
            "abc.music.malformed_accidental"
        ),
        3
    );

    let tune_report = parse_tune_report_from_document(&document_report.value);
    let events = tune_report.value.expect("expected tune").events;
    assert!(matches!(
        events.as_slice(),
        [Event::Note {
            step: 'C',
            accidental: None,
            ..
        }]
    ));
}

#[test]
fn recovers_accidental_separated_from_note_by_misplaced_length() {
    // `^/c` is malformed: the `/` length operator sits between the accidental and
    // its note. The sharp's intent is unambiguous (cf. the abc2xml baseline and
    // parallel well-formed `^c` bars), so croma must still apply the sharp to the
    // following note rather than dropping it. The misplaced `/` is still flagged
    // as a length error and is NOT applied to the note's duration.
    let document_report = parse_document("X:1\nL:1/4\nK:C\n^/c c\n", ParseOptions::default());

    assert_eq!(
        count_diagnostics(&document_report.diagnostics, "abc.music.malformed_length"),
        1
    );
    assert_eq!(
        count_diagnostics(
            &document_report.diagnostics,
            "abc.music.malformed_accidental"
        ),
        0
    );

    let tune_report = parse_tune_report_from_document(&document_report.value);
    let events = tune_report.value.expect("expected tune").events;
    assert!(matches!(
        events.as_slice(),
        [
            Event::Note {
                step: 'C',
                accidental: Some(Accidental::Sharp),
                duration: sharp_duration,
                ..
            },
            Event::Note {
                step: 'C',
                accidental: None,
                duration: plain_duration,
                ..
            },
        ] if sharp_duration == plain_duration
    ));
}

#[test]
fn lowers_fractional_lengths_and_slash_shorthand() {
    let document = parse_document(
        "X:1\nL:1/8\nK:C\nA2 A/ A// A3/2 A/4\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");

    assert!(report.diagnostics.is_empty());
    assert_eq!(tune.divisions, 8);
    let durations = tune
        .events
        .iter()
        .filter_map(|event| match event {
            Event::Note { duration, .. } => Some(*duration),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(durations, vec![8, 2, 1, 6, 1]);
}

#[test]
fn recovers_malformed_lengths_and_preserves_valid_neighbors() {
    let document_report =
        parse_document("X:1\nL:1/8\nK:C\nA0 B/0 C 3 / D\n", ParseOptions::default());
    assert_eq!(
        count_diagnostics(&document_report.diagnostics, "abc.music.malformed_length"),
        4
    );

    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let malformed_spans = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::Malformed(item) => Some(item.span),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(malformed_spans.len(), 2);
    assert!(
        malformed_spans
            .iter()
            .all(|span| document_report.value.source.slice(*span).is_some())
    );

    let tune_report = parse_tune_report_from_document(&document_report.value);
    let tune = tune_report.value.expect("expected tune");
    let durations = tune
        .events
        .iter()
        .filter_map(|event| match event {
            Event::Note { duration, .. } => Some(*duration),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(durations, vec![4, 4, 4, 4]);
}

#[test]
fn lowers_multi_measure_rests_in_known_and_free_meter() {
    let document = parse_document(
        "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|Z2|GABc|]\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    assert!(report.diagnostics.is_empty());
    let tune = report.value.expect("expected tune");
    let voice = &tune.score.parts[0].voices[0];
    assert_eq!(voice.measures.len(), 4);
    assert_eq!(
        voice
            .measures
            .iter()
            .map(|measure| measure.actual_duration)
            .collect::<Vec<_>>(),
        vec![Fraction::new(4, 4); 4]
    );
    let rest_measures = voice
        .events
        .iter()
        .filter_map(|event| match &event.kind {
            TimedEventKind::Rest(rest) if rest.visibility == RestVisibility::Visible => {
                Some(event.measure.number)
            }
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(rest_measures, vec![2, 3]);

    let document = parse_document("X:1\nM:none\nL:1/8\nK:C\nZ3\n", ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    assert!(
        report
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.multirest.free_meter")
    );
    assert_eq!(tune.events.len(), 1);
    assert!(matches!(tune.events[0], Event::Rest { duration: 12, .. }));
}

#[test]
fn lowers_visible_invisible_rests_and_spacers() {
    let (events, diagnostics) = events_for("X:1\nL:1/8\nK:C\nz x y C\n");

    assert!(diagnostics.is_empty());
    let rests = events
        .iter()
        .filter_map(|event| match event {
            Event::Rest {
                visibility,
                duration,
                ..
            } => Some((*visibility, *duration)),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(
        rests,
        vec![(RestVisibility::Visible, 4), (RestVisibility::Invisible, 4),]
    );
    assert_eq!(
        events
            .iter()
            .filter(|event| matches!(event, Event::Spacer { .. }))
            .count(),
        1
    );
}

#[test]
fn malformed_rest_lengths_recover_to_safe_durations() {
    let document_report = parse_document("X:1\nL:1/8\nK:C\nz0 x/0\n", ParseOptions::default());
    assert_eq!(
        count_diagnostics(&document_report.diagnostics, "abc.music.malformed_length"),
        2
    );

    let tune_report = parse_tune_report_from_document(&document_report.value);
    let rests = tune_report
        .value
        .expect("expected tune")
        .events
        .into_iter()
        .filter_map(|event| match event {
            Event::Rest {
                visibility,
                duration,
                ..
            } => Some((visibility, duration)),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(
        rests,
        vec![(RestVisibility::Visible, 4), (RestVisibility::Invisible, 4),]
    );
}

#[test]
fn lowers_basic_double_and_repeat_barlines() {
    let (events, diagnostics) = events_for("X:1\nK:C\nC|D||E|:F:|G::A[|B|]c\n");

    assert!(diagnostics.is_empty());
    let barlines = events
        .iter()
        .filter_map(|event| match event {
            Event::Barline { kind, .. } => Some(*kind),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(
        barlines,
        vec![
            BarlineKind::Regular,
            BarlineKind::Double,
            BarlineKind::RepeatStart,
            BarlineKind::RepeatEnd,
            BarlineKind::RepeatBoth,
            BarlineKind::Initial,
            BarlineKind::Final,
        ]
    );
}

#[test]
fn recovers_invalid_barline_fragments_as_skipped_malformed_items() {
    let document_report = parse_document("X:1\nK:C\nC : D\n", ParseOptions::default());
    assert_eq!(
        count_diagnostics(&document_report.diagnostics, "abc.music.invalid_barline"),
        1
    );

    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    assert!(tune_music.lines[0].items.iter().any(|item| matches!(
        item,
        MusicItem::Malformed(MalformedSyntax {
            kind: MalformedSyntaxKind::InvalidBarline,
            ..
        })
    )));

    let tune_report = parse_tune_report_from_document(&document_report.value);
    let notes = tune_report
        .value
        .expect("expected tune")
        .events
        .into_iter()
        .filter(|event| matches!(event, Event::Note { .. }))
        .count();
    assert_eq!(notes, 2);
}

#[test]
fn parses_liberal_dotted_and_invisible_barlines_with_diagnostics() {
    let report = parse_document("X:1\nK:C\nC |[| D .| E [|] F\n", ParseOptions::default());
    assert!(
        report
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.barline.liberal")
    );
    assert!(
        report
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.barline.policy")
    );

    let tune_report = parse_tune_report_from_document(&report.value);
    assert!(
        tune_report
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.barline_policy")
    );
}

#[test]
fn trailing_final_barline_after_liberal_boundary_stays_on_previous_measure() {
    let document = parse_document(
        "X:1\nM:4/4\nL:1/4\nK:C\nCDEF:\n|]\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let measures = &tune.score.parts[0].voices[0].measures;

    assert_eq!(measures.len(), 1);
    assert!(
        measures[0]
            .barlines
            .iter()
            .any(|barline| barline.kind == BarlineKind::Final),
        "explicit final |] after a liberal : boundary must survive: {:?}",
        measures[0].barlines
    );
}

#[test]
fn unclosed_inline_fields_groups_and_strings_are_recoverable_syntax() {
    let document_report = parse_document(
        "X:1\nK:C\nC [M:3/4\nD {ef\nE \"Am\nF [CE\nG\n",
        ParseOptions::default(),
    );

    for code in [
        "abc.music.unclosed_inline_field",
        "abc.music.unclosed_grace",
        "abc.music.unclosed_quoted_text",
        "abc.music.unclosed_chord",
    ] {
        assert!(
            document_report
                .diagnostics
                .iter()
                .any(|diagnostic| diagnostic.code == code),
            "expected diagnostic {code}"
        );
    }

    let tune_report = parse_tune_report_from_document(&document_report.value);
    let notes = tune_report
        .value
        .expect("expected tune")
        .events
        .into_iter()
        .filter_map(|event| match event {
            Event::Note { step, .. } => Some(step),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(notes, vec!['C', 'D', 'E', 'F', 'G']);
}

#[test]
fn parses_spec_attachment_order_around_note_group() {
    let document_report =
        parse_document("X:1\nL:1/8\nK:C\n\"Gm7\"v.=G,2\n", ParseOptions::default());
    assert!(document_report.diagnostics.is_empty());
    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let note = tune_music.lines[0]
        .items
        .iter()
        .find_map(|item| match item {
            MusicItem::Note(note) => Some(note),
            _ => None,
        })
        .expect("expected note");

    assert_eq!(note.attachments.chord_symbols[0].text, "Gm7");
    assert_eq!(
        note.attachments
            .decorations
            .iter()
            .map(|decoration| decoration.name.as_str())
            .collect::<Vec<_>>(),
        // `v` (down-bow shorthand) normalizes to its canonical decoration
        // name; `.` (staccato) is handled separately and stays as-is.
        vec!["downbow", "."]
    );
    assert_eq!(
        note.accidental.map(|accidental| accidental.sign),
        Some(Accidental::Natural)
    );
    assert_eq!(note.octave_marks[0].mark, OctaveMark::Lower);
    assert_eq!(
        note.length.as_ref().map(|length| length.raw.as_str()),
        Some("2")
    );
}

#[test]
fn classifies_quoted_chord_symbols_and_annotations() {
    let document_report = parse_document(
        "X:1\nL:1/8\nK:C\n\"Am7\"C \"^above\"D \"_below\"E \"<left\"F \">right\"G \"@free\"A\n",
        ParseOptions::default(),
    );
    assert!(document_report.diagnostics.is_empty());
    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let notes = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::Note(note) => Some(note),
            _ => None,
        })
        .collect::<Vec<_>>();

    assert_eq!(notes[0].attachments.chord_symbols[0].text, "Am7");
    let placements = notes[1..]
        .iter()
        .map(|note| note.attachments.annotations[0].kind)
        .collect::<Vec<_>>();
    assert_eq!(
        placements,
        vec![
            QuotedTextKind::Annotation(AnnotationPlacement::Above),
            QuotedTextKind::Annotation(AnnotationPlacement::Below),
            QuotedTextKind::Annotation(AnnotationPlacement::Left),
            QuotedTextKind::Annotation(AnnotationPlacement::Right),
            QuotedTextKind::Annotation(AnnotationPlacement::Free),
        ]
    );
}

#[test]
fn quoted_text_before_grace_group_stays_in_main_note_bundle() {
    // `"F"{AB}c`: the syntax tree binds the quoted text to the MAIN note's
    // attachment bundle, alongside the grace group — the first grace note
    // inside the braces must not steal it.
    let document_report = parse_document("X:1\nL:1/8\nK:C\n\"F\"{AB}c\n", ParseOptions::default());
    assert!(document_report.diagnostics.is_empty());
    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let note = tune_music.lines[0]
        .items
        .iter()
        .find_map(|item| match item {
            MusicItem::Note(note) => Some(note),
            _ => None,
        })
        .expect("expected note");

    assert_eq!(note.pitch.step, 'c');
    assert_eq!(note.attachments.chord_symbols[0].text, "F");
    assert_eq!(note.attachments.grace_groups.len(), 1);
    let grace = &note.attachments.grace_groups[0];
    assert!(grace.elements.iter().all(|element| match element {
        crate::syntax::GraceElementSyntax::Note(grace_note) =>
            grace_note.attachments.chord_symbols.is_empty(),
        _ => true,
    }));
}

#[test]
fn grace_group_internal_slur_tokens_do_not_warn() {
    let source = "X:1\nL:1/8\nK:C\n{(fg)}a2 {(ef)}g2|]\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        count_diagnostics(&diagnostics, "abc.music.unknown_grace_token"),
        0,
        "grace-internal slur markers should not warn: {diagnostics:?}"
    );
    let notes = semantic_note_events(&tune);
    assert_eq!(notes[0].attachments.grace_groups.len(), 1);
    assert_eq!(notes[1].attachments.grace_groups.len(), 1);
    for group in [
        &notes[0].attachments.grace_groups[0],
        &notes[1].attachments.grace_groups[0],
    ] {
        assert_eq!(group.events.len(), 2);
        assert_eq!(group.events[0].slurs.len(), 1);
        assert_eq!(group.events[0].slurs[0].role, SlurRole::Start);
        assert_eq!(group.events[1].slurs.len(), 1);
        assert_eq!(group.events[1].slurs[0].role, SlurRole::Stop);
        assert_eq!(
            group.events[0].slurs[0].pair_id,
            group.events[1].slurs[0].pair_id
        );
    }
    assert_ne!(
        notes[0].attachments.grace_groups[0].events[0].slurs[0].pair_id,
        notes[1].attachments.grace_groups[0].events[0].slurs[0].pair_id
    );
}

#[test]
fn chord_internal_slurs_bind_to_their_own_member() {
    // ABC 2.1 §4.11: slurs may be used "into, out of and between chords". In
    // `[(C(E] [C)E)]` each chord-internal `(`/`)` binds to the member it is
    // adjacent to — starts on C and E of chord 1, stops on C and E of chord 2.
    // croma collapsed all starts onto the chord head and all stops onto the tail
    // (tune_007779, tune_011866).
    let (tune, _diagnostics) = tune_for("X:1\nL:1/4\nK:C\n[(C(E] [C)E)]\n");
    let chords: Vec<&crate::model::ChordEvent> = tune.score.parts[0].voices[0]
        .events
        .iter()
        .filter_map(|event| match &event.kind {
            TimedEventKind::Chord(chord) => Some(chord),
            _ => None,
        })
        .collect();
    assert_eq!(chords.len(), 2, "two chords");
    let role_of = |chord: &crate::model::ChordEvent, member: usize| {
        let slurs = &chord.members[member].attachments.slurs;
        assert_eq!(slurs.len(), 1, "each chord member carries exactly one slur");
        slurs[0].role
    };
    assert_eq!(role_of(chords[0], 0), SlurRole::Start, "chord1 C start");
    assert_eq!(role_of(chords[0], 1), SlurRole::Start, "chord1 E start");
    assert_eq!(role_of(chords[1], 0), SlurRole::Stop, "chord2 C stop");
    assert_eq!(role_of(chords[1], 1), SlurRole::Stop, "chord2 E stop");
}

#[test]
fn slur_start_anchors_on_a_following_rest() {
    // `(z2 G2)` — the slur `(` immediately precedes the rest `z2`, so per ABC 2.1
    // §4.11/§4.20 the slur starts ON the rest, not the next note. croma was
    // relocating the start onto the following pitched note (tune_008749).
    let (tune, diagnostics) = tune_for("X:1\nL:1/8\nK:C\n(z2 G2)\n");
    assert_eq!(
        count_diagnostics(&diagnostics, "abc.music.unmatched_slur"),
        0,
        "balanced slur over a rest should not warn: {diagnostics:?}"
    );
    let events = &tune.score.parts[0].voices[0].events;
    let rest = events
        .iter()
        .find(|event| matches!(event.kind, TimedEventKind::Rest(_)))
        .expect("expected a rest event");
    assert_eq!(
        rest.attachments.slurs.len(),
        1,
        "slur start should bind to the rest, not skip to the next note"
    );
    assert_eq!(rest.attachments.slurs[0].role, SlurRole::Start);
    let note = events
        .iter()
        .find(|event| matches!(event.kind, TimedEventKind::Note(_)))
        .expect("expected a note event");
    assert_eq!(note.attachments.slurs.len(), 1);
    assert_eq!(note.attachments.slurs[0].role, SlurRole::Stop);
    assert_eq!(
        rest.attachments.slurs[0].pair_id,
        note.attachments.slurs[0].pair_id
    );
}

#[test]
fn malformed_grace_group_internal_slurs_warn_and_skip_attachments() {
    for (source, code) in [
        ("X:1\nL:1/8\nK:C\n{(fg}a|]\n", "abc.music.unclosed_slur"),
        ("X:1\nL:1/8\nK:C\n{fg)}a|]\n", "abc.music.unmatched_slur"),
    ] {
        let (tune, diagnostics) = tune_for(source);

        assert_eq!(
            count_diagnostics(&diagnostics, code),
            1,
            "expected {code} for {source:?}: {diagnostics:?}"
        );
        let notes = semantic_note_events(&tune);
        let grace = &notes[0].attachments.grace_groups[0];
        assert_eq!(grace.events.len(), 2);
        assert!(
            grace.events.iter().all(|event| event.slurs.is_empty()),
            "malformed grace-internal slur markers should be skipped: {grace:?}"
        );
    }
}

#[test]
fn grace_group_internal_slur_can_span_chord_and_note() {
    let source = "X:1\nL:1/8\nK:C\n{([fg]a)}c|]\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    let grace = &notes[0].attachments.grace_groups[0];
    assert_eq!(grace.events.len(), 2);
    assert_eq!(grace.events[0].slurs.len(), 1);
    assert_eq!(grace.events[0].slurs[0].role, SlurRole::Start);
    assert_eq!(grace.events[1].slurs.len(), 1);
    assert_eq!(grace.events[1].slurs[0].role, SlurRole::Stop);
    assert_eq!(
        grace.events[0].slurs[0].pair_id,
        grace.events[1].slurs[0].pair_id
    );
}

#[test]
fn bare_grace_slur_closes_on_last_grace_note() {
    let source = "X:1\nL:1/8\nK:C\n({Bc})D|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(
        diagnostics.is_empty(),
        "bare grace slur should not warn: {diagnostics:?}"
    );
    let notes = semantic_note_events(&tune);
    assert_eq!(notes.len(), 1);
    assert!(
        notes[0].attachments.slurs.is_empty(),
        "slur should bind to the grace group, not the main note"
    );
    let grace = &notes[0].attachments.grace_groups[0];
    assert_eq!(grace.events.len(), 2);
    assert_eq!(grace.slurs.len(), 1);
    assert_eq!(grace.slurs[0].role, SlurRole::Start);
    assert_eq!(grace.events[1].slurs.len(), 1);
    assert_eq!(grace.events[1].slurs[0].role, SlurRole::Stop);
    assert_eq!(grace.slurs[0].pair_id, grace.events[1].slurs[0].pair_id);
}

#[test]
fn bare_grace_slur_before_barline_resolves_as_after_grace() {
    let source = "X:1\nL:1/8\nK:C\nA2({Bc})|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(
        diagnostics.is_empty(),
        "bare grace before barline should not warn: {diagnostics:?}"
    );
    let notes = semantic_note_events(&tune);
    assert_eq!(notes.len(), 1);
    assert!(notes[0].attachments.slurs.is_empty());
    assert!(notes[0].attachments.grace_groups.is_empty());
    let grace = &notes[0].attachments.after_grace_groups[0];
    assert_eq!(grace.events.len(), 2);
    assert_eq!(grace.slurs.len(), 1);
    assert_eq!(grace.slurs[0].role, SlurRole::Start);
    assert_eq!(grace.events[1].slurs.len(), 1);
    assert_eq!(grace.events[1].slurs[0].role, SlurRole::Stop);
    assert_eq!(grace.slurs[0].pair_id, grace.events[1].slurs[0].pair_id);
}

#[test]
fn parses_user_defined_and_legacy_decoration_symbols_from_dialect_state() {
    let user_symbol = parse_document("X:1\nU:W=!trill!\nK:C\nWC\n", ParseOptions::default());
    assert!(user_symbol.diagnostics.is_empty());
    let tune_music = user_symbol
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let note = tune_music.lines[0]
        .items
        .iter()
        .find_map(|item| match item {
            MusicItem::Note(note) => Some(note),
            _ => None,
        })
        .expect("expected note");
    assert_eq!(
        note.attachments.decorations[0].kind,
        DecorationKind::UserDefined
    );
    // The `U:`-defined symbol expands to its canonical decoration name so it
    // maps through the same export path as the long-form `!trill!`.
    assert_eq!(note.attachments.decorations[0].name, "trill");

    let legacy_allowed = parse_document(
        "X:1\nI:decoration +\nK:C\n+trill+C\n",
        ParseOptions::default(),
    );
    assert!(legacy_allowed.diagnostics.is_empty());
    let tune_music = legacy_allowed
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let note = tune_music.lines[0]
        .items
        .iter()
        .find_map(|item| match item {
            MusicItem::Note(note) => Some(note),
            _ => None,
        })
        .expect("expected note");
    assert_eq!(
        note.attachments.decorations[0].kind,
        DecorationKind::LegacyNamed
    );

    let legacy_rejected = parse_document("X:1\nK:C\n+trill+C\n", ParseOptions::default());
    assert!(
        legacy_rejected
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.invalid_decoration")
    );
}

#[test]
fn parses_chord_with_inside_and_outside_decorations() {
    let document_report =
        parse_document("X:1\nL:1/8\nK:C\n!trill![.CEG]\n", ParseOptions::default());
    assert!(document_report.diagnostics.is_empty());
    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let chord = tune_music.lines[0]
        .items
        .iter()
        .find_map(|item| match item {
            MusicItem::Chord(chord) => Some(chord),
            _ => None,
        })
        .expect("expected chord");

    assert_eq!(chord.attachments.decorations[0].name, "trill");
    assert_eq!(chord.members.len(), 3);
    assert_eq!(chord.members[0].note.attachments.decorations[0].name, ".");
}

#[test]
fn parses_chord_internal_tie_marker_on_member() {
    let document_report = parse_document("X:1\nL:1/8\nK:C\n[DA-]\n", ParseOptions::default());
    assert_eq!(
        count_diagnostics(
            &document_report.diagnostics,
            "abc.music.unknown_chord_token"
        ),
        0,
        "chord-internal tie must not be reported as an unknown chord token"
    );
    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let chord = tune_music.lines[0]
        .items
        .iter()
        .find_map(|item| match item {
            MusicItem::Chord(chord) => Some(chord),
            _ => None,
        })
        .expect("expected chord");

    assert_eq!(chord.members.len(), 2);
    assert!(
        chord.members[0].tie.is_none(),
        "D member must not carry a tie"
    );
    assert!(
        chord.members[1].tie.is_some(),
        "A member must carry the internal tie marker"
    );
}

#[test]
fn unclosed_chord_scan_stops_at_barline() {
    // An unclosed `[` followed by a space then a barline must not consume the
    // following measures. The chord scan stops at `|`, emitting an
    // unclosed-chord diagnostic for the partial run, and the real notes after
    // the barline survive as ordinary music.
    let document_report = parse_document(
        "X:1\nL:1/8\nK:C\n[ |: CDEF | GABc |\n",
        ParseOptions::default(),
    );
    assert_eq!(
        count_diagnostics(&document_report.diagnostics, "abc.music.unclosed_chord"),
        1,
        "the unclosed bracket should yield exactly one unclosed-chord diagnostic"
    );
    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let note_letters = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::Note(note) => Some(note.pitch.step),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(
        note_letters,
        vec!['C', 'D', 'E', 'F', 'G', 'A', 'B', 'c'],
        "all notes after the unclosed bracket must survive"
    );
}

#[test]
fn unclosed_chord_member_before_barline_recovers_as_note() {
    let source = "X:1\nL:1/8\nK:C\ne2 E E2 ][ f |\n";
    let document_report = parse_document(source, ParseOptions::default());
    assert_eq!(
        count_diagnostics(&document_report.diagnostics, "abc.music.unclosed_chord"),
        1,
        "the unclosed bracket should yield exactly one unclosed-chord diagnostic"
    );
    let diagnostic = document_report
        .diagnostics
        .iter()
        .find(|diagnostic| diagnostic.code == "abc.music.unclosed_chord")
        .expect("expected unclosed-chord diagnostic");
    assert_eq!(
        &source[diagnostic.span.start..diagnostic.span.end],
        "[ f ",
        "the diagnostic span should stay on the malformed bracket run before the barline"
    );

    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let note_letters = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::Note(note) => Some(note.pitch.step),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(
        note_letters,
        vec!['e', 'E', 'E', 'f'],
        "the note inside the unclosed bracket should recover as an ordinary note"
    );
    let recovered_note_index = tune_music.lines[0]
        .items
        .iter()
        .position(|item| matches!(item, MusicItem::Note(note) if note.pitch.step == 'f'))
        .expect("expected recovered f note");
    assert!(
        tune_music.lines[0].items[recovered_note_index + 1..]
            .iter()
            .any(|item| matches!(item, MusicItem::Barline(_))),
        "the barline that stopped the malformed chord scan must still be parsed"
    );
    let malformed = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::Malformed(item) if item.kind == MalformedSyntaxKind::UnclosedChord => {
                Some(item)
            }
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(malformed.len(), 1);
    assert_eq!(
        &source[malformed[0].span.start..malformed[0].span.end],
        "[ f ",
        "the malformed syntax span should stay on the malformed bracket run"
    );
}

#[test]
fn unclosed_chord_quoted_run_before_barline_recovers_members_as_notes() {
    let source = "X:1\nM:4/4\nL:1/8\nK:C\n|[F\"cont\" \"Am7\"GAB cAFA |\n";
    let document_report = parse_document(source, ParseOptions::default());
    assert_eq!(
        count_diagnostics(&document_report.diagnostics, "abc.music.unclosed_chord"),
        1,
        "the unclosed bracket should yield exactly one unclosed-chord diagnostic"
    );
    let diagnostic = document_report
        .diagnostics
        .iter()
        .find(|diagnostic| diagnostic.code == "abc.music.unclosed_chord")
        .expect("expected unclosed-chord diagnostic");
    assert_eq!(
        &source[diagnostic.span.start..diagnostic.span.end],
        "[F\"cont\" \"Am7\"GAB cAFA ",
        "the diagnostic span should stay on the malformed quoted bracket run before the barline"
    );

    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let note_letters = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::Note(note) => Some(note.pitch.step),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(
        note_letters,
        vec!['F', 'G', 'A', 'B', 'c', 'A', 'F', 'A'],
        "all notes inside the unclosed bracket should recover in source order"
    );
}

#[test]
fn unclosed_chord_inside_grace_group_does_not_recover_members_as_main_notes() {
    let source = "X:1\nL:1/8\nK:C\n{[CDE | G} A|\n";
    let document_report = parse_document(source, ParseOptions::default());
    assert_eq!(
        count_diagnostics(&document_report.diagnostics, "abc.music.unclosed_chord"),
        1,
        "the grace-internal unclosed bracket should still diagnose once"
    );
    let diagnostic = document_report
        .diagnostics
        .iter()
        .find(|diagnostic| diagnostic.code == "abc.music.unclosed_chord")
        .expect("expected unclosed-chord diagnostic");
    assert_eq!(
        &source[diagnostic.span.start..diagnostic.span.end],
        "[CDE ",
        "the diagnostic span should stay on the malformed grace-internal bracket run"
    );

    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let note_letters = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::Note(note) => Some(note.pitch.step),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(
        note_letters,
        vec!['A'],
        "grace-internal malformed chord members must not leak as mainline notes"
    );
}

#[test]
fn valid_chord_before_barline_does_not_swallow_following_note() {
    // Guard: a closed chord immediately followed by a barline parses as a chord
    // then a barline, with no unclosed-chord diagnostic and the following note
    // intact.
    let document_report = parse_document("X:1\nL:1/8\nK:C\n[CEG]|D\n", ParseOptions::default());
    assert_eq!(
        count_diagnostics(&document_report.diagnostics, "abc.music.unclosed_chord"),
        0,
        "a closed chord must not report an unclosed-chord diagnostic"
    );
    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let chord = tune_music.lines[0]
        .items
        .iter()
        .find_map(|item| match item {
            MusicItem::Chord(chord) => Some(chord),
            _ => None,
        })
        .expect("expected chord");
    assert_eq!(chord.members.len(), 3);
    let trailing_note = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::Note(note) => Some(note.pitch.step),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(
        trailing_note,
        vec!['D'],
        "the D after the barline must survive"
    );
}

#[test]
fn lowers_chord_member_and_outer_duration_multipliers() {
    let (events, diagnostics) = events_for("X:1\nL:1/8\nK:C\n[C2E2G2]3\n");
    assert!(diagnostics.is_empty());
    let notes = events
        .iter()
        .filter_map(|event| match event {
            Event::Note {
                step,
                duration,
                chord,
                ..
            } => Some((*step, *duration, *chord)),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(
        notes,
        vec![('C', 24, false), ('E', 24, true), ('G', 24, true)]
    );
}

#[test]
fn variable_duration_chord_members_emit_diagnostic() {
    let document = parse_document("X:1\nL:1/8\nK:C\n[E2G,6]\n", ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);

    assert!(
        report
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.chord.variable_duration")
    );
}

#[test]
fn broken_rhythm_is_transparent_across_grace_groups() {
    let (left_events, left_diagnostics) = events_for("X:1\nL:1/8\nK:C\nA<{g}A\n");
    let (right_events, right_diagnostics) = events_for("X:1\nL:1/8\nK:C\nA{g}<A\n");

    assert!(left_diagnostics.is_empty());
    assert!(right_diagnostics.is_empty());
    let durations = |events: Vec<Event>| {
        events
            .into_iter()
            .filter_map(|event| match event {
                Event::Note { duration, .. } => Some(duration),
                _ => None,
            })
            .collect::<Vec<_>>()
    };
    assert_eq!(durations(left_events), durations(right_events));
}

#[test]
fn parses_staccato_triplet_without_spaces() {
    let document_report = parse_document("X:1\nL:1/8\nK:C\n(3.a.b.c\n", ParseOptions::default());
    assert!(document_report.diagnostics.is_empty());
    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");

    assert!(matches!(tune_music.lines[0].items[0], MusicItem::Tuplet(_)));
    let staccato_count = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::Note(note) => Some(&note.attachments.decorations),
            _ => None,
        })
        .filter(|decorations| decorations.iter().any(|decoration| decoration.name == "."))
        .count();
    assert_eq!(staccato_count, 3);
}

#[test]
fn parses_adjacent_repeat_endings_after_barlines() {
    let document_report = parse_document("X:1\nK:C\n:|2 C|1D A:|2B\n", ParseOptions::default());
    assert!(document_report.diagnostics.is_empty());
    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");

    let endings = tune_music.lines[0]
        .items
        .iter()
        .filter(|item| matches!(item, MusicItem::VariantEnding(_)))
        .count();
    let repeat_ends = tune_music.lines[0]
        .items
        .iter()
        .filter(|item| {
            matches!(
                item,
                MusicItem::Barline(BarlineSyntax {
                    kind: BarlineKind::RepeatEnd,
                    ..
                })
            )
        })
        .count();
    assert_eq!(endings, 3);
    assert_eq!(repeat_ends, 2);
}

#[test]
fn parses_bracketed_variant_ending_lists_and_ranges() {
    let document_report = parse_document(
        "X:1\nK:C\n[1 C | [2 D | [1,3] E | [1-3] F | [1,3,5-7] G\n",
        ParseOptions::default(),
    );
    assert!(document_report.diagnostics.is_empty());
    let tune_music = document_report
        .value
        .music
        .tune(0)
        .expect("expected parsed tune music");
    let endings = tune_music.lines[0]
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::VariantEnding(ending) => Some(ending),
            _ => None,
        })
        .collect::<Vec<_>>();

    assert_eq!(endings.len(), 5);
    assert_eq!(endings[0].endings.len(), 1);
    assert_eq!(endings[2].endings.len(), 2);
    assert!(matches!(
        endings[3].endings[0],
        VariantEndingPart::Range { .. }
    ));
    assert_eq!(endings[4].endings.len(), 3);
}

#[test]
fn repeat_ending_shorthand_must_be_adjacent() {
    let legal = parse_document("X:1\nK:C\nC| [1D\n", ParseOptions::default());
    assert!(
        !legal
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.invalid_repeat_ending")
    );

    let spaced = parse_document("X:1\nK:C\nC| 1D\n", ParseOptions::default());
    assert!(
        spaced
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.invalid_repeat_ending")
    );
}

#[test]
fn unclosed_slurs_are_recoverable_in_lowering() {
    let document = parse_document("X:1\nK:C\n(C D\n", ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);

    assert!(
        report
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unclosed_slur")
    );
    assert_eq!(
        report
            .value
            .expect("expected tune")
            .events
            .iter()
            .filter(|event| matches!(event, Event::Note { .. }))
            .count(),
        2
    );
}

#[test]
fn non_music_lines_and_chords_do_not_leak_comments_or_directives() {
    let document_report = parse_document(
        "X:1\nT:ABC\n+:DEF\nK:C\n%%text GAB\n[CDE] C % FED\n",
        ParseOptions::default(),
    );
    let report = parse_tune_report_from_document(&document_report.value);
    let events = report.value.expect("expected tune").events;

    let notes = events
        .iter()
        .filter(|event| matches!(event, Event::Note { .. }))
        .count();
    assert_eq!(notes, 4);
}

#[test]
fn lowers_sequential_body_voice_blocks_to_explicit_timelines() {
    let document =
        parse_document("X:1\nK:C\nV:1\nC D|\nV:2\nE F|\n", ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");

    assert_eq!(
        tune.voices
            .iter()
            .map(|voice| voice.id.value.as_str())
            .collect::<Vec<_>>(),
        vec!["1", "2"]
    );
    let note_counts = tune
        .voices
        .iter()
        .map(|voice| {
            voice
                .measures
                .iter()
                .flat_map(|measure| &measure.events)
                .filter(|event| matches!(event.kind, TimelineEventKind::Note { .. }))
                .count()
        })
        .collect::<Vec<_>>();
    assert_eq!(note_counts, vec![2, 2]);
}

#[test]
fn lowers_inline_voice_switches_to_interleaved_timelines() {
    let document = parse_document(
        "X:1\nK:C\n[V:T1] C D| [V:T2] E F|\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");

    assert_eq!(tune.voices.len(), 2);
    assert!(tune.voices.iter().any(|voice| voice.id.value == "T1"));
    assert!(tune.voices.iter().any(|voice| voice.id.value == "T2"));
    let inline_voice = document
        .music
        .tune(0)
        .expect("expected parsed tune music")
        .lines
        .first()
        .expect("expected music line")
        .items
        .iter()
        .find_map(|item| match item {
            MusicItem::InlineField(field) if field.code == 'V' => Some(field),
            _ => None,
        })
        .expect("expected inline V field");
    assert_eq!(inline_voice.value.value, "T1");
}

#[test]
fn inline_field_after_barline_is_not_swallowed_into_a_liberal_barline() {
    // `|[M:3/8]` must parse as a plain barline followed by an inline field,
    // not as a liberal `|[` combined barline. The old greedy barline scan
    // ate the `[`, mangling the field and inserting a spurious empty bar.
    let document = parse_document(
        "X:1\nL:1/4\nM:6/8\nK:C\nC3|[M:3/8]E2E|[M:6/8]F2G|\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");

    let non_empty: Vec<usize> = tune.voices[0]
        .measures
        .iter()
        .map(|measure| {
            measure
                .events
                .iter()
                .filter(|event| event.alignable)
                .count()
        })
        .collect();
    assert_eq!(
        non_empty,
        vec![1, 2, 2],
        "no spurious empty measures: {non_empty:?}"
    );

    let line = document
        .music
        .tune(0)
        .expect("expected parsed tune music")
        .lines
        .first()
        .expect("expected music line");
    let inline_codes: Vec<char> = line
        .items
        .iter()
        .filter_map(|item| match item {
            MusicItem::InlineField(field) => Some(field.code),
            _ => None,
        })
        .collect();
    assert_eq!(inline_codes, vec!['M', 'M']);
}

#[test]
fn aligns_postponed_and_adjacent_lyrics_under_abc21_cursor_rules() {
    let document = parse_document(
            "X:1\nK:C\nC D E F|\nG A B c|\nw: doh re mi fa sol la ti doh\nw: alt verse words here more text ok done\n",
            ParseOptions::default(),
        )
        .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let lyrics = tune.voices[0]
        .measures
        .iter()
        .flat_map(|measure| &measure.events)
        .flat_map(|event| &event.lyrics)
        .filter(|lyric| lyric.control == LyricControl::Syllable)
        .map(|lyric| (lyric.verse, lyric.text.as_str()))
        .collect::<Vec<_>>();

    assert!(lyrics.contains(&(1, "doh")));
    assert!(lyrics.contains(&(1, "doh")));
    assert!(lyrics.contains(&(2, "alt")));
}

#[test]
fn empty_lyric_line_consumes_notes_and_later_lyrics_start_after_them() {
    let document = parse_document(
        "X:1\nK:C\nC D E F|\nw:\nG A B c|\nw: sol la ti doh\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let lyrics = tune.voices[0]
        .measures
        .iter()
        .flat_map(|measure| &measure.events)
        .filter_map(|event| {
            event
                .lyrics
                .iter()
                .find(|lyric| lyric.control == LyricControl::Syllable)
                .map(|lyric| lyric.text.as_str())
        })
        .collect::<Vec<_>>();

    assert_eq!(lyrics, vec!["sol", "la", "ti", "doh"]);
}

#[test]
fn lyrics_skip_rests_spacers_grace_notes_and_bar_marker_advances() {
    let document = parse_document(
        "X:1\nK:C\nC z y {g}D|E F|\nw: one | two three\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let aligned = tune.voices[0]
        .measures
        .iter()
        .flat_map(|measure| &measure.events)
        .filter_map(|event| {
            event
                .lyrics
                .iter()
                .find(|lyric| lyric.control == LyricControl::Syllable)
                .map(|lyric| lyric.text.as_str())
        })
        .collect::<Vec<_>>();

    assert_eq!(aligned, vec!["one", "two", "three"]);
}

#[test]
fn overlay_rewinds_to_previous_barline_and_warns_when_incomplete() {
    let document = parse_document("X:1\nL:1/8\nK:C\nC D & E|\n", ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");

    assert!(
        report
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.voice.overlay_incomplete_measure")
    );
    let overlay = &tune.voices[0].measures[0].overlays[0];
    assert_eq!(overlay.events.len(), 1);
    assert_eq!(overlay.events[0].onset, Fraction::zero());
}

#[test]
fn lyrics_align_to_overlay_notes_in_source_order() {
    let document = parse_document(
        "X:1\nM:C\nL:1/8\nK:G\nG2|A Ac2B BG2|ABc2B4|A3B c2B> A|(A2G2) E2F2|\nw:|||All a-lone and so lone-*ly\nG2F2 E2DC|D2D2 D2\"^*\"D> D&x6C> D|E2F E D2D2|E4 HA2||\nw:|***And tis down by the green-wood side-oh!\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let voice = &tune.voices[0];
    let overlay = voice
        .measures
        .iter()
        .flat_map(|measure| &measure.overlays)
        .next()
        .expect("expected overlay segment");
    let overlay_lyrics = overlay
        .events
        .iter()
        .filter(|event| event.alignable)
        .map(|event| {
            event
                .lyrics
                .iter()
                .find(|lyric| lyric.control == LyricControl::Syllable)
                .map(|lyric| lyric.text.as_str())
        })
        .collect::<Vec<_>>();
    assert_eq!(overlay_lyrics, vec![Some("down"), Some("by")]);

    let main_lyrics = voice
        .measures
        .iter()
        .flat_map(|measure| &measure.events)
        .flat_map(|event| &event.lyrics)
        .filter(|lyric| lyric.control == LyricControl::Syllable)
        .map(|lyric| lyric.text.as_str())
        .collect::<Vec<_>>();
    assert!(!main_lyrics.contains(&"down"));
    assert!(!main_lyrics.contains(&"by"));
}

#[test]
fn symbol_lines_align_to_notes_and_preserve_symbol_kinds() {
    let document = parse_document(
        "X:1\nK:C\nC z D E F|\ns: \"C\" * !>! \"^slow\"\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let symbols = tune.voices[0]
        .measures
        .iter()
        .flat_map(|measure| &measure.events)
        .flat_map(|event| &event.symbols)
        .map(|symbol| (symbol.text.as_str(), symbol.kind))
        .collect::<Vec<_>>();

    assert_eq!(
        symbols,
        vec![
            ("C", AlignedSymbolKind::ChordSymbol),
            (">", AlignedSymbolKind::Decoration),
            ("^slow", AlignedSymbolKind::Annotation),
        ]
    );
}

#[test]
fn preserves_score_directives_and_post_tune_words_in_lowered_tune() {
    let document = parse_document(
        "X:1\nK:C\n%%score (T1 T2)\nC\nW:after words\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");

    assert_eq!(tune.score_directives.len(), 1);
    assert_eq!(tune.score_directives[0].value.text, "(T1 T2)");
    assert_eq!(tune.post_tune_lyrics[0].text, "after words");
}

#[test]
fn preserves_header_score_directive_and_header_words() {
    let document = parse_document(
        "X:1\nI:score (A B)\nW:header words\nK:C\nC\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");

    assert_eq!(tune.score_directives.len(), 1);
    assert_eq!(tune.score_directives[0].value.text, "(A B)");
    assert_eq!(tune.post_tune_lyrics[0].text, "header words");
}

#[test]
fn body_voice_properties_override_header_definition_in_timeline() {
    let document = parse_document(
        "X:1\nV:1 name=Header\nK:C\nV:1 name=Body stem=down\nC\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");

    assert_eq!(
        tune.voices[0]
            .properties
            .name
            .as_ref()
            .map(|name| name.text.as_str()),
        Some("Body")
    );
    assert_eq!(
        tune.voices[0].properties.stem,
        Some(StemDirectionModel::Down)
    );
}

#[test]
fn lyrics_controls_preserve_text_and_diagnose_excess_tokens() {
    let source = "X:1\nK:C\nC D E F G A|\nw: time__ of~the \\-dash * extra too\n";
    let document = parse_document(source, ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let note_lyrics = tune.voices[0].measures[0]
        .events
        .iter()
        .filter(|event| event.alignable)
        .map(|event| {
            event
                .lyrics
                .iter()
                .map(|lyric| (lyric.control, lyric.text.as_str()))
                .collect::<Vec<_>>()
        })
        .collect::<Vec<_>>();

    assert_eq!(note_lyrics[0], vec![(LyricControl::Syllable, "time")]);
    assert_eq!(note_lyrics[1], vec![(LyricControl::Extender, "")]);
    assert_eq!(note_lyrics[2], vec![(LyricControl::Extender, "")]);
    assert_eq!(note_lyrics[3], vec![(LyricControl::Syllable, "of the")]);
    assert_eq!(note_lyrics[4], vec![(LyricControl::Syllable, "-dash")]);
    assert!(note_lyrics[5].is_empty());
    assert_eq!(
        count_diagnostics(&report.diagnostics, "abc.lyric.syllable_count"),
        2
    );
}

#[test]
fn lyric_continuation_line_splits_on_inserted_space_not_newline() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F|\nw:a b\n+:c d\n";
    let document = parse_document(source, ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let lyrics = tune.voices[0].measures[0]
        .events
        .iter()
        .flat_map(|event| &event.lyrics)
        .filter(|lyric| lyric.control == LyricControl::Syllable)
        .map(|lyric| lyric.text.as_str())
        .collect::<Vec<_>>();

    assert_eq!(lyrics, vec!["a", "b", "c", "d"]);
    assert_eq!(
        count_diagnostics(&report.diagnostics, "abc.lyric.syllable_count"),
        0
    );
}

#[test]
fn lyric_bar_marker_is_ignored_when_cursor_is_already_at_measure_boundary() {
    let document = parse_document(
        "X:1\nK:C\nC D|E F|\nw: one two | three four\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let aligned = tune.voices[0]
        .measures
        .iter()
        .flat_map(|measure| &measure.events)
        .filter_map(|event| {
            event
                .lyrics
                .iter()
                .find(|lyric| lyric.control == LyricControl::Syllable)
                .map(|lyric| lyric.text.as_str())
        })
        .collect::<Vec<_>>();

    assert_eq!(aligned, vec!["one", "two", "three", "four"]);
}

fn syllables_per_measure(source: &str) -> Vec<Vec<String>> {
    let document = parse_document(source, ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    tune.voices[0]
        .measures
        .iter()
        .map(|measure| {
            measure
                .events
                .iter()
                .filter(|event| event.alignable)
                .map(|event| {
                    event
                        .lyrics
                        .iter()
                        .find(|lyric| {
                            matches!(
                                lyric.control,
                                LyricControl::Syllable | LyricControl::Extender
                            )
                        })
                        .map(|lyric| lyric.text.clone())
                        .unwrap_or_default()
                })
                .collect::<Vec<_>>()
        })
        .collect::<Vec<_>>()
}

#[test]
fn leading_bar_marker_advances_past_a_filled_first_measure() {
    // Per ABC 2.1 section 5.1 a `|` "advances to the next bar". When a lyric
    // line opens with `|`, the notes of the first measure carry no syllable
    // and the first word lands on the downbeat of the second measure. The
    // earlier cursor model wrongly treated the line-start boundary as an
    // already-synced barline and kept the word on the pickup note.
    let per_measure = syllables_per_measure("X:1\nL:1/4\nK:C\nG z|c d|e f|\nw: |Oh well\n");
    assert_eq!(per_measure[0], vec!["".to_owned()]);
    assert_eq!(per_measure[1], vec!["Oh".to_owned(), "well".to_owned()]);
}

#[test]
fn lyric_bar_marker_advances_across_rest_only_measure() {
    let per_measure = syllables_per_measure("X:1\nL:1/4\nK:C\nz2|C D|E F|\nw: |one two\n");

    assert!(per_measure[0].is_empty());
    assert_eq!(per_measure[1], vec!["one".to_owned(), "two".to_owned()]);
    assert_eq!(per_measure[2], vec!["".to_owned(), "".to_owned()]);
}

#[test]
fn lyric_bar_markers_after_rest_only_gap_start_from_previous_block() {
    let per_measure = syllables_per_measure("X:1\nL:1/4\nK:C\nC|\nw: old\nz|z|D|E|\nw: ||new\n");

    assert_eq!(per_measure[0], vec!["old".to_owned()]);
    assert!(per_measure[1].is_empty());
    assert!(per_measure[2].is_empty());
    assert_eq!(per_measure[3], vec!["new".to_owned()]);
    assert_eq!(per_measure[4], vec!["".to_owned()]);
}

#[test]
fn consecutive_bar_markers_each_advance_one_measure() {
    // `|||` must skip three bars, not collapse into a single no-op at the
    // first boundary. The first word therefore lands in the fourth measure.
    let per_measure = syllables_per_measure("X:1\nL:1/4\nK:C\nG|c d|e f|g a|b c|\nw:|||All done\n");
    assert!(per_measure[0].iter().all(|text| text.is_empty()));
    assert!(per_measure[1].iter().all(|text| text.is_empty()));
    assert!(per_measure[2].iter().all(|text| text.is_empty()));
    assert_eq!(per_measure[3], vec!["All".to_owned(), "done".to_owned()]);
}

#[test]
fn double_hyphen_holds_a_blank_note_instead_of_a_literal_dash() {
    // `tri--umph` spans three notes with the middle one blank (ABC 2.1
    // section 5.1: a hyphen preceded by another hyphen is a separate, empty
    // syllable). The middle note must not export the literal "-" text.
    let document = parse_document(
        "X:1\nL:1/4\nK:C\nc d e|\nw: tri--umph\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let texts = tune.voices[0].measures[0]
        .events
        .iter()
        .filter(|event| event.alignable)
        .map(|event| {
            event
                .lyrics
                .iter()
                .find(|lyric| lyric.control == LyricControl::Syllable)
                .map(|lyric| lyric.text.clone())
        })
        .collect::<Vec<_>>();
    assert_eq!(
        texts,
        vec![Some("tri".to_owned()), None, Some("umph".to_owned())]
    );
}

#[test]
fn symbol_bar_boundary_and_excess_symbol_are_diagnosed_without_realigning() {
    let document = parse_document(
        "X:1\nK:C\nC D|E F|\ns: \"C\" !>! | \"^slow\" !fermata! !extra!\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let aligned = tune.voices[0]
        .measures
        .iter()
        .flat_map(|measure| &measure.events)
        .flat_map(|event| &event.symbols)
        .map(|symbol| (symbol.text.as_str(), symbol.kind))
        .collect::<Vec<_>>();

    assert_eq!(
        aligned,
        vec![
            ("C", AlignedSymbolKind::ChordSymbol),
            (">", AlignedSymbolKind::Decoration),
            ("^slow", AlignedSymbolKind::Annotation),
            ("fermata", AlignedSymbolKind::Decoration),
        ]
    );
    assert_eq!(
        count_diagnostics(&report.diagnostics, "abc.symbol.count"),
        1
    );
}

#[test]
fn lyrics_use_the_current_voice_cursor_in_interleaved_body_fields() {
    let document = parse_document(
        "X:1\nK:C\nV:1\nC D|\nw: one two\nV:2\nE F|\nw: three four\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let lyrics_for_voice = |voice_id: &str| {
        tune.voices
            .iter()
            .find(|voice| voice.id.value == voice_id)
            .expect("expected voice")
            .measures
            .iter()
            .flat_map(|measure| &measure.events)
            .flat_map(|event| &event.lyrics)
            .filter(|lyric| lyric.control == LyricControl::Syllable)
            .map(|lyric| lyric.text.as_str())
            .collect::<Vec<_>>()
    };

    assert_eq!(lyrics_for_voice("1"), vec!["one", "two"]);
    assert_eq!(lyrics_for_voice("2"), vec!["three", "four"]);
}

#[test]
fn body_voice_field_can_switch_voice_and_carry_same_line_music() {
    let document = parse_document(
        "X:1\nL:1/8\nK:C\nV:1 C D|\nV:2 E F|\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    assert!(!report.has_errors());
    let tune = report
        .value
        .as_ref()
        .expect("expected same-line voice music");
    let notes_for_voice = |voice_id: &str| {
        tune.voices
            .iter()
            .find(|voice| voice.id.value == voice_id)
            .expect("expected voice")
            .measures
            .iter()
            .flat_map(|measure| &measure.events)
            .filter_map(|event| match &event.kind {
                TimelineEventKind::Note { step, .. } => Some(*step),
                _ => None,
            })
            .collect::<Vec<_>>()
    };

    assert_eq!(notes_for_voice("1"), vec!['C', 'D']);
    assert_eq!(notes_for_voice("2"), vec!['E', 'F']);
}

#[test]
fn body_voice_properties_are_not_treated_as_same_line_music() {
    let document =
        parse_document("X:1\nK:C\nV:1 clef=treble\nC D|\n", ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected following music line");
    let voice = tune
        .voices
        .iter()
        .find(|voice| voice.id.value == "1")
        .expect("expected voice");

    assert_eq!(
        voice
            .properties
            .clef
            .as_ref()
            .map(|clef| clef.text.as_str()),
        Some("treble")
    );
    assert_eq!(
        voice.measures[0]
            .events
            .iter()
            .filter(|event| matches!(event.kind, TimelineEventKind::Note { .. }))
            .count(),
        2
    );
}

#[test]
fn body_voice_property_words_are_not_treated_as_same_line_music() {
    let document = parse_document(
        "X:1\nK:C\nV:1 Program 1 110 alto\nC D|\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected following music line");
    let voice = tune
        .voices
        .iter()
        .find(|voice| voice.id.value == "1")
        .expect("expected voice");

    assert_eq!(
        count_diagnostics(&report.diagnostics, "abc.music.unknown_token"),
        0
    );
    assert_eq!(
        voice.measures[0]
            .events
            .iter()
            .filter(|event| matches!(event.kind, TimelineEventKind::Note { .. }))
            .count(),
        2
    );
}

#[test]
fn body_voice_field_same_line_music_with_leading_accidental() {
    for accidental in ["=", "^", "_"] {
        let source = format!("X:1\nL:1/8\nK:C\nV:1 C D|\nV:2 {accidental}E F|\n");
        let document_report = parse_document(&source, ParseOptions::default());
        assert_eq!(
            count_diagnostics(
                &document_report.diagnostics,
                "abc.field.voice_property_ignored"
            ),
            0,
            "accidental `{accidental}` is music, not a discarded property"
        );
        let report = parse_tune_report_from_document(&document_report.value);
        assert!(!report.has_errors());
        let tune = report.value.expect("expected same-line voice music");
        let steps = tune
            .voices
            .iter()
            .find(|voice| voice.id.value == "2")
            .expect("expected voice 2")
            .measures
            .iter()
            .flat_map(|measure| &measure.events)
            .filter_map(|event| match &event.kind {
                TimelineEventKind::Note { step, .. } => Some(*step),
                _ => None,
            })
            .collect::<Vec<_>>();
        assert_eq!(steps, vec!['E', 'F'], "accidental `{accidental}`");
    }
}

#[test]
fn body_voice_field_same_line_music_with_accidental_inside_first_token() {
    let document = parse_document(
        "X:1\nL:1/8\nK:C\nV:1 C D|\nV:2 E2=F2 G2A2|\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    assert!(!report.has_errors());
    let tune = report.value.expect("expected same-line voice music");
    let steps = tune
        .voices
        .iter()
        .find(|voice| voice.id.value == "2")
        .expect("expected voice 2")
        .measures
        .iter()
        .flat_map(|measure| &measure.events)
        .filter_map(|event| match &event.kind {
            TimelineEventKind::Note { step, .. } => Some(*step),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(steps, vec!['E', 'F', 'G', 'A']);
}

#[test]
fn body_voice_field_unparseable_property_token_warns() {
    let report = parse_document(
        "X:1\nK:C\nV:1 Program =C2D2\nC D|\n",
        ParseOptions::default(),
    );
    assert_eq!(
        count_diagnostics(&report.diagnostics, "abc.field.voice_property_ignored"),
        1
    );
}

#[test]
fn overfull_overlay_measure_duration_emits_diagnostic() {
    let document = parse_document("X:1\nL:1/8\nK:C\nC & D E|\n", ParseOptions::default()).value;
    let report = parse_tune_report_from_document(&document);

    assert_eq!(
        count_diagnostics(&report.diagnostics, "abc.voice.overlay_overfull_measure"),
        1
    );
}

#[test]
fn ampersand_in_lyric_and_symbol_lines_is_not_music_overlay_syntax() {
    let document = parse_document(
        "X:1\nK:C\nC D E|\nw: Tom & Jerry\ns: & * !>!\n",
        ParseOptions::default(),
    )
    .value;
    let report = parse_tune_report_from_document(&document);
    let tune = report.value.expect("expected tune");
    let lyrics = tune.voices[0]
        .measures
        .iter()
        .flat_map(|measure| &measure.events)
        .flat_map(|event| &event.lyrics)
        .filter(|lyric| lyric.control == LyricControl::Syllable)
        .map(|lyric| lyric.text.as_str())
        .collect::<Vec<_>>();
    let symbols = tune.voices[0]
        .measures
        .iter()
        .flat_map(|measure| &measure.events)
        .flat_map(|event| &event.symbols)
        .map(|symbol| (symbol.text.as_str(), symbol.kind))
        .collect::<Vec<_>>();

    assert_eq!(lyrics, vec!["Tom", "&", "Jerry"]);
    assert_eq!(
        symbols,
        vec![
            ("&", AlignedSymbolKind::Raw),
            (">", AlignedSymbolKind::Decoration)
        ]
    );
    assert!(tune.voices[0].measures[0].overlays.is_empty());
}

#[test]
fn body_unit_length_scopes_to_current_voice_only() {
    // Header L:1/4; an `L:1/8` under V:1 must not leak into V:2
    // (tune_014637/005353 family): body fields apply to the current voice
    // and sibling/new voices keep the header default — the abcm2ps/
    // abc2midi/abc2xml convention, codified by ABC 2.2 (ABC 2.1 §7 is
    // VOLATILE/silent). The global broadcast silently under/over-filled
    // sibling voices' bars.
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "V:1\n",
        "L:1/8\n",
        "CDEF|\n",
        "V:2\n",
        "C,D,E,F,|\n",
    );
    let (tune, _diagnostics) = tune_for(source);
    let durations: Vec<Vec<_>> = tune
        .score
        .parts
        .iter()
        .map(|part| {
            part.voices[0]
                .events
                .iter()
                .filter_map(|event| match &event.kind {
                    TimedEventKind::Note(_) => Some(event.duration),
                    _ => None,
                })
                .collect()
        })
        .collect();
    // V:1 notes are eighths (1/8), V:2 notes stay quarters (header 1/4).
    assert_eq!(durations.len(), 2);
    assert!(durations[0].iter().all(|d| *d == Fraction::new(1, 8)));
    assert!(durations[1].iter().all(|d| *d == Fraction::new(1, 4)));
}

fn tune_for(source: &str) -> (crate::model::Tune, Vec<Diagnostic>) {
    let document = parse_document(source, ParseOptions::default());
    let mut diagnostics = document.diagnostics;
    let report = parse_tune_report_from_document(&document.value);
    diagnostics.extend(report.diagnostics);
    (report.value.expect("expected tune"), diagnostics)
}

fn diagnostic_span<'a>(
    source: &'a str,
    diagnostics: &'a [Diagnostic],
    code: &'static str,
) -> &'a str {
    let diagnostic = diagnostics
        .iter()
        .find(|diagnostic| diagnostic.code == code)
        .expect("expected diagnostic");
    &source[diagnostic.span.start..diagnostic.span.end]
}

fn semantic_note_events(tune: &crate::model::Tune) -> Vec<&TimedEvent> {
    tune.score.parts[0].voices[0]
        .events
        .iter()
        .filter(|event| matches!(event.kind, TimedEventKind::Note(_)))
        .collect()
}

fn semantic_note_alters(tune: &crate::model::Tune) -> Vec<i8> {
    semantic_note_events(tune)
        .into_iter()
        .map(|event| match &event.kind {
            TimedEventKind::Note(note) => note.pitch.alter,
            _ => unreachable!(),
        })
        .collect()
}

fn semantic_chord_member_alters(tune: &crate::model::Tune) -> Vec<Vec<i8>> {
    tune.score.parts[0].voices[0]
        .events
        .iter()
        .filter_map(|event| match &event.kind {
            TimedEventKind::Chord(chord) => Some(
                chord
                    .members
                    .iter()
                    .map(|member| member.pitch.alter)
                    .collect(),
            ),
            _ => None,
        })
        .collect()
}

fn leading_grace_note_alters(tune: &crate::model::Tune) -> Vec<i8> {
    semantic_note_events(tune)
        .into_iter()
        .flat_map(|event| grace_note_alters_in_attachments(&event.attachments))
        .chain(
            tune.score.parts[0].voices[0]
                .events
                .iter()
                .flat_map(|event| match &event.kind {
                    TimedEventKind::Chord(chord) => chord
                        .members
                        .iter()
                        .flat_map(|member| grace_note_alters_in_attachments(&member.attachments))
                        .collect::<Vec<_>>(),
                    _ => Vec::new(),
                }),
        )
        .collect()
}

fn grace_note_alters_in_attachments(attachments: &crate::model::EventAttachments) -> Vec<i8> {
    attachments
        .grace_groups
        .iter()
        .flat_map(|group| group.events.iter())
        .flat_map(|event| match &event.kind {
            crate::model::GraceEventKind::Note(note) => vec![note.pitch.alter],
            crate::model::GraceEventKind::Chord(notes) => {
                notes.iter().map(|note| note.pitch.alter).collect()
            }
            crate::model::GraceEventKind::Rest(_) => Vec::new(),
        })
        .collect()
}

#[test]
fn semantic_score_marks_pickup_and_keeps_fixed_measure_numbers() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC|D E F G|A B c d|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let measures = &tune.score.parts[0].voices[0].measures;
    assert_eq!(measures[0].expected_duration, Some(Fraction::new(4, 4)));
    assert_eq!(measures[0].actual_duration, Fraction::new(1, 4));
    assert!(measures[0].pickup);
    assert_eq!(
        measures
            .iter()
            .map(|measure| measure.id.number)
            .collect::<Vec<_>>(),
        vec![1, 2, 3]
    );
    assert_eq!(measures[1].actual_duration, Fraction::new(4, 4));
    assert!(measures[1].complete);
}

#[test]
fn complex_header_meter_uses_additive_duration() {
    let source = "X:1\nM:(2+3+2)/8\nL:1/8\nK:C\nCDEFGAB|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let meter = tune.score.metadata.meter.as_ref().expect("header meter");
    assert_eq!(meter.duration, Some(Fraction::new(7, 8)));
    assert!(!meter.free_meter);
    let measures = &tune.score.parts[0].voices[0].measures;
    assert_eq!(measures[0].expected_duration, Some(Fraction::new(7, 8)));
    assert_eq!(measures[0].actual_duration, Fraction::new(7, 8));
    assert!(measures[0].complete);
}

#[test]
fn additive_extension_header_meter_uses_summed_duration() {
    let source = "X:1\nM:3/4+2/4\nL:1/4\nK:C\nCDEFG|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let meter = tune.score.metadata.meter.as_ref().expect("header meter");
    assert_eq!(meter.duration, Some(Fraction::new(5, 4)));
    assert!(!meter.free_meter);
    let measure = &tune.score.parts[0].voices[0].measures[0];
    assert_eq!(measure.expected_duration, Some(Fraction::new(5, 4)));
    assert_eq!(measure.actual_duration, Fraction::new(5, 4));
    assert!(measure.complete);
}

#[test]
fn leading_repeat_start_stays_on_first_measure() {
    let source = "X:1\nM:3/4\nL:1/4\nK:C\n|: G c d | E D C |\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let measures = &tune.score.parts[0].voices[0].measures;
    assert_eq!(measures.len(), 2);
    assert_eq!(measures[0].actual_duration, Fraction::new(3, 4));
    assert_eq!(measures[1].actual_duration, Fraction::new(3, 4));
    assert!(
        measures[0]
            .barlines
            .iter()
            .any(|barline| barline.kind == BarlineKind::RepeatStart)
    );
    let first_measure_steps = semantic_note_events(&tune)
        .into_iter()
        .take(3)
        .map(|event| match &event.kind {
            TimedEventKind::Note(note) => note.pitch.step,
            _ => unreachable!(),
        })
        .collect::<Vec<_>>();
    assert_eq!(first_measure_steps, vec!['G', 'C', 'D']);
}

#[test]
fn plain_leading_barline_does_not_create_empty_pickup_measure() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n| E | F G A B |\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let measures = &tune.score.parts[0].voices[0].measures;
    assert_eq!(measures.len(), 2);
    assert_eq!(measures[0].actual_duration, Fraction::new(1, 4));
    assert!(measures[0].pickup);
    assert_eq!(measures[1].actual_duration, Fraction::new(4, 4));
}

#[test]
fn semantic_accidentals_propagate_within_measure_and_reset_at_barline() {
    let source = "X:1\nL:1/8\nK:C\n^F F|F\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    assert_eq!(semantic_note_alters(&tune), vec![1, 1, 0]);
    assert!(tune.score.accidental_policy.reset_at_barlines);
}

#[test]
fn written_grace_accidental_propagates_to_main_notes_in_measure() {
    let source = "X:1\nL:1/8\nK:C\n{^f}f2 f2 f4 |]\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    assert_eq!(leading_grace_note_alters(&tune), vec![1]);
    assert_eq!(semantic_note_alters(&tune), vec![1, 1, 1]);
}

#[test]
fn grace_notes_inherit_measure_accidentals_from_main_notes() {
    let source = "X:1\nL:1/8\nK:C\n^c2 {dc}d2 c4 |]\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    assert_eq!(semantic_note_alters(&tune), vec![1, 0, 1]);
    assert_eq!(leading_grace_note_alters(&tune), vec![0, 1]);
}

#[test]
fn grace_accidental_ledger_uses_voice_octave_shift() {
    let source = "X:1\nL:1/8\nK:C octave=1\n{^f}f2 f2 f4 |]\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    assert_eq!(leading_grace_note_alters(&tune), vec![1]);
    assert_eq!(semantic_note_alters(&tune), vec![1, 1, 1]);
}

#[test]
fn flushed_grace_accidentals_precede_direct_graces_on_same_note() {
    let source = "X:1\nL:1/8\nK:C\n{^f}[M:3/4]{=f}f2 f2 |]\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    assert_eq!(leading_grace_note_alters(&tune), vec![1, 0]);
    assert_eq!(semantic_note_alters(&tune), vec![0, 0]);
}

#[test]
fn chord_member_grace_accidentals_do_not_affect_earlier_members() {
    let source = "X:1\nL:1/8\nK:C\n[f{^f}f]2 |]\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    assert_eq!(leading_grace_note_alters(&tune), vec![1]);
    assert_eq!(semantic_chord_member_alters(&tune), vec![vec![0, 1]]);
}

#[test]
fn semantic_accidental_carry_persists_across_standalone_meter_change() {
    // ABC 2.1 §11.3 (`%%propagate-accidentals` default `pitch`): an explicit
    // accidental applies to same-pitch notes up to the END of the bar. A
    // mid-tune `M:` field line is not a bar line, so it must not clear the
    // measure accidental ledger (abc2xml carries the flat through too).
    let source = "X:1\nL:1/4\nK:C\n_e\nM:3/2\ne\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    assert_eq!(semantic_note_alters(&tune), vec![-1, -1]);
}

#[test]
fn semantic_accidental_carry_persists_across_inline_meter_change() {
    let source = "X:1\nL:1/4\nK:C\n_e [M:3/2] e\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    assert_eq!(semantic_note_alters(&tune), vec![-1, -1]);
}

#[test]
fn semantic_accidental_carry_persists_across_same_key_change() {
    // A mid-tune `K:` field is not a bar line either; even a same-key K:C
    // restatement must keep the carried flat (ABC 2.1 §11.3, abc2xml parity).
    let source = "X:1\nL:1/4\nK:C\n_e\nK:C\ne\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    assert_eq!(semantic_note_alters(&tune), vec![-1, -1]);
}

#[test]
fn semantic_accidental_carry_persists_across_real_key_change() {
    // K:G alters F (sharp) but says nothing about E, so the explicitly
    // flattened E keeps its in-bar carry across the key change while the F
    // after it picks up the new signature.
    let source = "X:1\nL:1/4\nK:C\n_e\nK:G\ne f\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    assert_eq!(semantic_note_alters(&tune), vec![-1, -1, 1]);
}

#[test]
fn semantic_tuplets_and_broken_rhythm_keep_rational_durations() {
    let source = "X:1\nL:1/8\nK:C\n(3CDE F>G\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let durations = semantic_note_events(&tune)
        .into_iter()
        .map(|event| event.duration)
        .collect::<Vec<_>>();
    assert_eq!(
        durations,
        vec![
            Fraction::new(1, 12),
            Fraction::new(1, 12),
            Fraction::new(1, 12),
            Fraction::new(3, 16),
            Fraction::new(1, 16),
        ]
    );
}

#[test]
fn semantic_voices_have_explicit_onsets_and_durations() {
    let source = "X:1\nL:1/4\nK:C\nV:1\nC D|\nV:2\nE2 F|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let voice = tune
        .score
        .parts
        .iter()
        .flat_map(|part| &part.voices)
        .find(|voice| voice.id.value == "2")
        .expect("expected voice 2");
    let notes = voice
        .events
        .iter()
        .filter(|event| matches!(event.kind, TimedEventKind::Note(_)))
        .map(|event| (event.onset, event.duration))
        .collect::<Vec<_>>();
    assert_eq!(
        notes,
        vec![
            (Fraction::zero(), Fraction::new(2, 4)),
            (Fraction::new(2, 4), Fraction::new(1, 4)),
        ]
    );
}

#[test]
fn semantic_lyrics_symbols_and_prefix_attachments_stay_on_intended_event() {
    let source = "X:1\nL:1/8\nK:C\n\"Am\"!trill!C D\nw: one two\ns: \"C\" !>!\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let notes = semantic_note_events(&tune);
    let first = notes[0];
    assert_eq!(first.attachments.chord_symbols[0].text, "Am");
    assert_eq!(first.attachments.decorations[0].name, "trill");
    assert_eq!(first.attachments.lyrics[0].text, "one");
    assert_eq!(first.attachments.symbols[0].text, "C");
}

#[test]
fn chord_symbol_before_grace_group_binds_to_main_note() {
    // ABC 2.1 §4.20: in `"F"{AB}c` the grace group attaches to the main note
    // `c`, and the chord symbol written before the grace binds to `c` too —
    // not to a note inside the braces.
    let source = "X:1\nL:1/8\nK:C\n\"F\"{AB}c d|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    let first = notes[0];
    assert_eq!(first.attachments.chord_symbols[0].text, "F");
    assert_eq!(first.attachments.grace_groups.len(), 1);
    assert_eq!(first.attachments.grace_groups[0].note_count, 2);
    assert!(notes[1].attachments.chord_symbols.is_empty());
}

#[test]
fn trailing_trill_grace_group_binds_as_after_grace_on_previous_note() {
    let source = "X:1\nT:Trailing Grace\nM:4/4\nL:1/8\nK:C\nTe6{de}|d2f f2f|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    assert!(matches!(
        &notes[0].kind,
        TimedEventKind::Note(note) if note.pitch.step == 'E'
    ));
    assert_eq!(notes[0].measure.number, 1);
    assert_eq!(notes[0].attachments.grace_groups.len(), 0);
    assert_eq!(notes[0].attachments.after_grace_groups.len(), 1);
    assert_eq!(notes[0].attachments.after_grace_groups[0].note_count, 2);

    assert!(matches!(
        &notes[1].kind,
        TimedEventKind::Note(note) if note.pitch.step == 'D'
    ));
    assert_eq!(notes[1].measure.number, 2);
    assert!(notes[1].attachments.grace_groups.is_empty());
    assert!(notes[1].attachments.after_grace_groups.is_empty());
}

#[test]
fn trailing_grace_after_chord_member_trill_does_not_drain_into_member() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n[CE!trill!G]2{de}|A2 A2|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let events = tune
        .score
        .parts
        .iter()
        .flat_map(|part| &part.voices)
        .flat_map(|voice| &voice.events)
        .collect::<Vec<_>>();
    let first_chord = events
        .iter()
        .find_map(|event| match &event.kind {
            TimedEventKind::Chord(chord) => Some((event, chord)),
            _ => None,
        })
        .expect("expected chord");
    assert!(first_chord.0.attachments.after_grace_groups.is_empty());
    assert!(
        first_chord
            .1
            .members
            .iter()
            .all(|member| member.attachments.after_grace_groups.is_empty())
    );

    let following_a = events
        .iter()
        .find(|event| {
            matches!(
                &event.kind,
                TimedEventKind::Note(note) if note.pitch.step == 'A'
            )
        })
        .expect("expected following A");
    assert_eq!(following_a.measure.number, 2);
    assert_eq!(following_a.attachments.grace_groups.len(), 1);
    assert_eq!(following_a.attachments.grace_groups[0].note_count, 2);
}

#[test]
fn pre_barline_grace_after_complete_measure_stays_before_barline() {
    let source = "X:1\nM:2/4\nL:1/8\nK:D\nD>E FA {G}|FF EF|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    assert_eq!(notes.len(), 8);

    let final_note_before_bar = notes[3];
    assert_eq!(final_note_before_bar.measure.number, 1);
    assert_eq!(
        final_note_before_bar.attachments.after_grace_groups.len(),
        1
    );
    assert_eq!(
        final_note_before_bar.attachments.after_grace_groups[0].note_count,
        1
    );

    let first_note_after_bar = notes[4];
    assert_eq!(first_note_after_bar.measure.number, 2);
    assert!(first_note_after_bar.attachments.grace_groups.is_empty());
}

#[test]
fn croma_after_grace_carrier_binds_previous_note_across_barline() {
    let source = "X:1\nM:3/4\nL:1/8\nK:C\nB A/ G/ ^F3/2 D/ E2[I:croma-after-grace]{DE} | D4 :|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);

    let final_note_before_bar = notes[5];
    assert!(matches!(
        &final_note_before_bar.kind,
        TimedEventKind::Note(note) if note.pitch.step == 'E'
    ));
    assert_eq!(final_note_before_bar.measure.number, 1);
    assert!(final_note_before_bar.attachments.grace_groups.is_empty());
    assert_eq!(
        final_note_before_bar
            .attachments
            .after_grace_groups
            .first()
            .map(|group| group.note_count),
        Some(2)
    );

    let first_note_after_bar = notes[6];
    assert_eq!(first_note_after_bar.measure.number, 2);
    assert!(first_note_after_bar.attachments.grace_groups.is_empty());
}

#[test]
fn chord_symbol_before_slur_open_binds_to_first_slurred_note() {
    // `"G7"(DE)`: the chord symbol rides across the slur-open and binds to
    // `D`, which also carries the slur start; `E` carries the slur stop.
    let source = "X:1\nL:1/8\nK:C\n\"G7\"(DE) F|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    let first = notes[0];
    assert_eq!(first.attachments.chord_symbols[0].text, "G7");
    assert!(
        first
            .attachments
            .slurs
            .iter()
            .any(|slur| slur.role == SlurRole::Start)
    );
    assert!(
        notes[1]
            .attachments
            .slurs
            .iter()
            .any(|slur| slur.role == SlurRole::Stop)
    );
    assert!(notes[2].attachments.chord_symbols.is_empty());
}

#[test]
fn chord_symbol_before_grace_and_slur_binds_to_main_note() {
    // `"F"{AB}(cd)`: chord symbol, grace group, and slur start all land on the
    // main note `c`.
    let source = "X:1\nL:1/8\nK:C\n\"F\"{AB}(cd)|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    let first = notes[0];
    assert_eq!(first.attachments.chord_symbols[0].text, "F");
    assert_eq!(first.attachments.grace_groups.len(), 1);
    assert!(
        first
            .attachments
            .slurs
            .iter()
            .any(|slur| slur.role == SlurRole::Start)
    );
    assert!(
        notes[1]
            .attachments
            .slurs
            .iter()
            .any(|slur| slur.role == SlurRole::Stop)
    );
}

#[test]
fn chord_symbol_before_tuplet_marker_binds_to_first_tuplet_note() {
    // `"F"(3CDE F`: the chord symbol rides across the tuplet marker and binds
    // to `C`; the tuplet roles stay intact.
    let source = "X:1\nL:1/8\nK:C\n\"F\"(3CDE F|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    let first = notes[0];
    assert_eq!(first.attachments.chord_symbols[0].text, "F");
    assert!(
        first
            .attachments
            .tuplets
            .iter()
            .any(|tuplet| tuplet.role == TupletRole::Start)
    );
    assert!(
        notes[2]
            .attachments
            .tuplets
            .iter()
            .any(|tuplet| tuplet.role == TupletRole::Stop)
    );
    assert!(notes[3].attachments.chord_symbols.is_empty());
    assert!(notes[3].attachments.tuplets.is_empty());
}

/// `(kind, tuplet roles)` for every note/rest/chord in the first voice.
/// `semantic_note_events` filters to notes only, which would hide the rests
/// these tests are about.
fn timed_tuplet_roles(tune: &crate::model::Tune) -> Vec<(&'static str, Vec<TupletRole>)> {
    tune.score.parts[0].voices[0]
        .events
        .iter()
        .filter_map(|event| {
            let kind = match &event.kind {
                TimedEventKind::Note(_) => "note",
                TimedEventKind::Rest(_) => "rest",
                TimedEventKind::Chord(_) => "chord",
                _ => return None,
            };
            let roles = event
                .attachments
                .tuplets
                .iter()
                .map(|tuplet| tuplet.role)
                .collect();
            Some((kind, roles))
        })
        .collect()
}

#[test]
fn rest_led_tuplet_carries_start_role_on_the_rest() {
    // `(3zBA`: the leading rest is the tuplet's first group, so it carries the
    // Start role just as a note would.
    let source = "X:1\nL:1/8\nK:C\n(3zBA|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    assert_eq!(
        timed_tuplet_roles(&tune),
        vec![
            ("rest", vec![TupletRole::Start]),
            ("note", vec![TupletRole::Continue]),
            ("note", vec![TupletRole::Stop]),
        ]
    );
}

#[test]
fn rest_closed_tuplet_carries_stop_role_on_the_rest() {
    // `(3BAz`: the trailing rest closes the tuplet, so it carries Stop.
    let source = "X:1\nL:1/8\nK:C\n(3BAz|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    assert_eq!(
        timed_tuplet_roles(&tune),
        vec![
            ("note", vec![TupletRole::Start]),
            ("note", vec![TupletRole::Continue]),
            ("rest", vec![TupletRole::Stop]),
        ]
    );
}

#[test]
fn all_rest_tuplet_carries_roles_on_every_rest() {
    let source = "X:1\nL:1/8\nK:C\n(3zzz|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    assert_eq!(
        timed_tuplet_roles(&tune),
        vec![
            ("rest", vec![TupletRole::Start]),
            ("rest", vec![TupletRole::Continue]),
            ("rest", vec![TupletRole::Stop]),
        ]
    );
}

#[test]
fn one_note_tuplet_carries_start_and_stop_on_same_event() {
    let source = "X:1\nL:1/8\nK:C\n(3:2:1G B|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    assert_eq!(
        timed_tuplet_roles(&tune),
        vec![
            ("note", vec![TupletRole::Start, TupletRole::Stop]),
            ("note", Vec::new()),
        ]
    );
}

#[test]
fn nested_tuplets_carry_outer_and_inner_roles() {
    let source = "X:1\nM:C\nL:1/4\nK:C\n(7:8:8(3A/A/A/ A/A/A/A/A/|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let events: Vec<_> = tune.score.parts[0].voices[0]
        .events
        .iter()
        .filter(|event| matches!(event.kind, TimedEventKind::Note(_)))
        .collect();
    assert_eq!(events.len(), 8);
    assert!(events[0].attachments.tuplets.iter().any(|tuplet| {
        tuplet.actual_notes == 7 && tuplet.normal_notes == 8 && tuplet.role == TupletRole::Start
    }));
    assert!(events[0].attachments.tuplets.iter().any(|tuplet| {
        tuplet.actual_notes == 3 && tuplet.normal_notes == 2 && tuplet.role == TupletRole::Start
    }));
    assert!(events[2].attachments.tuplets.iter().any(|tuplet| {
        tuplet.actual_notes == 3 && tuplet.normal_notes == 2 && tuplet.role == TupletRole::Stop
    }));
    assert!(events[7].attachments.tuplets.iter().any(|tuplet| {
        tuplet.actual_notes == 7 && tuplet.normal_notes == 8 && tuplet.role == TupletRole::Stop
    }));
}

#[test]
fn rest_led_tuplet_keeps_prefix_attachments_on_the_rest() {
    // `(3"C"zBA`: the chord symbol rides across the tuplet marker and binds to
    // the leading rest, which still carries the Start role.
    let source = "X:1\nL:1/8\nK:C\n(3\"C\"zBA|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let rest = tune.score.parts[0].voices[0]
        .events
        .iter()
        .find(|event| matches!(event.kind, TimedEventKind::Rest(_)))
        .expect("expected a rest event");
    assert_eq!(rest.attachments.chord_symbols[0].text, "C");
    assert!(
        rest.attachments
            .tuplets
            .iter()
            .any(|tuplet| tuplet.role == TupletRole::Start)
    );
}

#[test]
fn quoted_texts_before_and_after_grace_group_keep_order() {
    // `"F"{AB}"G"c`: both chord symbols bind to `c`, in source order.
    let source = "X:1\nL:1/8\nK:C\n\"F\"{AB}\"G\"c d|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    let first = notes[0];
    assert_eq!(
        first
            .attachments
            .chord_symbols
            .iter()
            .map(|symbol| symbol.text.as_str())
            .collect::<Vec<_>>(),
        vec!["F", "G"]
    );
    assert_eq!(first.attachments.grace_groups.len(), 1);
}

#[test]
fn decoration_before_grace_group_binds_to_main_note() {
    // `!trill!{AB}c`: the decoration rides the same pending bundle as quoted
    // text, so it must survive the grace group and bind to `c`.
    let source = "X:1\nL:1/8\nK:C\n!trill!{AB}c d|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    let first = notes[0];
    assert_eq!(first.attachments.decorations[0].name, "trill");
    assert_eq!(first.attachments.grace_groups.len(), 1);
    assert!(notes[1].attachments.decorations.is_empty());
}

#[test]
fn quoted_text_after_grace_or_inside_slur_stays_bound() {
    // Controls that already worked before the prefix-attachment fix: quoted
    // text AFTER the grace group, and INSIDE the slur parens.
    let (tune, diagnostics) = tune_for("X:1\nL:1/8\nK:C\n{AB}\"F\"c d|\n");
    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    assert_eq!(notes[0].attachments.chord_symbols[0].text, "F");
    assert_eq!(notes[0].attachments.grace_groups.len(), 1);

    let (tune, diagnostics) = tune_for("X:1\nL:1/8\nK:C\n(\"G7\"DE) F|\n");
    assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
    let notes = semantic_note_events(&tune);
    assert_eq!(notes[0].attachments.chord_symbols[0].text, "G7");
    assert!(
        notes[0]
            .attachments
            .slurs
            .iter()
            .any(|slur| slur.role == SlurRole::Start)
    );
}

#[test]
fn unclosed_grace_group_after_quoted_text_still_diagnoses() {
    // `"F"{AB c`: the unclosed grace group swallows the rest of the line and
    // must keep emitting its diagnostic; the pending chord symbol must not
    // suppress it or panic. The symbol itself is not voided by the malformed
    // grace (ABC 2.1 §4.18 binds it to the note it precedes): it recovers
    // onto the first surviving note instead of vanishing silently.
    let source = "X:1\nL:1/8\nK:C\n\"F\"{AB c\nd e|\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        count_diagnostics(&diagnostics, "abc.music.unclosed_grace"),
        1
    );
    let notes = semantic_note_events(&tune);
    let symbols: Vec<_> = notes
        .iter()
        .flat_map(|note| note.attachments.chord_symbols.iter())
        .map(|symbol| symbol.text.as_str())
        .collect();
    assert_eq!(
        symbols,
        ["F"],
        "the recovered symbol binds once, to the first surviving note"
    );
    assert_eq!(
        notes
            .first()
            .map(|note| note.attachments.chord_symbols.len()),
        Some(1)
    );
}

#[test]
fn broken_rhythm_without_neighbors_diagnoses_and_keeps_timing_stable() {
    let source = "X:1\nL:1/8\nK:C\n< C D >|\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.broken_rhythm.missing_left"),
        "<"
    );
    assert_eq!(
        diagnostic_span(
            source,
            &diagnostics,
            "abc.music.broken_rhythm.missing_right"
        ),
        ">"
    );
    let durations = semantic_note_events(&tune)
        .into_iter()
        .map(|event| event.duration)
        .collect::<Vec<_>>();
    assert_eq!(durations, vec![Fraction::new(1, 8), Fraction::new(1, 8)]);
    assert_eq!(
        tune.score.parts[0].voices[0].measures[0].actual_duration,
        Fraction::new(2, 8)
    );
}

#[test]
fn broken_rhythm_after_barline_does_not_bind_across_bar() {
    // ABC 2.1 §4.4: `>` dots the *previous* note. After a barline there is no
    // previous note for a leading `>`, so it must be void: `c2` keeps 1/4 and
    // `d` keeps 1/8 instead of dotting `c2` across the bar.
    let source = "X:1\nL:1/8\nK:C\nc2|>d e\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.broken_rhythm.missing_left"),
        ">"
    );
    let durations = semantic_note_events(&tune)
        .into_iter()
        .map(|event| event.duration)
        .collect::<Vec<_>>();
    assert_eq!(
        durations,
        vec![
            Fraction::new(2, 8),
            Fraction::new(1, 8),
            Fraction::new(1, 8)
        ]
    );
}

#[test]
fn broken_rhythm_after_barline_single_note_left_does_not_bind() {
    let source = "X:1\nL:1/8\nK:C\nB|>d e\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.broken_rhythm.missing_left"),
        ">"
    );
    let durations = semantic_note_events(&tune)
        .into_iter()
        .map(|event| event.duration)
        .collect::<Vec<_>>();
    assert_eq!(
        durations,
        vec![
            Fraction::new(1, 8),
            Fraction::new(1, 8),
            Fraction::new(1, 8)
        ]
    );
}

#[test]
fn broken_rhythm_within_measure_still_applies() {
    let source = "X:1\nL:1/8\nK:C\na>b\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let durations = semantic_note_events(&tune)
        .into_iter()
        .map(|event| event.duration)
        .collect::<Vec<_>>();
    assert_eq!(durations, vec![Fraction::new(3, 16), Fraction::new(1, 16)]);
}

#[test]
fn broken_rhythm_dangling_right_still_void() {
    let source = "X:1\nL:1/8\nK:C\nA>|B c\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(
            source,
            &diagnostics,
            "abc.music.broken_rhythm.missing_right"
        ),
        ">"
    );
    let durations = semantic_note_events(&tune)
        .into_iter()
        .map(|event| event.duration)
        .collect::<Vec<_>>();
    assert_eq!(
        durations,
        vec![
            Fraction::new(1, 8),
            Fraction::new(1, 8),
            Fraction::new(1, 8)
        ]
    );
}

#[test]
fn broken_rhythm_same_measure_multi_unit_left_still_applies() {
    let source = "X:1\nL:1/8\nK:C\nc2>d e\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let durations = semantic_note_events(&tune)
        .into_iter()
        .map(|event| event.duration)
        .collect::<Vec<_>>();
    assert_eq!(
        durations,
        vec![
            Fraction::new(3, 8),
            Fraction::new(1, 16),
            Fraction::new(1, 8)
        ]
    );
}

#[test]
fn broken_rhythm_grace_transparency_still_applies() {
    let source = "X:1\nL:1/8\nK:C\nc>{g}d\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let durations = semantic_note_events(&tune)
        .into_iter()
        .map(|event| event.duration)
        .collect::<Vec<_>>();
    assert_eq!(durations, vec![Fraction::new(3, 16), Fraction::new(1, 16)]);
}

#[test]
fn short_tuplet_does_not_consume_notes_after_barline() {
    let source = "X:1\nL:1/8\nK:C\n(3C|D E|\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.tuplet.too_few_notes"),
        "(3"
    );
    let durations = semantic_note_events(&tune)
        .into_iter()
        .map(|event| event.duration)
        .collect::<Vec<_>>();
    assert_eq!(
        durations,
        vec![
            Fraction::new(1, 12),
            Fraction::new(1, 8),
            Fraction::new(1, 8)
        ]
    );
    assert_eq!(
        tune.score.parts[0].voices[0].measures[1].actual_duration,
        Fraction::new(2, 8)
    );
}

#[test]
fn unmatched_tie_preserves_unmerged_note_events() {
    let source = "X:1\nL:1/8\nK:C\nC- D E\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
        "-"
    );
    let notes = semantic_note_events(&tune);
    assert_eq!(notes.len(), 3);
    assert!(notes.iter().all(|event| event.attachments.ties.is_empty()));
    assert_eq!(
        notes.iter().map(|event| event.duration).collect::<Vec<_>>(),
        vec![
            Fraction::new(1, 8),
            Fraction::new(1, 8),
            Fraction::new(1, 8)
        ]
    );
}

#[test]
fn post_barline_tie_marker_does_not_bind_across_the_bar() {
    // `C |- C` places the tie `-` AFTER the barline — the illegal `abc|-cba`
    // form (ABC 2.1 §4.11; the legal cross-bar tie is `a-|a` with `-` before the
    // bar). croma must not bind it backward across the barline: it emits no tie
    // and warns, rather than fabricating a cross-bar tie.
    let source = "X:1\nM:2/4\nL:1/4\nK:C\nC |- C D |\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
        "-"
    );
    let notes = semantic_note_events(&tune);
    assert!(
        notes.iter().all(|event| event.attachments.ties.is_empty()),
        "a post-barline tie marker must not produce any tie attachment"
    );
}

#[test]
fn ties_resolve_across_barlines_without_changing_measure_timing() {
    let source = "X:1\nM:2/4\nL:1/4\nK:C\nC- | C D |\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(
        !diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
    );
    let notes = semantic_note_events(&tune);
    assert_eq!(notes.len(), 3);
    assert_eq!(
        notes
            .iter()
            .map(|event| event.attachments.ties.first().map(|tie| tie.role))
            .collect::<Vec<_>>(),
        vec![Some(TieRole::Start), Some(TieRole::Stop), None]
    );
    assert_eq!(
        notes[0].attachments.ties[0].pair_id,
        notes[1].attachments.ties[0].pair_id
    );
    assert_eq!(
        notes.iter().map(|event| event.duration).collect::<Vec<_>>(),
        vec![
            Fraction::new(1, 4),
            Fraction::new(1, 4),
            Fraction::new(1, 4)
        ]
    );
    let measures = &tune.score.parts[0].voices[0].measures;
    assert_eq!(
        measures
            .iter()
            .map(|measure| measure.actual_duration)
            .collect::<Vec<_>>(),
        vec![Fraction::new(1, 4), Fraction::new(2, 4)]
    );
    assert!(measures[0].pickup);
    assert!(measures[1].complete);
}

#[test]
fn dropped_tie_does_not_leak_accidental_across_barline() {
    // The tie on `^a-` finds no matching stop note (`b` mismatches) and is
    // dropped; the accidental carry preserved across the barline on the tie's
    // behalf must be undone with it, so the measure-2 `a`s stay natural.
    let source = "X:1\nL:1/8\nK:C\n^a- | b a a\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
        "-"
    );
    assert_eq!(semantic_note_alters(&tune), vec![1, 0, 0, 0]);
    let notes = semantic_note_events(&tune);
    assert!(notes.iter().all(|event| event.attachments.ties.is_empty()));
}

#[test]
fn dropped_tie_at_rest_does_not_leak_accidental_across_barline() {
    // The pending tie is dropped when the rest arrives; the barline-preserved
    // accidental carry must be dropped with it.
    let source = "X:1\nL:1/8\nK:C\n^a- | z a\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
        "-"
    );
    assert_eq!(semantic_note_alters(&tune), vec![1, 0]);
}

#[test]
fn chord_tie_partial_drop_keeps_matched_tie_and_drops_leaked_accidental() {
    // The C tie matches the next `c`; the A tie is dropped in the same drain.
    // The dropped A tie must not leak its sharp onto the trailing `a`, while
    // the matched C tie stays intact.
    let source = "X:1\nL:1/8\nK:C\n[^ac]- | c a\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
        "-"
    );
    let chord = tune.score.parts[0].voices[0]
        .events
        .iter()
        .find_map(|event| match &event.kind {
            TimedEventKind::Chord(chord) => Some(chord),
            _ => None,
        })
        .expect("expected chord");
    assert_eq!(
        chord
            .members
            .iter()
            .map(|member| (member.pitch.step, member.pitch.alter))
            .collect::<Vec<_>>(),
        vec![('A', 1), ('C', 0)]
    );
    // The A tie is dropped (no matching stop note); the C tie survives.
    assert!(chord.members[0].attachments.ties.is_empty());
    assert_eq!(chord.members[1].attachments.ties.len(), 1);
    assert_eq!(chord.members[1].attachments.ties[0].role, TieRole::Start);

    // Measure 2: the tie-stop `c` and the trailing `a`, which must NOT
    // inherit the dropped A tie's sharp.
    assert_eq!(semantic_note_alters(&tune), vec![0, 0]);
    let notes = semantic_note_events(&tune);
    assert_eq!(notes.len(), 2);
    assert_eq!(notes[0].attachments.ties.len(), 1);
    assert_eq!(notes[0].attachments.ties[0].role, TieRole::Stop);
    assert_eq!(
        notes[0].attachments.ties[0].pair_id,
        chord.members[1].attachments.ties[0].pair_id
    );
    assert!(notes[1].attachments.ties.is_empty());
}

#[test]
fn rewritten_accidental_after_dropped_tie_carries_within_measure() {
    // After the dropped tie's leaked carry is undone, a freshly written `^a`
    // in measure 2 must still propagate to the rest of that measure.
    let source = "X:1\nL:1/8\nK:C\n^a- | b ^a a\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
        "-"
    );
    assert_eq!(semantic_note_alters(&tune), vec![1, 0, 1, 1]);
}

#[test]
fn tie_across_barline_preserves_accidental_carry() {
    // Legit cross-bar tie: the stop note keeps the start note's sharp.
    let source = "X:1\nL:1/8\nK:C\n^a- | a\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(
        !diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
    );
    assert_eq!(semantic_note_alters(&tune), vec![1, 1]);
    let notes = semantic_note_events(&tune);
    assert_eq!(
        notes
            .iter()
            .map(|event| event.attachments.ties.first().map(|tie| tie.role))
            .collect::<Vec<_>>(),
        vec![Some(TieRole::Start), Some(TieRole::Stop)]
    );
    assert_eq!(
        notes[0].attachments.ties[0].pair_id,
        notes[1].attachments.ties[0].pair_id
    );
}

#[test]
fn same_measure_dropped_tie_keeps_written_accidental_carry() {
    // A tie dropped within the measure must not cancel the carry that comes
    // from the WRITTEN accidental itself: `^a-b a` stays A#, B, A#.
    let source = "X:1\nL:1/8\nK:C\n^a-b a\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.unmatched_tie"),
        "-"
    );
    assert_eq!(semantic_note_alters(&tune), vec![1, 0, 1]);
}

#[test]
fn matched_tie_stop_accidental_is_stop_note_only() {
    // A matched cross-bar tie carries the sharp onto the stop note only. The
    // barline resets the ordinary measure ledger, so a later untied same-pitch
    // note in the stop note's measure resolves against the key again.
    let source = "X:1\nL:1/8\nK:C\n^a- | a b a\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(
        !diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
    );
    assert_eq!(semantic_note_alters(&tune), vec![1, 1, 0, 0]);
}

#[test]
fn chained_tie_across_barline_preserves_accidental_until_chain_ends() {
    let source = "X:1\nL:1/8\nK:C\n^g- | g-g g\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(
        !diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
    );
    assert_eq!(semantic_note_alters(&tune), vec![1, 1, 1, 0]);
}

#[test]
fn tie_across_barline_with_rewritten_accidental_keeps_carry() {
    // The stop note re-writes the same sharp; the tie still matches and the
    // carry persists for the rest of the measure.
    let source = "X:1\nL:1/8\nK:C\n^a- | ^a a\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(
        !diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
    );
    assert_eq!(semantic_note_alters(&tune), vec![1, 1, 1]);
}

#[test]
fn tie_across_double_barline_preserves_accidental_carry() {
    let source = "X:1\nL:1/8\nK:C\n^a- || a\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(
        !diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
    );
    assert_eq!(semantic_note_alters(&tune), vec![1, 1]);
    let notes = semantic_note_events(&tune);
    assert_eq!(
        notes
            .iter()
            .map(|event| event.attachments.ties.first().map(|tie| tie.role))
            .collect::<Vec<_>>(),
        vec![Some(TieRole::Start), Some(TieRole::Stop)]
    );
}

#[test]
fn crossing_slurs_diagnose_but_preserve_notes() {
    let source = "X:1\nL:1/8\nK:C\n.(C (D .) E)\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.crossing_slur"),
        ".)"
    );
    assert_eq!(semantic_note_events(&tune).len(), 3);
    assert_eq!(
        semantic_note_events(&tune)
            .into_iter()
            .map(|event| event.duration)
            .collect::<Vec<_>>(),
        vec![
            Fraction::new(1, 8),
            Fraction::new(1, 8),
            Fraction::new(1, 8)
        ]
    );
}

#[test]
fn variable_chord_members_preserve_chord_shape_and_following_timing() {
    let source = "X:1\nL:1/8\nK:C\n[E2G,6] C\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.music.chord.variable_duration"),
        "[E2G,6]"
    );
    let events = &tune.score.parts[0].voices[0].events;
    let chord = events
        .iter()
        .find_map(|event| match &event.kind {
            TimedEventKind::Chord(chord) => Some(chord),
            _ => None,
        })
        .expect("expected chord");
    assert_eq!(chord.members.len(), 2);
    assert_eq!(
        chord
            .members
            .iter()
            .map(|member| member.duration)
            .collect::<Vec<_>>(),
        vec![Fraction::new(2, 8), Fraction::new(6, 8)]
    );
    let note_after = events
        .iter()
        .find(|event| {
            matches!(event.kind, TimedEventKind::Note(_)) && event.onset == Fraction::new(2, 8)
        })
        .expect("expected following note");
    assert_eq!(note_after.onset, Fraction::new(2, 8));
}

#[test]
fn overlay_incomplete_duration_does_not_shift_base_timeline() {
    let source = "X:1\nL:1/8\nK:C\nC D & E|\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.voice.overlay_incomplete_measure"),
        "&"
    );
    let base_notes = semantic_note_events(&tune)
        .into_iter()
        .map(|event| (event.onset, event.duration))
        .collect::<Vec<_>>();
    assert_eq!(
        base_notes,
        vec![
            (Fraction::zero(), Fraction::new(1, 8)),
            (Fraction::new(1, 8), Fraction::new(1, 8)),
        ]
    );
}

#[test]
fn lyric_count_mismatches_attach_valid_syllables_only() {
    let source = "X:1\nL:1/8\nK:C\nC D E F|\nw: one two\nw: a b c d e\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        count_diagnostics(&diagnostics, "abc.lyric.syllable_count"),
        2
    );
    let lyrics = semantic_note_events(&tune)
        .into_iter()
        .flat_map(|event| event.attachments.lyrics.iter())
        .filter(|lyric| lyric.control == LyricControl::Syllable)
        .map(|lyric| lyric.text.as_str())
        .collect::<Vec<_>>();
    assert_eq!(lyrics, vec!["one", "a", "two", "b", "c", "d"]);
    assert_eq!(semantic_note_events(&tune).len(), 4);
}

#[test]
fn lyric_overflow_mid_hyphenated_word_drops_orphan_hyphen() {
    let source = "X:1\nL:1/4\nK:C\nC|\nw: a-b\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        count_diagnostics(&diagnostics, "abc.lyric.syllable_count"),
        1
    );
    let lyrics = semantic_note_events(&tune)[0]
        .attachments
        .lyrics
        .iter()
        .map(|lyric| (lyric.control, lyric.text.as_str()))
        .collect::<Vec<_>>();
    assert_eq!(lyrics, vec![(LyricControl::Syllable, "a")]);

    let source = "X:1\nL:1/4\nK:C\nC D|\nw: a-b\n";
    let (tune, diagnostics) = tune_for(source);
    assert!(diagnostics.is_empty());
    let lyrics = semantic_note_events(&tune)
        .into_iter()
        .map(|event| {
            event
                .attachments
                .lyrics
                .iter()
                .map(|lyric| (lyric.control, lyric.text.as_str()))
                .collect::<Vec<_>>()
        })
        .collect::<Vec<_>>();
    assert_eq!(
        lyrics,
        vec![
            vec![(LyricControl::Syllable, "a"), (LyricControl::Hyphen, "-")],
            vec![(LyricControl::Syllable, "b")]
        ]
    );
}

#[test]
fn lyric_hyphen_can_continue_across_non_adjacent_blocks() {
    let source = "X:1\nL:1/4\nK:C\nC|\nw: A-\nD|\nw: men\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(diagnostics.is_empty());
    let lyrics = semantic_note_events(&tune)
        .into_iter()
        .map(|event| {
            event
                .attachments
                .lyrics
                .iter()
                .map(|lyric| (lyric.control, lyric.text.as_str()))
                .collect::<Vec<_>>()
        })
        .collect::<Vec<_>>();
    assert_eq!(
        lyrics,
        vec![
            vec![(LyricControl::Syllable, "A"), (LyricControl::Hyphen, "-")],
            vec![(LyricControl::Syllable, "men")]
        ]
    );
}

#[test]
fn invalid_state_changes_keep_previous_state_and_valid_music() {
    let source = "X:1\nM:2/4\nL:1/8\nK:C\nC D|\nM:bad\nK:???\nL:bad\nE F|\n";
    let document = parse_document(source, ParseOptions::default());
    assert_eq!(
        diagnostic_span(source, &document.diagnostics, "abc.field.invalid_l"),
        "bad"
    );
    let report = parse_tune_report_from_document(&document.value);
    let tune = report.value.expect("expected tune");

    assert_eq!(
        diagnostic_span(source, &report.diagnostics, "abc.field.invalid_m"),
        "bad"
    );
    assert_eq!(
        diagnostic_span(source, &report.diagnostics, "abc.field.invalid_k"),
        "???"
    );
    let notes = semantic_note_events(&tune);
    assert_eq!(notes.len(), 4);
    assert!(
        notes
            .iter()
            .all(|event| event.duration == Fraction::new(1, 8))
    );
    assert_eq!(
        tune.score.parts[0].voices[0].measures[1].expected_duration,
        Some(Fraction::new(2, 4))
    );
}

#[test]
fn malformed_repeat_ending_keeps_barline_timing_intact() {
    let source = "X:1\nL:1/8\nK:C\nC|[1- D|E|\n";
    let document = parse_document(source, ParseOptions::default());
    assert_eq!(
        diagnostic_span(
            source,
            &document.diagnostics,
            "abc.music.invalid_repeat_ending"
        ),
        // `|[1-` parses as a barline plus the `[1` variant ending, so the
        // malformed-ending diagnostic spans the whole `[1-`.
        "[1-"
    );
    let report = parse_tune_report_from_document(&document.value);
    let tune = report.value.expect("expected tune");

    assert_eq!(semantic_note_events(&tune).len(), 3);
    assert_eq!(
        tune.score.parts[0].voices[0]
            .measures
            .iter()
            .map(|measure| measure.actual_duration)
            .collect::<Vec<_>>(),
        vec![
            Fraction::new(1, 8),
            Fraction::new(1, 8),
            Fraction::new(1, 8)
        ]
    );
}

#[test]
fn inline_instruction_field_warns_and_changes_nothing() {
    // `[I:tuplets 1 0 0]` is an abcm2ps DISPLAY directive: it cannot change
    // how `(3CDE` parses (abc2xml skips it too). Lowering drops it but must
    // say so — previously the `_ => {}` arm swallowed it silently — and the
    // music on either side must lower identically.
    let source = "X:1\nL:1/8\nK:C\n(3CDE [I:tuplets 1 0 0](3CDE|\n";
    let document = parse_document(source, ParseOptions::default());
    let report = parse_tune_report_from_document(&document.value);
    let tune = report.value.expect("expected tune");

    assert_eq!(
        diagnostic_span(source, &report.diagnostics, "abc.field.inline_ignored"),
        "[I:tuplets 1 0 0]"
    );
    let notes = tune
        .events
        .iter()
        .filter_map(|event| match event {
            Event::Note {
                step,
                octave,
                duration,
                ..
            } => Some((*step, *octave, *duration)),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(notes.len(), 6);
    assert_eq!(notes[..3], notes[3..], "music differs across the inline I:");
}

#[test]
fn croma_private_carrier_warnings_can_be_suppressed_during_music_analysis() {
    let source =
        "X:1\nL:1/8\nK:C\n%%croma-future value\n[I:croma-future value]C [I:tuplets 1 0 0]D|\n";

    let default_document = parse_document(source, ParseOptions::default());
    assert!(
        default_document
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.directive.unsupported"),
        "default parsing should warn for unknown %%croma-* directives"
    );
    let default_report = parse_tune_report_from_document(&default_document.value);
    assert!(
        default_report.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "abc.field.inline_ignored"
                && diagnostic.message.contains("croma-future")
        }),
        "default lowering should warn for unknown [I:croma-*] carriers"
    );

    let suppressed_document = parse_document(
        source,
        ParseOptions::default().suppress_croma_carrier_warnings(),
    );
    assert!(
        suppressed_document
            .diagnostics
            .iter()
            .all(|diagnostic| !diagnostic.message.contains("croma-future")),
        "%%croma-* warnings should be suppressed: {:?}",
        suppressed_document.diagnostics
    );
    let suppressed_report = parse_tune_report_from_document(&suppressed_document.value);
    assert!(
        suppressed_report
            .diagnostics
            .iter()
            .all(|diagnostic| !diagnostic.message.contains("croma-future")),
        "[I:croma-*] warnings should be suppressed: {:?}",
        suppressed_report.diagnostics
    );
    assert!(
        suppressed_report.diagnostics.iter().any(|diagnostic| {
            diagnostic.code == "abc.field.inline_ignored" && diagnostic.message.contains("tuplets")
        }),
        "ordinary inline I: warnings must stay visible: {:?}",
        suppressed_report.diagnostics
    );
}

#[test]
fn unsupported_directive_is_metadata_not_music() {
    let source = "X:1\nK:C\n%%foo bar\nC D|\n";
    let (tune, diagnostics) = tune_for(source);

    assert_eq!(
        diagnostic_span(source, &diagnostics, "abc.directive.unsupported"),
        "foo"
    );
    assert_eq!(tune.score.metadata.preserved_directives[0].name.text, "foo");
    assert_eq!(semantic_note_events(&tune).len(), 2);
    assert_eq!(
        tune.score.parts[0].voices[0].measures[0].actual_duration,
        Fraction::new(2, 8)
    );
}

#[test]
fn mid_tune_key_and_meter_changes_reach_the_score() {
    let source = "X:1\nL:1/4\nK:C\nCDEF|[K:F]GAB_B|[M:3/4]ABc|\n";
    let doc = crate::parse_document(source, crate::ParseOptions::default());
    let score = crate::lower_score(&doc.value, crate::LowerOptions)
        .value
        .expect("score");
    let events = &score.parts[0].voices[0].events;
    let keys: Vec<&crate::model::KeySignatureModel> = events
        .iter()
        .filter_map(|e| match &e.kind {
            crate::TimedEventKind::KeyChange(k) => Some(k),
            _ => None,
        })
        .collect();
    let meters: Vec<&crate::model::MeterModel> = events
        .iter()
        .filter_map(|e| match &e.kind {
            crate::TimedEventKind::MeterChange(m) => Some(m),
            _ => None,
        })
        .collect();
    assert_eq!(keys.len(), 1, "one key change event");
    assert_eq!(keys[0].display, "F");
    assert_eq!(keys[0].fifths, -1);
    assert_eq!(meters.len(), 1, "one meter change event");
    assert_eq!(meters[0].display, "3/4");
    // header metadata stays the header values
    assert_eq!(
        score.metadata.key.as_ref().map(|k| k.display.as_str()),
        Some("C")
    );
    // positions: key change directly after the first barline, before the G
    let key_idx = events
        .iter()
        .position(|e| matches!(e.kind, crate::TimedEventKind::KeyChange(_)))
        .expect("key change present");
    assert!(key_idx > 0, "key change is not the first event");
    assert!(matches!(
        events[key_idx - 1].kind,
        crate::TimedEventKind::Barline(_)
    ));
}

#[test]
fn redundant_meter_change_is_canonicalized_for_plain_abc() {
    let (tune, diagnostics) = tune_for("X:1\nM:4/4\nL:1/4\nK:C\nC [M:4/4]D|\n");

    assert!(
        diagnostics.is_empty(),
        "plain redundant meter change should not warn: {diagnostics:?}"
    );
    assert!(
        part_meter_change_displays(&tune.score, 0).is_empty(),
        "plain ABC keeps the existing canonicalization and does not record a no-op meter change"
    );
}

#[test]
fn nospace_header_key_global_accidentals_preserve_base_key() {
    let source = "X:1\nL:1/4\nK:D_B^g\nCDEF|\n";
    let (tune, diagnostics) = tune_for(source);
    assert_eq!(
        diagnostics
            .iter()
            .filter(|diagnostic| diagnostic.code == "abc.field.key.compact_accidentals_ignored")
            .count(),
        1,
        "expected compact accidental warning for K:D_B^g: {diagnostics:?}"
    );
    let key = tune.score.metadata.key.as_ref().expect("header key");

    assert_eq!(key.fifths, 2);
    assert!(
        key.explicit_accidentals.is_empty(),
        "compact accidental tail is nonstandard and ignored"
    );
}

#[test]
fn nospace_inline_key_global_accidentals_preserve_base_key() {
    let source = "X:1\nL:1/4\nK:C\nCDEF|[K:D_B^g]GABc|\n";
    let (tune, diagnostics) = tune_for(source);
    assert_eq!(
        diagnostics
            .iter()
            .filter(|diagnostic| diagnostic.code == "abc.field.key.compact_accidentals_ignored")
            .count(),
        1,
        "expected compact accidental warning for [K:D_B^g]: {diagnostics:?}"
    );
    let key_changes = tune.score.parts[0].voices[0]
        .events
        .iter()
        .filter_map(|event| match &event.kind {
            TimedEventKind::KeyChange(key) => Some(key),
            _ => None,
        })
        .collect::<Vec<_>>();

    assert_eq!(key_changes.len(), 1);
    assert_eq!(key_changes[0].fifths, 2);
    assert!(
        key_changes[0].explicit_accidentals.is_empty(),
        "compact accidental tail is nonstandard and ignored"
    );
}

#[test]
fn body_key_none_resets_signature_and_is_not_rejected() {
    // A mid-tune `K:none` cancels the key signature (ABC 2.1 §3.1.14: "K:none ...
    // no key signature at all"). It is tonic-less but VALID, so
    // `key_is_invalid_for_lowering` must NOT reject it (no `abc.field.invalid_k`)
    // and it records a 0-fifths key change. This pins the `KeyMode::None` arm,
    // which subsumes the redundant raw `is_empty()` / `eq("none")` guards the
    // predicate cleanup removes.
    let source = "X:1\nL:1/4\nK:D\nDEFG|\nK:none\nABcd|\n";
    let (tune, diagnostics) = tune_for(source);
    assert!(
        diagnostics.iter().all(|d| d.code != "abc.field.invalid_k"),
        "K:none must not be rejected as an invalid key: {diagnostics:?}"
    );
    assert_eq!(
        part_key_change_fifths(&tune.score, 0),
        vec![0],
        "K:none records a single 0-fifths key change"
    );
}

#[test]
fn key_tonic_trailing_junk_recovery_warns() {
    // Strict-spec-first: recovering the tonic from `K:Bb,` (a valid §3.1.14 tonic
    // followed by ignored, non-alphabetic trailing junk) must ANNOUNCE the
    // deviation, mirroring the no-space compact-accidental warning — silent
    // recovery would bless malformed input. The tonic is still recovered (Bb =
    // 2 flats), only flagged.
    let source = "X:1\nL:1/4\nK:Bb,\nCDEF|\n";
    let (tune, diagnostics) = tune_for(source);
    assert_eq!(
        diagnostics
            .iter()
            .filter(|d| d.code == "abc.field.key.tonic_trailing_junk_ignored")
            .count(),
        1,
        "K:Bb, recovery must warn: {diagnostics:?}"
    );
    assert_eq!(
        tune.score.metadata.key.as_ref().map(|k| k.fifths),
        Some(-2),
        "tonic Bb is still recovered despite the trailing junk"
    );
}

#[test]
fn clean_key_tonic_does_not_warn_trailing_junk() {
    // The negative: well-formed keys recover nothing and must NOT emit the
    // trailing-junk warning.
    for source in ["X:1\nL:1/4\nK:Bb\nCDEF|\n", "X:1\nL:1/4\nK:C\nCDEF|\n"] {
        let (_, diagnostics) = tune_for(source);
        assert!(
            diagnostics
                .iter()
                .all(|d| d.code != "abc.field.key.tonic_trailing_junk_ignored"),
            "clean key must not warn trailing junk ({source:?}): {diagnostics:?}"
        );
    }
}

#[test]
fn redundant_chord_tie_marker_warns() {
    // A tie marker inside a chord that is not directly after a member note
    // (`[-CE]`, doubled `[CE--]`) is recovered (attached-or-dropped) but must
    // warn — recovery is never silent (3-tier policy, tier 2).
    for source in ["X:1\nL:1/4\nK:C\n[-CE]|\n", "X:1\nL:1/4\nK:C\n[CE--]|\n"] {
        let (_, diagnostics) = tune_for(source);
        assert!(
            diagnostics
                .iter()
                .any(|d| d.code == "abc.music.redundant_tie"),
            "expected redundant_tie warning for {source:?}: {diagnostics:?}"
        );
    }
}

#[test]
fn invalid_inline_unit_length_warns() {
    // An unparseable inline `[L:..]` keeps the previous unit length but must warn.
    let (_, diagnostics) = tune_for("X:1\nL:1/4\nK:C\nC[L:bad]D|\n");
    assert!(
        diagnostics.iter().any(|d| d.code == "abc.field.invalid_l"),
        "expected invalid_l for inline [L:bad]: {diagnostics:?}"
    );
}

#[test]
fn invalid_inline_key_change_warns() {
    // An invalid inline `[K:..]` that names no signature and no clef keeps the
    // prevailing key but must warn, mirroring the whole-line invalid-K: path.
    let (_, diagnostics) = tune_for("X:1\nL:1/4\nK:C\nC[K:???]D|\n");
    assert!(
        diagnostics.iter().any(|d| d.code == "abc.field.invalid_k"),
        "expected invalid_k for inline [K:???]: {diagnostics:?}"
    );
}

#[test]
fn valid_unsupported_inline_field_is_silently_skipped() {
    // A VALID inline field croma's lowering does not apply (`[P:..]`, `[w:..]`,
    // `[r:..]`, ...) is an unsupported-feature no-op, NOT a recovery from
    // malformed input — so it is skipped SILENTLY (no `abc.field.inline_ignored`).
    // Warning on well-formed input would be noise (fired 781x / 359 files).
    for source in [
        "X:1\nL:1/4\nK:C\nC[P:A]D|\n",
        "X:1\nL:1/4\nK:C\nC[r:a remark]D|\n",
        "X:1\nL:1/4\nK:C\nC[w:la la]D|\n",
    ] {
        let (_, diagnostics) = tune_for(source);
        assert!(
            diagnostics
                .iter()
                .all(|d| d.code != "abc.field.inline_ignored"),
            "valid unsupported inline field must be silent ({source:?}): {diagnostics:?}"
        );
    }
}

#[test]
fn unterminated_tempo_quote_warns() {
    // A `Q:` field with an opening `"` and no close recovers the trailing text,
    // but must warn — recovery is never silent.
    let (_, diagnostics) = tune_for("X:1\nL:1/4\nK:C\nC4|\nQ:\"Allegro\nD4|\n");
    assert!(
        diagnostics
            .iter()
            .any(|d| d.code == "abc.field.tempo.unterminated_quote"),
        "expected unterminated_quote warning: {diagnostics:?}"
    );
}

#[test]
fn line_leading_colon_repeat_start_warns() {
    // A line-leading `:` glued to a note opens a repeat section (the author's `|:`
    // with the pipe dropped). croma recovers it as a RepeatStart — a justified
    // recovery, but it must warn (it was previously silent).
    let (_, diagnostics) = tune_for("X:1\nL:1/4\nK:G\n:GABc:|\n");
    assert!(
        diagnostics
            .iter()
            .any(|d| d.code == "abc.music.barline.recovered_repeat"),
        "expected recovered_repeat warning: {diagnostics:?}"
    );
}

#[test]
fn supported_body_additive_meter_change_does_not_warn() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|\nM:3/4+4/4\nGABcdef|\n";
    let (tune, diagnostics) = tune_for(source);

    assert!(
        diagnostics
            .iter()
            .all(|diagnostic| diagnostic.code != "abc.music.meter.unsupported_complex"),
        "supported additive meter should not warn: {diagnostics:?}"
    );
    let meter_changes = tune.score.parts[0].voices[0]
        .events
        .iter()
        .filter_map(|event| match &event.kind {
            TimedEventKind::MeterChange(meter) => Some(meter),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(meter_changes.len(), 1);
    assert_eq!(meter_changes[0].display, "3/4+4/4");
    assert_eq!(meter_changes[0].duration, Some(Fraction::new(7, 4)));
    assert!(!meter_changes[0].free_meter);

    assert_eq!(
        tune.score.parts[0].voices[0].measures[1].actual_duration,
        Fraction::new(7, 4)
    );
}

fn part_note_steps_and_alters(score: &crate::model::Score, part_index: usize) -> Vec<(char, i8)> {
    score.parts[part_index].voices[0]
        .events
        .iter()
        .filter_map(|event| match &event.kind {
            TimedEventKind::Note(note) => Some((note.pitch.step, note.pitch.alter)),
            _ => None,
        })
        .collect()
}

fn part_key_change_fifths(score: &crate::model::Score, part_index: usize) -> Vec<i8> {
    score.parts[part_index].voices[0]
        .events
        .iter()
        .filter_map(|event| match &event.kind {
            TimedEventKind::KeyChange(key) => Some(key.fifths),
            _ => None,
        })
        .collect()
}

fn part_meter_change_displays(score: &crate::model::Score, part_index: usize) -> Vec<&str> {
    score.parts[part_index].voices[0]
        .events
        .iter()
        .filter_map(|event| match &event.kind {
            TimedEventKind::MeterChange(meter) => Some(meter.display.as_str()),
            _ => None,
        })
        .collect()
}

fn notes_before_first_key_change(score: &crate::model::Score, part_index: usize) -> usize {
    let events = &score.parts[part_index].voices[0].events;
    let key_index = events
        .iter()
        .position(|event| matches!(event.kind, TimedEventKind::KeyChange(_)))
        .expect("key change");
    events[..key_index]
        .iter()
        .filter(|event| matches!(event.kind, TimedEventKind::Note(_)))
        .count()
}

fn notes_before_first_meter_change(score: &crate::model::Score, part_index: usize) -> usize {
    let events = &score.parts[part_index].voices[0].events;
    let meter_index = events
        .iter()
        .position(|event| matches!(event.kind, TimedEventKind::MeterChange(_)))
        .expect("meter change");
    events[..meter_index]
        .iter()
        .filter(|event| matches!(event.kind, TimedEventKind::Note(_)))
        .count()
}

#[test]
fn standalone_body_key_line_scopes_to_current_voice_timeline() {
    let late_body_voice = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:D\n",
        "V:1\n",
        "B B B B|\n",
        "K:Dm\n",
        "B B B B|\n",
        "V:2\n",
        "F C B F|\n",
        "K:Dm\n",
        "F C B F|\n",
    );
    let header_predeclared_late_voice = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "V:1\n",
        "V:2\n",
        "K:D\n",
        "V:1\n",
        "B B B B|\n",
        "K:Dm\n",
        "B B B B|\n",
        "V:2\n",
        "F C B F|\n",
        "K:Dm\n",
        "F C B F|\n",
    );

    for (label, source) in [
        ("late body voice", late_body_voice),
        (
            "header-predeclared late voice",
            header_predeclared_late_voice,
        ),
    ] {
        let doc = crate::parse_document(source, crate::ParseOptions::default());
        let score = crate::lower_score(&doc.value, crate::LowerOptions)
            .value
            .expect("score");

        assert_eq!(score.parts.len(), 2, "{label}: expected two parts");
        assert_eq!(
            part_key_change_fifths(&score, 0),
            vec![-1],
            "{label}: V1 records only its Dm change"
        );
        assert_eq!(
            part_key_change_fifths(&score, 1),
            vec![-1],
            "{label}: V2 records only its own Dm change"
        );
        assert_eq!(
            notes_before_first_key_change(&score, 1),
            4,
            "{label}: V2 must stay in the header key until its own K:Dm"
        );
        assert_eq!(
            part_note_steps_and_alters(&score, 1),
            vec![
                ('F', 1),
                ('C', 1),
                ('B', 0),
                ('F', 1),
                ('F', 0),
                ('C', 0),
                ('B', -1),
                ('F', 0),
            ],
            "{label}: V2 first bar stays in D major, second bar changes to D minor"
        );
    }
}

#[test]
fn standalone_body_meter_line_scopes_to_current_voice_timeline() {
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "V:1\n",
        "V:2\n",
        "K:C\n",
        "V:1\n",
        "C D E F|\n",
        "M:3/4\n",
        "G A B|\n",
        "V:2\n",
        "C D E F|\n",
        "M:3/4\n",
        "G A B|\n",
    );
    let doc = crate::parse_document(source, crate::ParseOptions::default());
    let score = crate::lower_score(&doc.value, crate::LowerOptions)
        .value
        .expect("score");

    assert_eq!(score.parts.len(), 2, "expected two parts");
    assert_eq!(part_meter_change_displays(&score, 0), vec!["3/4"]);
    assert_eq!(part_meter_change_displays(&score, 1), vec!["3/4"]);
    assert_eq!(
        notes_before_first_meter_change(&score, 1),
        4,
        "V2 must stay in the header meter until its own M:3/4"
    );
}
