use super::{
    format::{ParseError, parse},
    song::{
        Arpeggio, Articulation, Compare, Condition, ConditionSource, Curve, Effect, Envelope, Jump,
        Layer, Modulation, MotifInstance, MotifNote, Note, NoteOverride, Slide, Song, Sound,
        TimeSignature, VoiceKnob, WavePoint, ramp_offset, volume_ramp_target,
    },
};
use std::{
    fmt::{self, Display, Write as _},
    str::FromStr,
};

impl Display for Song {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&render(self))
    }
}

impl FromStr for Song {
    type Err = ParseError;
    fn from_str(source: &str) -> Result<Self, Self::Err> {
        parse(source)
    }
}

fn render(song: &Song) -> String {
    let mut out = String::new();
    if (song.tempo_factor - 1.0).abs() > f32::EPSILON {
        let _ = writeln!(
            out,
            "@tempo {} {}",
            trim_float(song.tempo),
            trim_float(song.tempo * song.tempo_factor)
        );
    } else {
        let _ = writeln!(out, "@tempo {}", trim_float(song.tempo));
    }
    if song.signature != TimeSignature::default() {
        let _ = writeln!(
            out,
            "@signature {} {}",
            song.signature.beats_per_bar, song.signature.beat_unit
        );
    }
    for import in &song.imports {
        let refresh = if import.refresh { " refresh" } else { "" };
        let _ = writeln!(out, "@import {}{refresh}", import.path);
    }
    for (name, default) in &song.params {
        let _ = writeln!(out, "@param {name} {}", trim_float(*default));
    }
    if let Some(length) = song.length {
        let _ = writeln!(out, "@length {}", trim_float(length));
    }
    for jump in &song.jumps {
        let _ = writeln!(out, "{}", format_jump(jump));
    }
    out.push('\n');

    let has_meta = song.title.is_some()
        || song.description.is_some()
        || song.author.is_some()
        || !song.tags.is_empty();
    if has_meta {
        out.push_str("# Meta\n\n");
        if let Some(title) = &song.title {
            let _ = writeln!(out, "title {title}");
        }
        if let Some(description) = &song.description {
            let _ = writeln!(out, "description {description}");
        }
        if let Some(author) = &song.author {
            let _ = writeln!(out, "author {author}");
        }
        if !song.tags.is_empty() {
            out.push_str("\n## Tags\n\n");
            for tag in &song.tags {
                let _ = writeln!(out, "{tag}");
            }
        }
        out.push('\n');
    }

    let local_sounds: Vec<(&String, &Sound)> = song
        .sounds
        .iter()
        .filter(|(name, _)| song.local_sound_names.contains(name.as_str()))
        .collect();
    if !local_sounds.is_empty() {
        out.push_str("# Sounds\n\n");
        for (name, sound) in local_sounds {
            let _ = writeln!(out, "{}: {}", name, sound_parameters(sound));
        }
        out.push('\n');
    }

    let local_waves: Vec<_> = song
        .waves
        .iter()
        .filter(|(name, _)| !song.wave_origins.contains_key(name.as_str()))
        .collect();
    if !local_waves.is_empty() {
        out.push_str("# Waves\n\n");
    }
    for (name, table) in local_waves {
        let _ = writeln!(out, "## {name}\n");
        for point in table {
            let _ = writeln!(
                out,
                "{} {}{}",
                trim_float(point.x),
                trim_float(point.y),
                curve_suffix(point.curve)
            );
        }
        out.push('\n');
    }

    let local_tunings: Vec<_> = song
        .tunings
        .iter()
        .filter(|(name, _)| !song.tuning_origins.contains_key(name.as_str()))
        .collect();
    if !local_tunings.is_empty() {
        out.push_str("# Tunings\n\n");
    }
    for (name, tuning) in local_tunings {
        let _ = writeln!(out, "## {name}\n");
        if (tuning.period - 2.0).abs() > f32::EPSILON {
            let _ = writeln!(out, "period {}", trim_float(tuning.period));
        }
        let degrees: Vec<String> = tuning
            .degrees
            .iter()
            .map(|degree| trim_float(*degree))
            .collect();
        let _ = writeln!(out, "{}", degrees.join(" "));
        out.push('\n');
    }

    let local_motifs: Vec<_> = song
        .motifs
        .iter()
        .filter(|(name, _)| !song.motif_origins.contains_key(name.as_str()))
        .collect();
    if !local_motifs.is_empty() {
        out.push_str("# Motifs\n\n");
    }
    for (name, notes) in local_motifs {
        let _ = writeln!(out, "## {name}\n");
        for note in notes {
            let _ = writeln!(out, "{}", format_motif_note(note));
        }
        out.push('\n');
    }

    if !song.layers.is_empty() {
        out.push_str("# Layers\n\n");
    }
    for layer in &song.layers {
        let _ = writeln!(out, "## {}", layer.name);
        let directives = layer_directives(layer);
        if !directives.is_empty() {
            let _ = writeln!(out, "{directives}");
        }
        out.push('\n');
        let mut notes: Vec<&Note> = layer.notes.iter().collect();
        notes.sort_by(|a, b| a.start.time.total_cmp(&b.start.time));
        for note in notes {
            let _ = writeln!(out, "{}", format_note(note));
        }
        for instance in &layer.motif_instances {
            let _ = writeln!(out, "{}", format_motif_instance(instance));
            for note_override in &instance.overrides {
                let _ = writeln!(out, "  {}", format_note_override(note_override));
            }
            if let Some(trim) = instance.trim {
                let _ = writeln!(out, "  trim {} {}", trim.from, trim.count);
            }
        }
        out.push('\n');
    }

    if !song.subtitles.is_empty() {
        out.push_str("# Subtitles\n\n");
        for subtitle in &song.subtitles {
            if subtitle.text.is_empty() {
                let _ = writeln!(out, "{}", trim_float(subtitle.beat));
            } else {
                let _ = writeln!(out, "{} {}", trim_float(subtitle.beat), subtitle.text);
            }
        }
        out.push('\n');
    }

    for section in &song.unknown {
        let _ = writeln!(out, "# {}", section.header);
        for line in &section.lines {
            let _ = writeln!(out, "{line}");
        }
        out.push('\n');
    }

    out
}

fn format_jump(jump: &Jump) -> String {
    let mut line = format!("@jump {}", jump.target);
    if jump.skip != 0.0 {
        let _ = write!(line, " {}", trim_float(jump.skip));
    }
    if let Some(condition) = &jump.condition {
        let _ = write!(line, " if {}", format_condition(condition));
    }
    line
}

fn format_condition(condition: &Condition) -> String {
    format!(
        "{} {} {}",
        condition_source_token(&condition.source),
        compare_token(condition.compare),
        trim_float(condition.value)
    )
}

fn condition_source_token(source: &ConditionSource) -> String {
    match source {
        ConditionSource::Plays => "plays".to_string(),
        ConditionSource::Random => "random".to_string(),
        ConditionSource::Param(name) => name.clone(),
    }
}

const fn compare_token(compare: Compare) -> &'static str {
    match compare {
        Compare::Less => "<",
        Compare::LessEqual => "<=",
        Compare::Greater => ">",
        Compare::GreaterEqual => ">=",
        Compare::Equal => "==",
        Compare::NotEqual => "!=",
    }
}

#[allow(clippy::too_many_arguments)]
fn append_note_options(
    line: &mut String,
    pitch_curve: &[WavePoint],
    volume: f32,
    volume_curve: &[WavePoint],
    text: &str,
    articulation: Articulation,
    arpeggio: &Arpeggio,
) {
    let prefix = match articulation {
        Articulation::Separate => "",
        Articulation::Legato => "'",
    };
    if !text.is_empty() || articulation == Articulation::Legato {
        let _ = write!(line, " \"{prefix}{text}\"");
    }
    if let Some((offset, curve)) = ramp_offset(pitch_curve)
        && offset != 0.0
    {
        let _ = write!(line, " glide {}{}", trim_float(offset), curve_suffix(curve));
    }
    if (volume - 1.0).abs() > f32::EPSILON {
        let _ = write!(line, " volume {}", trim_float(volume));
    }
    if let Some((target, curve)) = volume_ramp_target(volume_curve)
        && target != 1.0
    {
        let _ = write!(
            line,
            " vol-ramp {}{}",
            trim_float(target),
            curve_suffix(curve)
        );
    }
    if !arpeggio.offsets.is_empty() {
        let offsets = arpeggio
            .offsets
            .iter()
            .map(|offset| trim_float(*offset))
            .collect::<Vec<_>>()
            .join(" ");
        let _ = write!(
            line,
            " arpeggio {offsets} rate {}",
            trim_float(arpeggio.rate)
        );
    }
    // Multi-point envelopes that are not a plain ramp go on indented sub-lines.
    if ramp_offset(pitch_curve).is_none() {
        let _ = write!(line, "\n  pitch {}", format_curve_points(pitch_curve));
    }
    if volume_ramp_target(volume_curve).is_none() {
        let _ = write!(line, "\n  volume {}", format_curve_points(volume_curve));
    }
}

fn format_curve_points(points: &[WavePoint]) -> String {
    points
        .iter()
        .map(|point| {
            format!(
                "{} {}{}",
                trim_float(point.x),
                trim_float(point.y),
                curve_suffix(point.curve)
            )
        })
        .collect::<Vec<_>>()
        .join(", ")
}

fn format_note(note: &Note) -> String {
    let mut line = format!(
        "{} {} {} {}",
        note.sound,
        trim_float(note.start.time),
        trim_float(note.start.pitch),
        trim_float(note.duration())
    );
    append_note_options(
        &mut line,
        &note.pitch_curve(),
        note.volume,
        &note.volume_curve,
        &note.text,
        note.articulation,
        &note.arpeggio,
    );
    line
}

fn format_motif_note(note: &MotifNote) -> String {
    let mut line = format!(
        "{} {} {}",
        trim_float(note.start.time),
        trim_float(note.start.pitch),
        trim_float(note.duration())
    );
    append_note_options(
        &mut line,
        &note.pitch_curve(),
        note.volume,
        &note.volume_curve,
        &note.text,
        note.articulation,
        &note.arpeggio,
    );
    line
}

fn format_motif_instance(instance: &MotifInstance) -> String {
    let mut line = format!(
        "motif {} {} {}",
        instance.name,
        trim_float(instance.start),
        instance.sound
    );
    if instance.transpose != 0.0 {
        let _ = write!(line, " transpose {}", trim_float(instance.transpose));
    }
    if let Some(tuning) = &instance.tuning {
        let _ = write!(line, " tuning {tuning}");
    }
    line
}

fn format_note_override(note_override: &NoteOverride) -> String {
    let mut line = format!("note {}", note_override.index);
    if note_override.skip {
        line.push_str(" skip");
    }
    for (token, value) in [
        ("pitch", note_override.pitch),
        ("start", note_override.start),
        ("duration", note_override.duration),
        ("glide", note_override.glide),
        ("volume", note_override.volume),
    ] {
        if let Some(value) = value {
            let _ = write!(line, " {token} {}", trim_float(value));
        }
    }
    if let Some(text) = &note_override.text {
        let _ = write!(line, " text \"{text}\"");
    }
    if let Some(points) = &note_override.pitch_curve {
        let _ = write!(line, " pitch-curve {}", format_curve_points(points));
    }
    line
}

const fn curve_suffix(curve: Curve) -> &'static str {
    match (curve.ease_in, curve.ease_out) {
        (true, true) => "",
        (false, false) => " linear",
        (true, false) => " ease-in",
        (false, true) => " ease-out",
    }
}

fn sound_parameters(sound: &Sound) -> String {
    let mut parts: Vec<String> = Vec::new();
    for zone in &sound.samples {
        let mut entry = format!("sample \"{}\"", zone.file);
        if !zone.covers_all() {
            let _ = write!(
                entry,
                " @ {}..{}",
                trim_float(zone.pitches[0]),
                trim_float(zone.pitches[1])
            );
        }
        parts.push(entry);
    }
    if !sound.voice.is_empty() {
        let mut entry = format!("voice {}", sound.voice);
        for voice_override in &sound.voice_overrides {
            let knob = match voice_override.knob {
                VoiceKnob::FormantShift => "formant-shift",
                VoiceKnob::Tilt => "tilt",
                VoiceKnob::Breathiness => "breathiness",
                VoiceKnob::Vibrato => "vibrato",
            };
            let _ = write!(entry, " {knob} {}", trim_float(voice_override.value));
        }
        if sound.voice_ensemble > 1 {
            let _ = write!(entry, " ensemble {}", sound.voice_ensemble);
        }
        parts.push(entry);
    }
    parts.extend(sound.harmonics.iter().map(|harmonic| {
        let base = wave_token(&harmonic.wave);
        let mut entry = if (harmonic.multiplier - 1.0).abs() < f32::EPSILON {
            base.to_string()
        } else {
            format!("{base}@{}", trim_float(harmonic.multiplier))
        };
        let _ = write!(entry, " {}", trim_float(harmonic.weight));
        if harmonic.duty > 0.0 {
            let _ = write!(entry, " duty {}", trim_float(harmonic.duty));
        }
        entry
    }));
    if !sound.arpeggio.offsets.is_empty() {
        let offsets: Vec<String> = sound
            .arpeggio
            .offsets
            .iter()
            .map(|offset| trim_float(*offset))
            .collect();
        parts.push(format!(
            "arpeggio {} rate {}",
            offsets.join(" "),
            trim_float(sound.arpeggio.rate)
        ));
    }
    if sound.distortion != 0.0 {
        parts.push(format!("distortion {}", trim_float(sound.distortion)));
    }
    if sound.detune != 0.0 {
        parts.push(format!("detune {}", trim_float(sound.detune)));
    }
    if sound.volume_jitter != 0.0 {
        parts.push(format!("vol-jitter {}", trim_float(sound.volume_jitter)));
    }
    if sound.steps > 0 {
        parts.push(format!("steps {}", sound.steps));
    }
    if sound.attack.duration > 0.0 {
        parts.push(format_envelope("attack", sound.attack));
    }
    if sound.decay.duration > 0.0 {
        parts.push(format_envelope("decay", sound.decay));
    }
    if sound.release.duration > 0.0 {
        parts.push(format_envelope("release", sound.release));
    }
    if (sound.sustain - 1.0).abs() > f32::EPSILON {
        parts.push(format!("sustain {}", trim_float(sound.sustain)));
    }
    if sound.noise != 0.0 {
        parts.push(format!("noise {}", trim_float(sound.noise)));
    }
    if sound.vibrato.depth > 0.0 {
        parts.push(format_modulation("vibrato", &sound.vibrato));
    }
    if sound.tremolo.depth > 0.0 {
        parts.push(format_modulation("tremolo", &sound.tremolo));
    }
    if sound.slide.time > 0.0 {
        parts.push(format_slide(sound.slide));
    }
    if (sound.volume - 1.0).abs() > f32::EPSILON {
        parts.push(format!("volume {}", trim_float(sound.volume)));
    }
    if parts.is_empty() {
        "sine 1.0".to_string()
    } else {
        parts.join(", ")
    }
}

fn format_modulation(name: &str, modulation: &Modulation) -> String {
    let mut text = format!(
        "{name} {} {}",
        trim_float(modulation.rate),
        trim_float(modulation.depth)
    );
    let table = if modulation.wave.is_empty() {
        "sine"
    } else {
        &modulation.wave
    };
    let token = wave_token(table);
    if token != "sine" {
        let _ = write!(text, " {token}");
    }
    text
}

fn format_slide(slide: Slide) -> String {
    format!(
        "slide {} {}",
        trim_float(slide.amount),
        trim_float(slide.time)
    )
}

/// The token that re-parses to a given table name, inverting the legacy
/// `hard_square` = the hard `square` table spelling. Other names round-trip
/// unchanged (built-in or custom `# Waves`).
fn wave_token(table: &str) -> &str {
    match table {
        "square" => "hard_square",
        other => other,
    }
}

fn layer_directives(layer: &Layer) -> String {
    let mut parts = Vec::new();
    if let Some(binding) = &layer.volume_param {
        if (binding.scale - 1.0).abs() > f32::EPSILON {
            parts.push(format!(
                "@volume param {} {}",
                binding.param,
                trim_float(binding.scale)
            ));
        } else {
            parts.push(format!("@volume param {}", binding.param));
        }
    } else if layer.volume != 1.0 {
        parts.push(format!("@volume {}", trim_float(layer.volume)));
    }
    if layer.pan != 0.0 {
        parts.push(format!("@pan {}", trim_float(layer.pan)));
    }
    if layer.muted {
        parts.push("@mute true".to_string());
    }
    if layer.solo {
        parts.push("@solo true".to_string());
    }
    if let Some(length) = layer.loop_length {
        if layer.loop_skip == 0.0 {
            parts.push(format!("@loop {}", trim_float(length)));
        } else {
            parts.push(format!(
                "@loop {} skip {}",
                trim_float(length),
                trim_float(layer.loop_skip)
            ));
        }
    }
    if let Some(tuning) = &layer.tuning {
        parts.push(format!("@tuning {tuning}"));
    }
    if layer.root != 440.0 {
        parts.push(format!("@root {}", trim_float(layer.root)));
    }
    for effect in &layer.effects {
        parts.push(format!("@effect {}", format_effect(effect)));
    }
    parts.join(" ")
}

fn format_effect(effect: &Effect) -> String {
    match effect {
        Effect::Lowpass { cutoff } => format!("lowpass {}", trim_float(*cutoff)),
        Effect::Highpass { cutoff } => format!("highpass {}", trim_float(*cutoff)),
        Effect::Echo {
            delay,
            feedback,
            mix,
        } => format!(
            "echo {} {} {}",
            trim_float(*delay),
            trim_float(*feedback),
            trim_float(*mix)
        ),
        Effect::Tremolo { rate, depth } => {
            format!("tremolo {} {}", trim_float(*rate), trim_float(*depth))
        }
        Effect::Bitcrusher { bits } => format!("bitcrusher {bits}"),
    }
}

fn format_envelope(name: &str, envelope: Envelope) -> String {
    format!(
        "{name} {}{}",
        trim_float(envelope.duration),
        curve_suffix(envelope.curve)
    )
}

fn trim_float(value: f32) -> String {
    if value.fract() == 0.0 {
        format!("{}", value as i64)
    } else {
        format!("{value}")
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)]
    use super::{Articulation, Curve, ramp_offset};
    use crate::track::{Song, parse};

    #[test]
    fn full_feature_round_trip_is_idempotent() {
        let source = "\
@tempo 96
@signature 3 4
@param combat 0
@length 32
@jump loop.track 4 if combat > 0
@jump end.track

# Sounds

kick: sample \"kick.wav\" @ -24..0
lead: saw 0.5, attack 0.01 linear, decay 0.2, noise 0.1, vibrato 5 0.3 triangle, slide -2 0.1

# Tunings

## minor

period 2
1 1.2 1.5

# Motifs

## hook

0 0 1
1 7 1 glide 3 volume 0.4 vol-ramp 0.5 ease-in

# Layers

## Bass
@volume 0.8 @pan -0.3 @tuning minor @root 220 @effect lowpass 800

lead 0 0 2 glide 5 volume 0.7 vol-ramp -0.2 linear
motif hook 4 lead transpose 12 tuning minor
kick 8 -12 1
";
        let song = parse(source).unwrap();
        let first = song.to_string();
        let reparsed = parse(&first).unwrap();
        let second = reparsed.to_string();
        assert_eq!(first, second, "serialize is not idempotent");
        assert_eq!(reparsed.signature.beats_per_bar, 3);
        assert_eq!(reparsed.sounds["lead"].slide.amount, -2.0);
        assert_eq!(reparsed.sounds["lead"].vibrato.wave, "triangle");
    }

    #[test]
    fn voice_sound_and_sung_text_round_trip() {
        let source = "\
@tempo 100

# Sounds

choir: voice female tilt 0.6 vibrato 0.5, volume 0.8

# Motifs

## verse

0 0 1 \"hal\"
1 2 1 \"lo\"

# Layers

## Vocals

choir 0 0 2 \"hal'lo du\" glide 2 volume 0.7
choir 2 -2 1 \"'welt\"
motif verse 4 choir
  note 1 text \"o du\"
";
        let song = parse(source).unwrap();
        assert_eq!(song.sounds["choir"].voice, "female");
        assert_eq!(song.sounds["choir"].voice_overrides.len(), 2);
        assert_eq!(song.layers[0].notes[0].text, "hal'lo du");
        assert_eq!(song.layers[0].notes[1].text, "welt");
        assert_eq!(song.layers[0].notes[1].articulation, Articulation::Legato);
        assert_eq!(song.layers[0].notes[0].articulation, Articulation::Separate);
        assert_eq!(song.motifs["verse"][0].text, "hal");
        let instance = &song.layers[0].motif_instances[0];
        assert_eq!(instance.overrides[0].text.as_deref(), Some("o du"));
        let resolved = song.motifs["verse"][1].resolve(instance, Some(&instance.overrides[0]));
        assert_eq!(resolved.text, "o du");
        let first = song.to_string();
        let reparsed = parse(&first).unwrap();
        assert_eq!(first, reparsed.to_string(), "serialize is not idempotent");
        assert_eq!(reparsed.layers[0].notes[0].text, "hal'lo du");
        assert_eq!(reparsed.motifs["verse"][1].text, "lo");
        assert_eq!(
            reparsed.sounds["choir"].voice_overrides,
            song.sounds["choir"].voice_overrides
        );
    }

    #[test]
    fn motif_overrides_and_trim_round_trip() {
        let source = "\
@tempo 120

# Sounds

bell: sine 1

# Motifs

## hook

0 0 1
1 7 1

# Layers

## A

motif hook 0 bell transpose 2
  note 1 pitch 12 volume 0.5
  note 0 start 0.5
  trim 0 2
";
        let song = parse(source).unwrap();
        let again = parse(&song.to_string()).unwrap();
        let instance = &again.layers[0].motif_instances[0];
        assert_eq!(instance.overrides.len(), 2);
        assert_eq!(instance.overrides[0].index, 1);
        assert_eq!(instance.overrides[0].pitch, Some(12.0));
        assert_eq!(instance.overrides[0].volume, Some(0.5));
        assert_eq!(instance.overrides[1].start, Some(0.5));
        assert_eq!(
            instance.trim.map(|trim| (trim.from, trim.count)),
            Some((0, 2))
        );
    }

    #[test]
    fn smooth_curve_is_default_and_omitted() {
        let source = "@tempo 120\n# Sounds\nbell: sine 1\n# Layers\n\n## A\nbell 0 0 2 glide 5\n";
        let serialized = parse(source).unwrap().to_string();
        assert!(serialized.contains("glide 5\n"), "{serialized}");
        assert!(!serialized.contains("ease"));
    }

    #[test]
    fn canonical_source_is_a_fixpoint() {
        // A canonically-formatted file must come back byte-for-byte unchanged
        // after parse → serialize, so importing then exporting is a no-op.
        let source = "@tempo 120\n\n# Sounds\n\nbell: sine 1\n\n# Layers\n\n## A\n\nbell 0 0 1\n\n";
        assert_eq!(parse(source).unwrap().to_string(), source);
    }

    #[test]
    fn canonical_source_with_directives_is_a_fixpoint() {
        let source = "@tempo 96\n@signature 3 4\n\n# Sounds\n\nbell: sine 1, decay 0.2\n\n# Layers\n\n## Melody\n@volume 0.8 @pan -0.3\n\nbell 0 0 2 glide 5 linear\n\n";
        assert_eq!(parse(source).unwrap().to_string(), source);
    }

    #[test]
    fn chiptune_features_round_trip() {
        let source = "\
@tempo 150

# Waves

## gb_lead

0.25 1
0.75 -1 linear

# Sounds

pulse: hard_square 0.8 duty 0.25, arpeggio 0 4 7 rate 30
wave: wave gb_lead 0.9, wave@2 gb_lead 0.3

# Layers

## Lead

pulse 0 0 2
wave 2 0 2
";
        let song = parse(source).unwrap();
        assert_eq!(song.waves["gb_lead"].len(), 2);
        assert_eq!(song.waves["gb_lead"][0].x, 0.25);
        assert_eq!(song.waves["gb_lead"][0].y, 1.0);
        assert_eq!(song.waves["gb_lead"][1].x, 0.75);
        assert_eq!(song.waves["gb_lead"][1].y, -1.0);
        assert_eq!(song.sounds["pulse"].harmonics[0].duty, 0.25);
        assert_eq!(song.sounds["pulse"].arpeggio.offsets, vec![0.0, 4.0, 7.0]);
        assert_eq!(song.sounds["pulse"].arpeggio.rate, 30.0);
        assert_eq!(song.sounds["wave"].harmonics[0].wave, "gb_lead");
        assert_eq!(song.sounds["wave"].harmonics[1].multiplier, 2.0);
        let first = song.to_string();
        let reparsed = parse(&first).unwrap();
        assert_eq!(first, reparsed.to_string(), "serialize is not idempotent");
        assert_eq!(reparsed.waves["gb_lead"], song.waves["gb_lead"]);
        assert_eq!(reparsed.sounds["pulse"].harmonics[0].duty, 0.25);
        assert_eq!(
            reparsed.sounds["pulse"].arpeggio,
            song.sounds["pulse"].arpeggio
        );
    }

    #[test]
    fn old_and_new_section_syntax_parse_identically() {
        let old_syntax = "\
@tempo 100

# Tunings

## penta

1 1.2 1.5

# Motifs

## hook

0 0 1

# Layers

## A
@volume 0.5

motif hook 0 bell
bell 0 0 1
";
        let new_syntax = "\
@tempo 100

# Tunings

## penta

1 1.2 1.5

# Motifs

## hook

0 0 1

# Layers

## A
@volume 0.5

motif hook 0 bell
bell 0 0 1
";
        let from_old = parse(old_syntax).unwrap();
        let from_new = parse(new_syntax).unwrap();
        assert_eq!(from_old.to_string(), from_new.to_string());
        assert_eq!(from_new.layers[0].name, "A");
        assert_eq!(from_new.layers[0].volume, 0.5);
        assert!(from_new.motifs.contains_key("hook"));
        assert!(from_new.tunings.contains_key("penta"));
    }

    #[test]
    fn from_str_parses() {
        let song: Song = "@tempo 90\n# Layers\n\n## A\n".parse().unwrap();
        assert_eq!(song.tempo, 90.0);
    }

    #[test]
    fn volume_param_round_trips() {
        for source in [
            "@param combat 0\n# Layers\n\n## A\n@volume param combat\nbell 0 0 1\n",
            "@param heat 0\n# Layers\n\n## A\n@volume param heat 0.5\nbell 0 0 1\n",
        ] {
            let song = parse(source).unwrap();
            let again = parse(&song.to_string()).unwrap();
            assert_eq!(song.layers[0].volume_param, again.layers[0].volume_param);
        }
    }

    #[test]
    fn linear_curve_round_trips() {
        let source =
            "@tempo 120\n# Sounds\nbell: sine 1\n# Layers\n\n## A\nbell 0 0 2 glide 5 linear\n";
        let again = parse(&parse(source).unwrap().to_string()).unwrap();
        assert_eq!(
            ramp_offset(&again.layers[0].notes[0].pitch_curve()),
            Some((5.0, Curve::LINEAR))
        );
    }

    #[test]
    fn multi_point_note_curve_round_trips() {
        let source = "@tempo 120\n# Sounds\nbell: sine 1\n# Layers\n\n## A\nbell 0 0 2\n  pitch 0 0, 0.5 7 ease, 1 0\n";
        let song = parse(source).unwrap();
        let again = parse(&song.to_string()).unwrap();
        assert_eq!(
            song.layers[0].notes[0].pitch_curve(),
            again.layers[0].notes[0].pitch_curve()
        );
        assert_eq!(again.layers[0].notes[0].pitch_curve().len(), 3);
    }

    #[test]
    fn note_arpeggio_round_trips() {
        let source = "@tempo 120\n# Sounds\nbell: sine 1\n# Layers\n\n## A\nbell 0 0 2 arpeggio 0 4 7 rate 30\n";
        let song = parse(source).unwrap();
        assert!(song.to_string().contains("arpeggio 0 4 7 rate 30"));
        let again = parse(&song.to_string()).unwrap();
        assert_eq!(
            again.layers[0].notes[0].arpeggio.offsets,
            vec![0.0, 4.0, 7.0]
        );
        assert_eq!(again.layers[0].notes[0].arpeggio.rate, 30.0);
    }

    #[test]
    fn motif_note_multi_point_curve_round_trips() {
        let source = "@tempo 120\n# Motifs\n\n## riff\n0 0 2\n  pitch 0 0, 0.5 5 ease, 1 0\n";
        let song = parse(source).unwrap();
        let again = parse(&song.to_string()).unwrap();
        assert_eq!(song.motifs["riff"][0].pitch_curve().len(), 3);
        assert_eq!(
            song.motifs["riff"][0].pitch_curve(),
            again.motifs["riff"][0].pitch_curve()
        );
    }
}
