use super::song::{
    Arpeggio, Articulation, Compare, Condition, ConditionSource, Curve, Effect, Envelope, Harmonic,
    Import, Jump, Layer, Modulation, MotifInstance, MotifNote, Note, NoteInfo, NoteOverride,
    NoteSegment, Refresh, SampleZone, Slide, Song, Sound, Subtitle, TimeSignature, Trim, Tuning,
    UnknownSection, VoiceKnob, VoiceOverride, VolumeBinding, WavePoint, is_audio_filename,
    ramp_curve, volume_ramp,
};
use std::{
    collections::HashSet,
    future::Future,
    path::{Path, PathBuf},
};

/// Errors returned while parsing a `.track` source string or resolving
/// imported files.
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
    /// Syntactic or semantic error on a specific line of the source.
    #[error("line {line}: {message}")]
    Line {
        /// 1-based line number.
        line: usize,
        /// Human-readable description.
        message: String,
    },
    /// An imported file could not be read.
    #[error("import '{path}': read error: {error}")]
    ImportRead {
        /// Path that failed.
        path: PathBuf,
        /// Underlying I/O error.
        error: std::io::Error,
    },
    /// An imported file failed to parse.
    #[error("import '{path}': {error}")]
    ImportParse {
        /// Path that failed.
        path: PathBuf,
        /// Inner parse error.
        error: Box<Self>,
    },
    /// A URL import was requested but the `online` feature is disabled.
    #[error("URL import '{url}' requires the 'online' feature")]
    ImportOffline {
        /// URL that could not be fetched.
        url: String,
    },
    /// Downloading a URL import failed.
    #[error("import download '{url}': {message}")]
    ImportDownload {
        /// URL that failed.
        url: String,
        /// Human-readable failure description.
        message: String,
    },
}

fn err(line: usize, message: impl Into<String>) -> ParseError {
    ParseError::Line {
        line,
        message: message.into(),
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum Section {
    Top,
    Sounds,
    Layer,
    Motif(String),
    Tuning(String),
    Wave(String),
    Subtitles,
    Meta,
    Tags,
    Unknown(usize),
    Group,
}

#[derive(Clone, Copy)]
enum GroupKind {
    Layers,
    Motifs,
    Tunings,
    Waves,
}

/// Parses a `.track` source string into a [`Song`].
///
/// Imports referenced via `@import` are recorded in `song.imports` but not
/// loaded; call [`resolve_imports`] afterwards to merge their sounds.
pub fn parse(source: &str) -> Result<Song, ParseError> {
    let mut song = Song::default();
    let mut section = Section::Top;
    let mut current_group: Option<GroupKind> = None;
    let mut current_layer: Option<Layer> = None;
    let mut current_motif_instance: Option<usize> = None;
    let mut current_note: Option<usize> = None;
    let mut current_motif_note: Option<usize> = None;

    for (index, raw) in source.lines().enumerate() {
        let line_number = index + 1;
        let indented = raw.starts_with([' ', '\t']);
        let line = raw.trim();
        if line.is_empty() {
            continue;
        }

        if let Some(rest) = line.strip_prefix('#') {
            let header = rest.trim();
            current_motif_instance = None;
            current_note = None;
            current_motif_note = None;
            commit_layer(&mut song, &mut current_layer);
            if let Some(subsection) = rest.strip_prefix('#') {
                let name = subsection.trim();
                if let Some(group) = current_group {
                    match group {
                        GroupKind::Layers => {
                            section = Section::Layer;
                            current_layer = Some(Layer::new(name));
                        }
                        GroupKind::Motifs => {
                            song.motifs.entry(name.to_string()).or_default();
                            section = Section::Motif(name.to_string());
                        }
                        GroupKind::Tunings => {
                            song.tunings
                                .entry(name.to_string())
                                .or_insert_with(empty_tuning);
                            section = Section::Tuning(name.to_string());
                        }
                        GroupKind::Waves => {
                            song.waves.entry(name.to_string()).or_default();
                            section = Section::Wave(name.to_string());
                        }
                    }
                    continue;
                }
                if name.eq_ignore_ascii_case("Tags")
                    && matches!(section, Section::Meta | Section::Tags)
                {
                    section = Section::Tags;
                    continue;
                }
            }
            current_group = None;
            if header.eq_ignore_ascii_case("Sounds") {
                section = Section::Sounds;
            } else if header.eq_ignore_ascii_case("Layers") {
                current_group = Some(GroupKind::Layers);
                section = Section::Group;
            } else if header.eq_ignore_ascii_case("Motifs") {
                current_group = Some(GroupKind::Motifs);
                section = Section::Group;
            } else if header.eq_ignore_ascii_case("Tunings") {
                current_group = Some(GroupKind::Tunings);
                section = Section::Group;
            } else if header.eq_ignore_ascii_case("Waves") {
                current_group = Some(GroupKind::Waves);
                section = Section::Group;
            } else if header.eq_ignore_ascii_case("Wavetables") {
                return Err(err(
                    line_number,
                    "'# Wavetables' was renamed: use '# Waves'",
                ));
            } else if header.eq_ignore_ascii_case("Subtitles") {
                section = Section::Subtitles;
            } else if header.eq_ignore_ascii_case("Meta") {
                section = Section::Meta;
            } else if let Some(name) = header
                .strip_prefix("Layer ")
                .or_else(|| header.strip_prefix("Motif "))
                .or_else(|| header.strip_prefix("Tuning "))
            {
                let kind = header.split_whitespace().next().unwrap_or_default();
                return Err(err(
                    line_number,
                    format!(
                        "'# {kind} {name}' was removed: use '## {name}' under a '# {kind}s' header",
                        name = name.trim()
                    ),
                ));
            } else {
                song.unknown.push(UnknownSection {
                    header: header.to_string(),
                    lines: Vec::new(),
                });
                section = Section::Unknown(song.unknown.len() - 1);
            }
            continue;
        }

        // Directives are handled before the unknown-section capture so a global
        // directive (e.g. `@import`, `@tempo`) still applies when it follows an
        // unrecognized `#` section, instead of being silently swallowed.
        if line.starts_with('@') {
            apply_directives(
                &mut song,
                current_layer.as_mut(),
                &section,
                line,
                line_number,
            )?;
            continue;
        }

        if let Section::Unknown(index) = &section {
            song.unknown[*index].lines.push(line.to_string());
            continue;
        }

        match &section {
            Section::Top => {
                return Err(err(
                    line_number,
                    "content before any section header (use '# Sounds' or '# Layer Name')",
                ));
            }
            Section::Sounds => {
                let (name, sound) = parse_sound_line(line, line_number)?;
                song.local_sound_names.insert(name.clone());
                song.sounds.insert(name, sound);
            }
            Section::Layer => {
                let layer = current_layer
                    .as_mut()
                    .ok_or_else(|| err(line_number, "no current layer"))?;
                if indented {
                    if is_note_curve_line(line) {
                        let target = current_note.ok_or_else(|| {
                            err(line_number, "indented pitch/volume curve without a note")
                        })?;
                        apply_note_curve(&mut layer.notes[target], line, line_number)?;
                    } else {
                        let target = current_motif_instance.ok_or_else(|| {
                            err(line_number, "indented override without a preceding motif")
                        })?;
                        apply_motif_override(
                            &mut layer.motif_instances[target],
                            line,
                            line_number,
                        )?;
                    }
                } else {
                    match add_clip_line(layer, line, line_number)? {
                        ClipKind::Motif => {
                            current_motif_instance = Some(layer.motif_instances.len() - 1);
                            current_note = None;
                        }
                        ClipKind::Note => {
                            current_note = Some(layer.notes.len() - 1);
                            current_motif_instance = None;
                        }
                    }
                }
            }
            Section::Motif(name) => {
                if indented {
                    if !is_note_curve_line(line) {
                        return Err(err(
                            line_number,
                            "indented motif line must be a 'pitch'/'volume' curve",
                        ));
                    }
                    let index = current_motif_note.ok_or_else(|| {
                        err(line_number, "indented pitch/volume curve without a note")
                    })?;
                    let notes = song.motifs.entry(name.clone()).or_default();
                    apply_motif_note_curve(&mut notes[index], line, line_number)?;
                } else {
                    let note = parse_motif_note(line, line_number)?;
                    let notes = song.motifs.entry(name.clone()).or_default();
                    notes.push(note);
                    current_motif_note = Some(notes.len() - 1);
                }
            }
            Section::Tuning(name) => {
                let tuning = song
                    .tunings
                    .entry(name.clone())
                    .or_insert_with(empty_tuning);
                apply_tuning_line(tuning, line, line_number)?;
            }
            Section::Wave(name) => {
                let table = song.waves.entry(name.clone()).or_default();
                let point = parse_wave_point(table.last(), line, line_number)?;
                table.push(point);
            }
            Section::Subtitles => {
                let (beat_token, text) = match line.split_once(char::is_whitespace) {
                    Some((beat, text)) => (beat, text.trim().to_string()),
                    None => (line, String::new()),
                };
                let beat = parse_float(beat_token, line_number, "subtitle beat")?;
                song.subtitles.push(Subtitle { beat, text });
            }
            Section::Meta => apply_meta_line(&mut song, line, line_number)?,
            Section::Tags => song.tags.push(line.to_string()),
            Section::Group => {
                return Err(err(
                    line_number,
                    "content directly under a group header (start a '## <name>' subsection)",
                ));
            }
            Section::Unknown(_) => unreachable!("handled above"),
        }
    }

    commit_layer(&mut song, &mut current_layer);
    for layer in &mut song.layers {
        layer
            .notes
            .sort_by(|a, b| a.start.time.total_cmp(&b.start.time));
    }
    for notes in song.motifs.values_mut() {
        notes.sort_by(|a, b| a.start.time.total_cmp(&b.start.time));
    }
    for tuning in song.tunings.values_mut() {
        normalize_tuning(tuning);
    }
    song.subtitles.sort_by(|a, b| a.beat.total_cmp(&b.beat));
    Ok(song)
}

/// A tuning marker with empty degrees; filled in by `apply_tuning_line` and
/// finalized by `normalize_tuning`.
fn empty_tuning() -> Tuning {
    Tuning {
        period: 2.0,
        degrees: Vec::new(),
    }
}

/// Parses one line of a `# Tuning` section: `equal <n>`, `period <ratio>`, or
/// one or more degree ratios (fractions like `3/2` or decimals).
fn apply_tuning_line(
    tuning: &mut Tuning,
    line: &str,
    line_number: usize,
) -> Result<(), ParseError> {
    let mut parts = line.split_whitespace();
    let first = parts
        .next()
        .ok_or_else(|| err(line_number, "empty tuning line"))?;
    match first {
        "equal" => {
            let steps_text = parts
                .next()
                .ok_or_else(|| err(line_number, "equal needs a step count"))?;
            let steps: usize = steps_text
                .parse()
                .map_err(|_| err(line_number, format!("invalid step count '{steps_text}'")))?;
            *tuning = Tuning::equal(steps);
        }
        "period" => {
            let ratio_text = parts
                .next()
                .ok_or_else(|| err(line_number, "period needs a ratio"))?;
            tuning.period = parse_ratio(ratio_text, line_number)?;
        }
        _ => {
            for token in std::iter::once(first).chain(parts) {
                tuning.degrees.push(parse_ratio(token, line_number)?);
            }
        }
    }
    Ok(())
}

/// Parses a ratio written as a fraction (`3/2`) or a decimal (`1.5`).
fn parse_ratio(token: &str, line_number: usize) -> Result<f32, ParseError> {
    if let Some((numerator, denominator)) = token.split_once('/') {
        let numerator = parse_float(numerator, line_number, "ratio numerator")?;
        let denominator = parse_float(denominator, line_number, "ratio denominator")?;
        if denominator == 0.0 {
            return Err(err(line_number, "ratio has zero denominator"));
        }
        Ok(numerator / denominator)
    } else {
        parse_float(token, line_number, "ratio")
    }
}

/// Normalizes a tuning's degrees so the first is 1.0; ensures at least `[1.0]`.
fn normalize_tuning(tuning: &mut Tuning) {
    if tuning.degrees.is_empty() {
        tuning.degrees.push(1.0);
        return;
    }
    let base = tuning.degrees[0];
    if base != 0.0 && base != 1.0 {
        for degree in &mut tuning.degrees {
            *degree /= base;
        }
    }
}

/// Adds one layer line: a motif instance or a note.
/// True for an indented note sub-line setting a `pitch`/`volume` curve.
fn is_note_curve_line(line: &str) -> bool {
    matches!(line.split_whitespace().next(), Some("pitch" | "volume"))
}

/// Applies an indented `pitch <points>` / `volume <points>` sub-line to a note,
/// where each point is `<x> <y> [curve]` (x in 0..1 over the note, y an offset
/// from the note's pitch/volume), comma-separated.
fn apply_note_curve(note: &mut Note, line: &str, line_number: usize) -> Result<(), ParseError> {
    let (key, rest) = line
        .split_once(char::is_whitespace)
        .ok_or_else(|| err(line_number, "note curve needs points"))?;
    let points = parse_wave_points(rest, line_number)?;
    match key {
        "pitch" => note.set_pitch_curve(&points),
        "volume" => note.volume_curve = points,
        other => return Err(err(line_number, format!("unknown note curve '{other}'"))),
    }
    Ok(())
}

/// Applies an indented `pitch`/`volume` sub-line to a motif note (see
/// [`apply_note_curve`]).
fn apply_motif_note_curve(
    note: &mut MotifNote,
    line: &str,
    line_number: usize,
) -> Result<(), ParseError> {
    let (key, rest) = line
        .split_once(char::is_whitespace)
        .ok_or_else(|| err(line_number, "note curve needs points"))?;
    let points = parse_wave_points(rest, line_number)?;
    match key {
        "pitch" => note.set_pitch_curve(&points),
        "volume" => note.volume_curve = points,
        other => return Err(err(line_number, format!("unknown note curve '{other}'"))),
    }
    Ok(())
}

/// Parses a comma-separated list of `<x> <y> [curve]` envelope points.
fn parse_wave_points(value: &str, line_number: usize) -> Result<Vec<WavePoint>, ParseError> {
    value
        .split(',')
        .map(|entry| {
            let tokens: Vec<&str> = entry.split_whitespace().collect();
            let (x, y, curve_token) = match tokens.as_slice() {
                [x, y] => (*x, *y, None),
                [x, y, curve] => (*x, *y, Some(*curve)),
                _ => return Err(err(line_number, "envelope point needs '<x> <y> [curve]'")),
            };
            Ok(WavePoint {
                x: parse_float(x, line_number, "envelope x")?,
                y: parse_float(y, line_number, "envelope y")?,
                curve: match curve_token {
                    Some(token) => parse_curve(token, line_number)?,
                    None => Curve::default(),
                },
            })
        })
        .collect()
}

fn add_clip_line(
    layer: &mut Layer,
    line: &str,
    line_number: usize,
) -> Result<ClipKind, ParseError> {
    Ok(match parse_track_line(line, line_number)? {
        TrackLine::Note(note) => {
            layer.notes.push(note);
            ClipKind::Note
        }
        TrackLine::Motif(instance) => {
            layer.motif_instances.push(instance);
            ClipKind::Motif
        }
    })
}

enum ClipKind {
    Motif,
    Note,
}

fn commit_layer(song: &mut Song, current: &mut Option<Layer>) {
    if let Some(layer) = current.take() {
        song.layers.push(layer);
    }
}

fn apply_directives(
    song: &mut Song,
    mut layer: Option<&mut Layer>,
    section: &Section,
    line: &str,
    line_number: usize,
) -> Result<(), ParseError> {
    for directive in split_directives(line) {
        let mut parts = directive.splitn(2, char::is_whitespace);
        let key = parts
            .next()
            .ok_or_else(|| err(line_number, "empty directive"))?;
        let value = parts.next().unwrap_or("").trim();
        match section {
            Section::Layer => {
                let layer = layer
                    .as_deref_mut()
                    .ok_or_else(|| err(line_number, "layer directive outside layer"))?;
                apply_layer_directive(layer, key, value, line_number)?;
            }
            _ => apply_song_directive(song, key, value, line_number)?,
        }
    }
    Ok(())
}

/// Applies one `# Meta` line (`<key> <value>`), routing known keys to their
/// [`Song`] fields. The value is the rest of the line; an empty value or an
/// unrecognized key is an error.
fn apply_meta_line(song: &mut Song, line: &str, line_number: usize) -> Result<(), ParseError> {
    let (key, value) = match line.split_once(char::is_whitespace) {
        Some((key, value)) => (key, value.trim()),
        None => (line, ""),
    };
    if value.is_empty() {
        return Err(err(line_number, format!("meta '{key}' needs a value")));
    }
    let field = match key {
        "title" => &mut song.title,
        "description" => &mut song.description,
        "author" => &mut song.author,
        other => return Err(err(line_number, format!("unknown meta key '{other}'"))),
    };
    *field = Some(value.to_string());
    Ok(())
}

fn split_directives(line: &str) -> Vec<&str> {
    line.split('@')
        .map(str::trim)
        .filter(|piece| !piece.is_empty())
        .collect()
}

fn apply_song_directive(
    song: &mut Song,
    key: &str,
    value: &str,
    line_number: usize,
) -> Result<(), ParseError> {
    match key {
        "tempo" => parse_tempo(song, value, line_number)?,
        "import" => song.imports.push(parse_import(value, line_number)?),
        "length" => song.length = Some(parse_float(value, line_number, "length")?),
        "signature" => song.signature = parse_signature(value, line_number)?,
        "jump" => song.jumps.push(parse_jump(value, line_number)?),
        "param" => {
            let (name, default) = parse_param(value, line_number)?;
            song.params.insert(name, default);
        }
        other => {
            return Err(err(
                line_number,
                format!("unknown global directive '{other}'"),
            ));
        }
    }
    Ok(())
}

/// Parses `@tempo <start> [<end>]`: a steady tempo, or a start and end tempo
/// the song ramps between (stored as the multiplier `end / start`).
fn parse_tempo(song: &mut Song, value: &str, line_number: usize) -> Result<(), ParseError> {
    let mut parts = value.split_whitespace();
    let start = parse_float(
        parts
            .next()
            .ok_or_else(|| err(line_number, "@tempo needs a value"))?,
        line_number,
        "tempo",
    )?;
    song.tempo = start;
    song.tempo_factor = match parts.next() {
        Some(end) if start > 0.0 => parse_float(end, line_number, "end tempo")? / start,
        _ => 1.0,
    };
    Ok(())
}

fn parse_signature(value: &str, line_number: usize) -> Result<TimeSignature, ParseError> {
    let mut parts = value.split_whitespace();
    let beats_per_bar = parse_positive(
        parts
            .next()
            .ok_or_else(|| err(line_number, "@signature needs <beats_per_bar> [beat_unit]"))?,
        line_number,
        "beats_per_bar",
    )?;
    let beat_unit = match parts.next() {
        Some(text) => parse_positive(text, line_number, "beat_unit")?,
        None => 4,
    };
    if parts.next().is_some() {
        return Err(err(
            line_number,
            "@signature takes only <beats_per_bar> [beat_unit]",
        ));
    }
    Ok(TimeSignature {
        beats_per_bar,
        beat_unit,
    })
}

fn parse_positive(value: &str, line_number: usize, key: &str) -> Result<u32, ParseError> {
    match value.parse::<u32>() {
        Ok(0) | Err(_) => Err(err(
            line_number,
            format!("invalid positive integer for {key}: '{value}'"),
        )),
        Ok(number) => Ok(number),
    }
}

fn parse_param(value: &str, line_number: usize) -> Result<(String, f32), ParseError> {
    let mut parts = value.split_whitespace();
    let name = parts
        .next()
        .ok_or_else(|| err(line_number, "@param needs a name"))?
        .to_string();
    let default = match parts.next() {
        Some(text) => parse_float(text, line_number, "param default")?,
        None => 0.0,
    };
    if parts.next().is_some() {
        return Err(err(line_number, "@param takes only <name> [default]"));
    }
    Ok((name, default))
}

/// Parses `@jump <target> [<skip>] [if <source> <op> <value>]`.
fn parse_jump(value: &str, line_number: usize) -> Result<Jump, ParseError> {
    let mut parts = value.split_whitespace().peekable();
    let target = parts
        .next()
        .ok_or_else(|| err(line_number, "@jump needs a target"))?
        .to_string();
    let skip = if parts.peek().is_some_and(|token| *token != "if") {
        let text = parts.next().unwrap_or("0");
        parse_float(text, line_number, "jump skip")?
    } else {
        0.0
    };
    let condition = match parts.next() {
        Some("if") => Some(parse_condition(&mut parts, line_number)?),
        Some(token) => {
            return Err(err(line_number, format!("expected 'if', got '{token}'")));
        }
        None => None,
    };
    Ok(Jump {
        target,
        skip,
        condition,
    })
}

/// Parses `<source> <op> <value>` where source is `plays`, `random`, or a
/// parameter name.
fn parse_condition<'a>(
    parts: &mut impl Iterator<Item = &'a str>,
    line_number: usize,
) -> Result<Condition, ParseError> {
    let source_text = parts
        .next()
        .ok_or_else(|| err(line_number, "condition needs a source"))?;
    let source = match source_text {
        "plays" => ConditionSource::Plays,
        "random" => ConditionSource::Random,
        name => ConditionSource::Param(name.to_string()),
    };
    let op_text = parts
        .next()
        .ok_or_else(|| err(line_number, "condition needs a comparison"))?;
    let compare = match op_text {
        "<" => Compare::Less,
        "<=" => Compare::LessEqual,
        ">" => Compare::Greater,
        ">=" => Compare::GreaterEqual,
        "==" => Compare::Equal,
        "!=" => Compare::NotEqual,
        other => {
            return Err(err(line_number, format!("unknown comparison '{other}'")));
        }
    };
    let value_text = parts
        .next()
        .ok_or_else(|| err(line_number, "condition needs a value"))?;
    let value = parse_float(value_text, line_number, "condition value")?;
    if parts.next().is_some() {
        return Err(err(line_number, "extra tokens after condition"));
    }
    Ok(Condition {
        source,
        compare,
        value,
    })
}

fn parse_import(value: &str, line_number: usize) -> Result<Import, ParseError> {
    let mut parts = value.split_whitespace();
    let path = parts
        .next()
        .ok_or_else(|| err(line_number, "@import needs a path"))?
        .to_string();
    let refresh = match parts.next() {
        Some("refresh") => true,
        Some(token) => {
            return Err(err(
                line_number,
                format!("unexpected import token '{token}'"),
            ));
        }
        None => false,
    };
    if parts.next().is_some() {
        return Err(err(line_number, "extra tokens after import refresh"));
    }
    Ok(Import { path, refresh })
}

fn parse_loop(layer: &mut Layer, value: &str, line_number: usize) -> Result<(), ParseError> {
    let mut parts = value.split_whitespace();
    let length = parse_float(
        parts
            .next()
            .ok_or_else(|| err(line_number, "loop needs a length"))?,
        line_number,
        "loop",
    )?;
    layer.loop_length = Some(length);
    layer.loop_skip = match parts.next() {
        Some("skip") => {
            let value = parts
                .next()
                .ok_or_else(|| err(line_number, "loop skip needs a value"))?;
            parse_float(value, line_number, "skip")?
        }
        Some(token) => {
            return Err(err(line_number, format!("unexpected loop token '{token}'")));
        }
        None => 0.0,
    };
    if parts.next().is_some() {
        return Err(err(line_number, "extra tokens after loop skip"));
    }
    Ok(())
}

fn apply_layer_directive(
    layer: &mut Layer,
    key: &str,
    value: &str,
    line_number: usize,
) -> Result<(), ParseError> {
    match key {
        "volume" => parse_layer_volume(layer, value, line_number)?,
        "pan" => layer.pan = parse_float(value, line_number, "pan")?,
        "mute" => layer.muted = parse_bool(value, line_number, "mute")?,
        "solo" => layer.solo = parse_bool(value, line_number, "solo")?,
        "loop" => parse_loop(layer, value, line_number)?,
        "effect" => layer.effects.push(parse_effect(value, line_number)?),
        "tuning" => layer.tuning = Some(value.to_string()),
        "root" => layer.root = parse_float(value, line_number, "root")?,
        other => {
            return Err(err(
                line_number,
                format!("unknown layer directive '{other}'"),
            ));
        }
    }
    Ok(())
}

/// Parses a `@volume` value: either a static `<float>` or a parameter binding
/// `param <name> [scale]` for vertical layering.
fn parse_layer_volume(
    layer: &mut Layer,
    value: &str,
    line_number: usize,
) -> Result<(), ParseError> {
    let mut tokens = value.split_whitespace();
    let first = tokens
        .next()
        .ok_or_else(|| err(line_number, "@volume needs a value or 'param <name>'"))?;
    if first == "param" {
        let name = tokens
            .next()
            .ok_or_else(|| err(line_number, "@volume param needs a parameter name"))?;
        let scale = match tokens.next() {
            Some(token) => parse_float(token, line_number, "volume scale")?,
            None => 1.0,
        };
        layer.volume_param = Some(VolumeBinding {
            param: name.to_string(),
            scale,
        });
    } else {
        layer.volume = parse_float(first, line_number, "volume")?;
        layer.volume_param = None;
    }
    Ok(())
}

fn parse_float(value: &str, line_number: usize, key: &str) -> Result<f32, ParseError> {
    value
        .parse::<f32>()
        .map_err(|_| err(line_number, format!("invalid number for {key}: '{value}'")))
}

fn parse_bool(value: &str, line_number: usize, key: &str) -> Result<bool, ParseError> {
    match value {
        "true" | "1" | "on" | "yes" => Ok(true),
        "false" | "0" | "off" | "no" | "" => Ok(false),
        other => Err(err(
            line_number,
            format!("invalid bool for {key}: '{other}'"),
        )),
    }
}

fn parse_sound_line(line: &str, line_number: usize) -> Result<(String, Sound), ParseError> {
    let (name, rest) = line
        .split_once(':')
        .ok_or_else(|| err(line_number, "sound definition needs ':'"))?;
    let name = name.trim().to_string();
    if name.is_empty() {
        return Err(err(line_number, "sound name empty"));
    }
    let mut sound = Sound::default();
    for entry in rest.split(',') {
        let entry = entry.trim();
        if entry.is_empty() {
            continue;
        }
        let mut parts = entry.split_whitespace();
        let key = parts
            .next()
            .ok_or_else(|| err(line_number, "empty sound parameter"))?;
        let value_text = parts
            .next()
            .ok_or_else(|| err(line_number, format!("missing value for '{key}'")))?;
        match key {
            "voice" => {
                sound.voice = value_text.to_string();
                while let Some(knob_text) = parts.next() {
                    if knob_text == "ensemble" {
                        let count_text = parts.next().ok_or_else(|| {
                            err(line_number, "voice 'ensemble' needs a singer count")
                        })?;
                        sound.voice_ensemble = count_text.parse::<usize>().map_err(|_| {
                            err(
                                line_number,
                                format!("invalid ensemble count '{count_text}'"),
                            )
                        })?;
                        continue;
                    }
                    let knob = match knob_text {
                        "formant-shift" => VoiceKnob::FormantShift,
                        "tilt" => VoiceKnob::Tilt,
                        "breathiness" => VoiceKnob::Breathiness,
                        "vibrato" => VoiceKnob::Vibrato,
                        other => {
                            return Err(err(
                                line_number,
                                format!("unknown voice parameter '{other}'"),
                            ));
                        }
                    };
                    let value = parse_float(
                        parts.next().ok_or_else(|| {
                            err(line_number, format!("voice '{knob_text}' needs a value"))
                        })?,
                        line_number,
                        knob_text,
                    )?;
                    sound.voice_overrides.push(VoiceOverride { knob, value });
                }
            }
            "distortion" => sound.distortion = parse_float(value_text, line_number, key)?,
            "detune" => sound.detune = parse_float(value_text, line_number, key)?,
            "vol-jitter" => sound.volume_jitter = parse_float(value_text, line_number, key)?,
            "steps" => {
                sound.steps = value_text
                    .parse::<u32>()
                    .map_err(|_| err(line_number, format!("invalid steps count '{value_text}'")))?;
            }
            "volume" => sound.volume = parse_float(value_text, line_number, key)?,
            "sustain" => sound.sustain = parse_float(value_text, line_number, key)?,
            "noise" => sound.noise = parse_float(value_text, line_number, key)?,
            "attack" => {
                sound.attack = parse_envelope(value_text, parts.next(), line_number)?;
            }
            "decay" => {
                sound.decay = parse_envelope(value_text, parts.next(), line_number)?;
            }
            "release" => {
                sound.release = parse_envelope(value_text, parts.next(), line_number)?;
            }
            "vibrato" => sound.vibrato = parse_lfo(value_text, &mut parts, line_number, key)?,
            "tremolo" => sound.tremolo = parse_lfo(value_text, &mut parts, line_number, key)?,
            "slide" => {
                let amount = parse_float(value_text, line_number, "slide amount")?;
                let time = parse_float(
                    parts
                        .next()
                        .ok_or_else(|| err(line_number, "slide needs <amount> <time>"))?,
                    line_number,
                    "slide time",
                )?;
                sound.slide = Slide { amount, time };
            }
            "sample" => {
                let inner = value_text.strip_prefix('"').ok_or_else(|| {
                    err(
                        line_number,
                        "sample needs a quoted file: sample \"kick.wav\"",
                    )
                })?;
                let file = read_quoted(inner, &mut parts, line_number)?;
                let mut zone = SampleZone::full_range(file);
                if let Some(token) = parts.next() {
                    let range_text = if token == "@" {
                        parts.next().ok_or_else(|| {
                            err(line_number, "sample range needs '<low>..<high>' after '@'")
                        })?
                    } else {
                        token.strip_prefix('@').ok_or_else(|| {
                            err(line_number, format!("unexpected sample token '{token}'"))
                        })?
                    };
                    let (low, high) = range_text
                        .split_once("..")
                        .ok_or_else(|| err(line_number, "sample range must be '<low>..<high>'"))?;
                    zone.pitches = [
                        parse_float(low, line_number, "sample range low")?,
                        parse_float(high, line_number, "sample range high")?,
                    ];
                }
                sound.samples.push(zone);
            }
            "arpeggio" => {
                sound.arpeggio = parse_arpeggio(value_text, &mut parts, line_number)?;
            }
            other if other == "wave" || other.starts_with("wave@") => {
                let multiplier = match other.split_once('@') {
                    Some((_, multiplier_text)) => {
                        parse_float(multiplier_text, line_number, "multiplier")?
                    }
                    None => 1.0,
                };
                let weight = parse_float(
                    parts
                        .next()
                        .ok_or_else(|| err(line_number, "wave needs <name> <weight>"))?,
                    line_number,
                    "wave weight",
                )?;
                sound.harmonics.push(Harmonic {
                    multiplier,
                    weight,
                    duty: 0.0,
                    wave: value_text.to_string(),
                });
            }
            other => {
                let value = parse_float(value_text, line_number, key)?;
                let mut harmonic = parse_harmonic(other, value, line_number)?;
                if let Some(token) = parts.next() {
                    if token != "duty" {
                        return Err(err(
                            line_number,
                            format!("unexpected harmonic token '{token}' (only 'duty <width>')"),
                        ));
                    }
                    let duty = parse_float(
                        parts
                            .next()
                            .ok_or_else(|| err(line_number, "duty needs a width in 0..1"))?,
                        line_number,
                        "duty",
                    )?;
                    if !(0.0..=1.0).contains(&duty) {
                        return Err(err(line_number, "duty must be within 0..1"));
                    }
                    harmonic.duty = duty;
                }
                sound.harmonics.push(harmonic);
            }
        }
    }
    Ok((name, sound))
}

fn parse_effect(value: &str, line_number: usize) -> Result<Effect, ParseError> {
    let mut parts = value.split_whitespace();
    let kind = parts
        .next()
        .ok_or_else(|| err(line_number, "effect needs a type"))?
        .to_string();
    match kind.as_str() {
        "lowpass" => Ok(Effect::Lowpass {
            cutoff: take_float(&mut parts, "cutoff", &kind, line_number)?,
        }),
        "highpass" => Ok(Effect::Highpass {
            cutoff: take_float(&mut parts, "cutoff", &kind, line_number)?,
        }),
        "echo" => Ok(Effect::Echo {
            delay: take_float(&mut parts, "delay", &kind, line_number)?,
            feedback: take_float(&mut parts, "feedback", &kind, line_number)?,
            mix: take_float(&mut parts, "mix", &kind, line_number)?,
        }),
        "tremolo" => Ok(Effect::Tremolo {
            rate: take_float(&mut parts, "rate", &kind, line_number)?,
            depth: take_float(&mut parts, "depth", &kind, line_number)?,
        }),
        "bitcrusher" => {
            let bits = take_float(&mut parts, "bits", &kind, line_number)?;
            Ok(Effect::Bitcrusher {
                bits: bits.clamp(1.0, 16.0) as u8,
            })
        }
        other => Err(err(line_number, format!("unknown effect '{other}'"))),
    }
}

fn take_float<'a>(
    parts: &mut impl Iterator<Item = &'a str>,
    label: &str,
    kind: &str,
    line_number: usize,
) -> Result<f32, ParseError> {
    let text = parts
        .next()
        .ok_or_else(|| err(line_number, format!("effect '{kind}' missing {label}")))?;
    parse_float(text, line_number, label)
}

fn parse_envelope(
    duration_text: &str,
    curve_text: Option<&str>,
    line_number: usize,
) -> Result<Envelope, ParseError> {
    let duration = parse_float(duration_text, line_number, "duration")?;
    let curve = match curve_text {
        Some(token) => parse_curve(token, line_number)?,
        None => Curve::default(),
    };
    Ok(Envelope { duration, curve })
}

fn parse_curve(token: &str, line_number: usize) -> Result<Curve, ParseError> {
    match token {
        "linear" => Ok(Curve::LINEAR),
        "ease-in" => Ok(Curve {
            ease_in: true,
            ease_out: false,
        }),
        "ease-out" => Ok(Curve {
            ease_in: false,
            ease_out: true,
        }),
        "ease" => Ok(Curve {
            ease_in: true,
            ease_out: true,
        }),
        other => Err(err(
            line_number,
            format!("unknown curve '{other}' (expected linear, ease-in, ease-out or ease)"),
        )),
    }
}

fn curve_token<'a>(
    parts: &mut std::iter::Peekable<impl Iterator<Item = &'a str>>,
) -> Option<Curve> {
    let curve = match *parts.peek()? {
        "linear" => Curve::LINEAR,
        "ease-in" => Curve {
            ease_in: true,
            ease_out: false,
        },
        "ease-out" => Curve {
            ease_in: false,
            ease_out: true,
        },
        "ease" => Curve {
            ease_in: true,
            ease_out: true,
        },
        _ => return None,
    };
    parts.next();
    Some(curve)
}

fn parse_lfo<'a>(
    rate_text: &str,
    parts: &mut impl Iterator<Item = &'a str>,
    line_number: usize,
    key: &str,
) -> Result<Modulation, ParseError> {
    let rate = parse_float(rate_text, line_number, key)?;
    let depth = parse_float(
        parts
            .next()
            .ok_or_else(|| err(line_number, format!("{key} needs <rate> <depth>")))?,
        line_number,
        key,
    )?;
    let wave = match parts.next() {
        Some(token) => normalize_wave_name(token),
        None => String::new(),
    };
    Ok(Modulation { rate, depth, wave })
}

/// Maps a waveform keyword to a canonical table name, preserving the legacy
/// spellings (`tri`, `square` = the soft one, `hard_square` = the hard one).
/// Any other name passes through as a `# Waves` reference, validated when
/// the sound is played.
fn normalize_wave_name(token: &str) -> String {
    match token {
        "tri" => "triangle",
        "square" => "soft_square",
        "hard_square" => "square",
        other => other,
    }
    .to_string()
}

fn parse_harmonic(key: &str, weight: f32, line_number: usize) -> Result<Harmonic, ParseError> {
    let (name_token, multiplier) = match key.split_once('@') {
        Some((name, multiplier_text)) => (
            name,
            parse_float(multiplier_text, line_number, "multiplier")?,
        ),
        None => match key {
            "octave" => ("sine", 2.0),
            "fifth" => ("sine", 1.5),
            "sub" => ("triangle", 0.5),
            other => (other, 1.0),
        },
    };
    Ok(Harmonic {
        multiplier,
        weight,
        duty: 0.0,
        wave: normalize_wave_name(name_token),
    })
}

enum TrackLine {
    Note(Note),
    Motif(MotifInstance),
}

/// Parses one track line: a motif instance
/// `motif <name> <start> <sound> [transpose <n>]`, an audio clip
/// `<file> <start>`, or a note
/// `<sound> <start> <pitch> <duration> [glide <semitones> [curve]]
/// [volume <v>] [vol-ramp <target> [curve]]` where `<start>` is the absolute beat
/// position and glide is relative to the note's pitch.
fn parse_track_line(line: &str, line_number: usize) -> Result<TrackLine, ParseError> {
    let mut parts = line.split_whitespace();
    let first = parts
        .next()
        .ok_or_else(|| err(line_number, "empty track line"))?;
    if first == "motif" {
        return parse_motif_instance(&mut parts, line_number).map(TrackLine::Motif);
    }
    if is_audio_filename(first) {
        return Err(err(
            line_number,
            format!(
                "audio clips were replaced by sampler sounds: define '<name>: sample \"{first}\"' under '# Sounds' and place it as a note"
            ),
        ));
    }
    let start = parse_float(
        parts
            .next()
            .ok_or_else(|| err(line_number, "note needs start"))?,
        line_number,
        "start",
    )?;
    let pitch = parse_float(
        parts
            .next()
            .ok_or_else(|| err(line_number, "note needs pitch"))?,
        line_number,
        "pitch",
    )?;
    let duration = parse_float(
        parts
            .next()
            .ok_or_else(|| err(line_number, "note needs duration"))?,
        line_number,
        "duration",
    )?;
    let options = parse_note_options(&mut parts, line_number)?;
    let mut note = Note {
        sound: first.to_string(),
        start: NoteInfo { pitch, time: start },
        end: NoteSegment::hold(duration),
        segments: Vec::new(),
        volume: options.volume,
        volume_curve: options.volume_curve,
        text: options.text,
        articulation: options.articulation,
        arpeggio: options.arpeggio,
    };
    note.set_pitch_curve(&options.pitch_curve);
    Ok(TrackLine::Note(note))
}

fn apply_motif_override(
    instance: &mut MotifInstance,
    line: &str,
    line_number: usize,
) -> Result<(), ParseError> {
    let mut parts = line.split_whitespace();
    match parts.next() {
        Some("note") => instance
            .overrides
            .push(parse_note_override(&mut parts, line_number)?),
        Some("trim") => instance.trim = Some(parse_trim(&mut parts, line_number)?),
        Some(other) => {
            return Err(err(
                line_number,
                format!("motif override must be 'note' or 'trim', got '{other}'"),
            ));
        }
        None => return Err(err(line_number, "empty motif override")),
    }
    Ok(())
}

fn parse_note_override<'a>(
    parts: &mut impl Iterator<Item = &'a str>,
    line_number: usize,
) -> Result<NoteOverride, ParseError> {
    let index = parse_index(
        parts
            .next()
            .ok_or_else(|| err(line_number, "note override needs an index"))?,
        line_number,
        "note index",
    )?;
    let mut result = NoteOverride {
        index,
        ..NoteOverride::default()
    };
    while let Some(field) = parts.next() {
        if field == "skip" {
            result.skip = true;
            continue;
        }
        if field == "pitch-curve" {
            let rest: Vec<&str> = parts.by_ref().collect();
            result.pitch_curve = Some(parse_wave_points(&rest.join(" "), line_number)?);
            continue;
        }
        let value = parts
            .next()
            .ok_or_else(|| err(line_number, format!("override '{field}' needs a value")))?;
        if field == "text" {
            let inner = value
                .strip_prefix('"')
                .ok_or_else(|| err(line_number, "override text must be quoted"))?;
            let text = read_quoted(inner, parts, line_number)?;
            result.text = Some(text.trim_start_matches('\'').to_string());
            continue;
        }
        let number = parse_float(value, line_number, field)?;
        match field {
            "pitch" => result.pitch = Some(number),
            "start" => result.start = Some(number),
            "duration" => result.duration = Some(number),
            "glide" => result.glide = Some(number),
            "volume" => result.volume = Some(number),
            other => {
                return Err(err(
                    line_number,
                    format!("unknown override field '{other}'"),
                ));
            }
        }
    }
    Ok(result)
}

fn parse_trim<'a>(
    parts: &mut impl Iterator<Item = &'a str>,
    line_number: usize,
) -> Result<Trim, ParseError> {
    let from = parse_index(
        parts
            .next()
            .ok_or_else(|| err(line_number, "trim needs <from> <count>"))?,
        line_number,
        "trim from",
    )?;
    let count = parse_index(
        parts
            .next()
            .ok_or_else(|| err(line_number, "trim needs <from> <count>"))?,
        line_number,
        "trim count",
    )?;
    if parts.next().is_some() {
        return Err(err(line_number, "trim takes only <from> <count>"));
    }
    Ok(Trim { from, count })
}

/// A wave line: `<x> <y> [curve]`, one vertex of the waveform. `x` is the
/// phase in `0..=1`, `y` the amplitude, and the optional curve shapes the span
/// to the next vertex. Vertices run in non-decreasing `x`; two at the same `x`
/// make a vertical jump (square, pulse).
fn parse_wave_point(
    previous: Option<&WavePoint>,
    line: &str,
    line_number: usize,
) -> Result<WavePoint, ParseError> {
    let tokens: Vec<&str> = line.split_whitespace().collect();
    let (numbers, curve_token) = match tokens.len() {
        2 => (&tokens[..], None),
        3 => (&tokens[..2], Some(tokens[2])),
        _ => {
            return Err(err(
                line_number,
                "wave vertex needs '<x> <y>' plus an optional curve",
            ));
        }
    };
    let x = parse_float(numbers[0], line_number, "wave x")?;
    let y = parse_float(numbers[1], line_number, "wave y")?;
    if !(0.0..=1.0).contains(&x) {
        return Err(err(line_number, "wave x must be within 0..=1"));
    }
    if let Some(previous) = previous
        && x < previous.x
    {
        return Err(err(
            line_number,
            "wave vertices must run in non-decreasing x",
        ));
    }
    let curve = match curve_token {
        Some(token) => parse_curve(token, line_number)?,
        None => Curve::default(),
    };
    Ok(WavePoint { x, y, curve })
}

/// Collects a `"…"` string that may span several whitespace-split tokens.
/// `first` is the token content after the opening quote; further tokens are
/// consumed (rejoined with single spaces) until one ends with `"`.
fn read_quoted<'a>(
    first: &str,
    parts: &mut impl Iterator<Item = &'a str>,
    line_number: usize,
) -> Result<String, ParseError> {
    let mut text = first.to_string();
    while !text.ends_with('"') {
        let next = parts
            .next()
            .ok_or_else(|| err(line_number, "unterminated note text (missing '\"')"))?;
        text.push(' ');
        text.push_str(next);
    }
    text.pop();
    Ok(text)
}

fn parse_index(value: &str, line_number: usize, key: &str) -> Result<usize, ParseError> {
    value
        .parse::<usize>()
        .map_err(|_| err(line_number, format!("invalid index for {key}: '{value}'")))
}

fn parse_motif_instance<'a>(
    parts: &mut impl Iterator<Item = &'a str>,
    line_number: usize,
) -> Result<MotifInstance, ParseError> {
    let name = parts
        .next()
        .ok_or_else(|| err(line_number, "motif needs a name"))?
        .to_string();
    let start = parse_float(
        parts
            .next()
            .ok_or_else(|| err(line_number, "motif needs start"))?,
        line_number,
        "start",
    )?;
    let sound = parts
        .next()
        .ok_or_else(|| err(line_number, "motif needs a sound"))?
        .to_string();
    let mut transpose = 0.0;
    let mut tuning = None;
    while let Some(token) = parts.next() {
        match token {
            "transpose" => {
                let value = parts
                    .next()
                    .ok_or_else(|| err(line_number, "transpose needs a value"))?;
                transpose = parse_float(value, line_number, "transpose")?;
            }
            "tuning" => {
                tuning = Some(
                    parts
                        .next()
                        .ok_or_else(|| err(line_number, "tuning needs a name"))?
                        .to_string(),
                );
            }
            other => {
                return Err(err(
                    line_number,
                    format!("unexpected motif token '{other}'"),
                ));
            }
        }
    }
    Ok(MotifInstance {
        name,
        start,
        sound,
        transpose,
        tuning,
        overrides: Vec::new(),
        trim: None,
    })
}

/// A motif note line `<start> <pitch> <duration> [glide <semitones> [curve]]
/// [volume <v>] [vol-ramp <target> [curve]]`.
fn parse_motif_note(line: &str, line_number: usize) -> Result<MotifNote, ParseError> {
    let mut parts = line.split_whitespace();
    let start = parse_float(
        parts
            .next()
            .ok_or_else(|| err(line_number, "motif note needs start"))?,
        line_number,
        "start",
    )?;
    let pitch = parse_float(
        parts
            .next()
            .ok_or_else(|| err(line_number, "motif note needs pitch"))?,
        line_number,
        "pitch",
    )?;
    let duration = parse_float(
        parts
            .next()
            .ok_or_else(|| err(line_number, "motif note needs duration"))?,
        line_number,
        "duration",
    )?;
    let options = parse_note_options(&mut parts, line_number)?;
    let mut note = MotifNote {
        start: NoteInfo { pitch, time: start },
        end: NoteSegment::hold(duration),
        segments: Vec::new(),
        volume: options.volume,
        volume_curve: options.volume_curve,
        text: options.text,
        articulation: options.articulation,
        arpeggio: options.arpeggio,
    };
    note.set_pitch_curve(&options.pitch_curve);
    Ok(note)
}

/// Parses an `arpeggio <offset>… rate <steps per second>` clause, with `first`
/// the first offset already taken. Reads further offsets until `rate`, then the
/// rate value; stops there so callers can keep reading later tokens.
fn parse_arpeggio<'a>(
    first: &str,
    parts: &mut impl Iterator<Item = &'a str>,
    line_number: usize,
) -> Result<Arpeggio, ParseError> {
    let mut offsets = vec![parse_float(first, line_number, "arpeggio offset")?];
    let mut rate = 0.0;
    while let Some(token) = parts.next() {
        if token == "rate" {
            rate = parse_float(
                parts
                    .next()
                    .ok_or_else(|| err(line_number, "arpeggio rate needs a value"))?,
                line_number,
                "arpeggio rate",
            )?;
            break;
        }
        offsets.push(parse_float(token, line_number, "arpeggio offset")?);
    }
    if rate <= 0.0 {
        return Err(err(line_number, "arpeggio needs 'rate <steps per second>'"));
    }
    Ok(Arpeggio { offsets, rate })
}

/// Optional trailing tokens shared by notes and motif notes:
/// `glide <offset> [curve]`, `volume <v>`, `vol-ramp <target> [curve]`,
/// `arpeggio <offset>… rate <n>`, `"<syllable>"` (sung text for voice sounds).
struct NoteOptions {
    pitch_curve: Vec<WavePoint>,
    volume: f32,
    volume_curve: Vec<WavePoint>,
    text: String,
    articulation: Articulation,
    arpeggio: Arpeggio,
}

fn parse_note_options<'a>(
    parts: &mut impl Iterator<Item = &'a str>,
    line_number: usize,
) -> Result<NoteOptions, ParseError> {
    let mut parts = parts.peekable();
    let mut options = NoteOptions {
        pitch_curve: Vec::new(),
        volume: 1.0,
        volume_curve: Vec::new(),
        text: String::new(),
        articulation: Articulation::Separate,
        arpeggio: Arpeggio::default(),
    };
    while let Some(token) = parts.next() {
        if let Some(inner) = token.strip_prefix('"') {
            let text = read_quoted(inner, &mut parts, line_number)?;
            match text.strip_prefix('\'') {
                Some(rest) => {
                    options.text = rest.to_string();
                    options.articulation = Articulation::Legato;
                }
                None => options.text = text,
            }
            continue;
        }
        match token {
            "glide" => {
                let value = parts
                    .next()
                    .ok_or_else(|| err(line_number, "glide needs an offset"))?;
                let offset = parse_float(value, line_number, "glide")?;
                let curve = curve_token(&mut parts).unwrap_or_default();
                options.pitch_curve = ramp_curve(offset, curve);
            }
            "volume" => {
                let value = parts
                    .next()
                    .ok_or_else(|| err(line_number, "volume needs a value"))?;
                options.volume = parse_float(value, line_number, "volume")?;
            }
            "vol-ramp" => {
                let value = parts
                    .next()
                    .ok_or_else(|| err(line_number, "vol-ramp needs a target factor"))?;
                let target = parse_float(value, line_number, "vol-ramp")?;
                let curve = curve_token(&mut parts).unwrap_or_default();
                options.volume_curve = volume_ramp(target, curve);
            }
            "arpeggio" => {
                let first = parts
                    .next()
                    .ok_or_else(|| err(line_number, "arpeggio needs an offset"))?;
                options.arpeggio = parse_arpeggio(first, &mut parts, line_number)?;
            }
            other => {
                return Err(err(line_number, format!("unexpected note token '{other}'")));
            }
        }
    }
    Ok(options)
}

/// Parses `source` with sounds from `sounds_source` already merged in.
///
/// Parses both sources, then merges all sounds defined in `sounds_source`
/// into the main song. Local sound definitions in `source` take precedence
/// over sounds with the same name from `sounds_source`.
pub fn parse_with_sounds(source: &str, sounds_source: &str) -> Result<Song, ParseError> {
    let mut song = parse(source)?;
    let library = parse(sounds_source)?;
    for (name, sound) in library.sounds {
        song.sounds.entry(name).or_insert(sound);
    }
    for (name, table) in library.waves {
        song.waves.entry(name).or_insert(table);
    }
    Ok(song)
}

/// Resolves all `@import` references in `song` through a custom loader.
///
/// Like [`resolve_imports`] but reads import sources via `load` instead of the
/// filesystem, so it works without disk access (wasm, remote URLs, embedded
/// assets). `load` receives the resolved import path and returns its `.track`
/// source, or `None` if it cannot be found. Both sounds and motifs from imports
/// are merged in (local definitions win). URL imports (containing `://`) are
/// passed to `load` verbatim rather than joined onto the filesystem base.
/// Missing or unparseable imports are skipped; cycles are broken by visiting
/// each path at most once.
///
/// The loader receives each resolved import path and a `force` flag (true when
/// the cache should be bypassed for that import). [`Refresh::Force`] forces it
/// for every import; a per-import `@import <url> refresh` forces just that one.
///
/// ```
/// use std::collections::HashMap;
/// use game_audio::track::{parse, resolve_imports_with, Refresh};
///
/// let files = HashMap::from([(
///     "https://example.com/lib.track".to_string(),
///     "# Sounds\nlead: sine 1.0\n# Motifs\n\n## hook\n0 0 1\n".to_string(),
/// )]);
/// let mut song = parse("@import https://example.com/lib.track\n").unwrap();
/// resolve_imports_with(&mut song, None, Refresh::Cached, |path, _force| {
///     files.get(path.to_str()?).cloned()
/// });
/// assert!(song.sounds.contains_key("lead"));
/// assert!(song.motifs.contains_key("hook"));
/// ```
pub fn resolve_imports_with(
    song: &mut Song,
    base: Option<&Path>,
    refresh: Refresh,
    mut load: impl FnMut(&Path, bool) -> Option<String>,
) {
    let mut visited = HashSet::new();
    for import in song.imports.clone() {
        let resolved = resolve_import_path(base, &import.path);
        let force = import.refresh || refresh == Refresh::Force;
        load_import_with(song, &resolved, force, refresh, &mut load, &mut visited);
    }
}

/// Like [`resolve_imports_with`] but the loader is asynchronous, so a browser
/// host can fetch URL imports via `fetch`.
///
/// `load` receives the resolved import path and a `force` flag and returns a
/// future yielding the `.track` source (or `None` if unavailable). The whole
/// import graph is traversed in the same order as the synchronous version. The
/// library performs no I/O itself and pulls in no browser dependency — the host
/// supplies the fetching.
///
/// `base` may be a URL directory (e.g. `https://host/songs`): a song fetched
/// from a URL passes its directory so its relative `@import`s resolve against
/// that URL rather than the filesystem.
///
/// ```
/// # use std::{collections::HashMap, future::{Future, ready}, sync::Arc, task::{Context, Poll, Wake}};
/// # use game_audio::track::{parse, resolve_imports_async, Refresh};
/// # struct Noop;
/// # impl Wake for Noop { fn wake(self: Arc<Self>) {} }
/// # fn block_on<F: Future>(fut: F) -> F::Output {
/// #     let waker = Arc::new(Noop).into();
/// #     let mut cx = Context::from_waker(&waker);
/// #     let mut fut = Box::pin(fut);
/// #     loop { if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) { return v; } }
/// # }
/// # fn main() {
/// let files = HashMap::from([(
///     "https://example.com/lib.track".to_string(),
///     "# Sounds\nlead: sine 1.0\n".to_string(),
/// )]);
/// let mut song = parse("@import https://example.com/lib.track\n").unwrap();
/// block_on(resolve_imports_async(&mut song, None, Refresh::Cached, |path, _force| {
///     // In a browser this returns `fetch(url).await`; here it reads a map.
///     ready(path.to_str().and_then(|url| files.get(url).cloned()))
/// }));
/// assert!(song.sounds.contains_key("lead"));
/// # }
/// ```
pub async fn resolve_imports_async<Fut>(
    song: &mut Song,
    base: Option<&Path>,
    refresh: Refresh,
    mut load: impl FnMut(PathBuf, bool) -> Fut,
) where
    Fut: Future<Output = Option<String>>,
{
    fn queue(
        imports: &[Import],
        base: Option<&Path>,
        refresh: Refresh,
        stack: &mut Vec<(PathBuf, bool)>,
    ) {
        for import in imports.iter().rev() {
            let resolved = resolve_import_path(base, &import.path);
            let force = import.refresh || refresh == Refresh::Force;
            stack.push((resolved, force));
        }
    }
    let mut visited = HashSet::new();
    let mut stack = Vec::new();
    queue(&song.imports, base, refresh, &mut stack);
    while let Some((path, force)) = stack.pop() {
        if !visited.insert(path.clone()) {
            continue;
        }
        let source = if is_std(&path.to_string_lossy()) {
            std_source(&path.to_string_lossy()).map(str::to_string)
        } else {
            load(path.clone(), force).await
        };
        let Some(source) = source else {
            continue;
        };
        let Ok(nested) = parse(&source) else {
            continue;
        };
        let origin = path.to_string_lossy().into_owned();
        let nested_imports = merge_imported(song, nested, &origin);
        queue(&nested_imports, path.parent(), refresh, &mut stack);
    }
}

fn load_import_with(
    target: &mut Song,
    path: &Path,
    force: bool,
    refresh: Refresh,
    load: &mut impl FnMut(&Path, bool) -> Option<String>,
    visited: &mut HashSet<PathBuf>,
) {
    if !visited.insert(path.to_path_buf()) {
        return;
    }
    let source = if is_std(&path.to_string_lossy()) {
        std_source(&path.to_string_lossy()).map(str::to_string)
    } else {
        load(path, force)
    };
    let Some(source) = source else {
        return;
    };
    let Ok(nested) = parse(&source) else {
        return;
    };
    let origin = path.to_string_lossy();
    let nested_imports = merge_imported(target, nested, &origin);
    let nested_base = path.parent();
    for nested_import in nested_imports {
        let resolved = resolve_import_path(nested_base, &nested_import.path);
        let force = nested_import.refresh || refresh == Refresh::Force;
        load_import_with(target, &resolved, force, refresh, load, visited);
    }
}

/// Merges an imported song's sounds, motifs, and tunings into `target` and
/// records `origin` (the importing file's path) for each newly added name.
/// First definition wins, so an existing (local or earlier-imported) name is
/// kept and its origin unchanged. Returns the imported song's own imports for
/// recursive resolution.
fn merge_imported(target: &mut Song, imported: Song, origin: &str) -> Vec<Import> {
    for (name, sound) in imported.sounds {
        if !target.sounds.contains_key(&name) {
            target
                .sound_origins
                .insert(name.clone(), origin.to_string());
            target.sounds.insert(name, sound);
        }
    }
    for (name, notes) in imported.motifs {
        if !target.motifs.contains_key(&name) {
            target
                .motif_origins
                .insert(name.clone(), origin.to_string());
            target.motifs.insert(name, notes);
        }
    }
    for (name, tuning) in imported.tunings {
        if !target.tunings.contains_key(&name) {
            target
                .tuning_origins
                .insert(name.clone(), origin.to_string());
            target.tunings.insert(name, tuning);
        }
    }
    for (name, table) in imported.waves {
        if !target.waves.contains_key(&name) {
            target.wave_origins.insert(name.clone(), origin.to_string());
            target.waves.insert(name, table);
        }
    }
    imported.imports
}

/// Resolves all `@import` references in `song`, loading external files
/// (relative to `base`) and merging their sound definitions.
///
/// Sounds from imports are added to `song.sounds` but not to
/// `song.local_sound_names`. Local definitions take precedence over
/// imported ones with the same name.
#[cfg(not(target_arch = "wasm32"))]
pub fn resolve_imports(
    song: &mut Song,
    base: Option<&Path>,
    refresh: Refresh,
) -> Result<(), ParseError> {
    let mut visited = HashSet::new();
    for import in song.imports.clone() {
        let resolved = resolve_import_path(base, &import.path);
        let force = import.refresh || refresh == Refresh::Force;
        load_import(song, &resolved, force, refresh, &mut visited)?;
    }
    Ok(())
}

/// Resolves imports without filesystem or network access.
///
/// On wasm the filesystem is unavailable and `fetch` is asynchronous, so this
/// merges only bundled `std:` imports and silently skips local-path and URL
/// imports (a song still plays its own definitions). Use
/// [`resolve_imports_async`] to pull URL imports through the host's `fetch`.
#[cfg(target_arch = "wasm32")]
pub fn resolve_imports(
    song: &mut Song,
    base: Option<&Path>,
    refresh: Refresh,
) -> Result<(), ParseError> {
    resolve_imports_with(song, base, refresh, |_path, _force| None);
    Ok(())
}

fn is_url(path: &str) -> bool {
    path.contains("://")
}

/// True for a bundled standard-library import (`std:instruments`, …).
fn is_std(path: &str) -> bool {
    path.starts_with("std:")
}

/// Source of a bundled `std:` import, compiled into the binary under the
/// `std-instruments` feature. `std:instruments` pulls the whole bank; the
/// category names load one leaf each. `None` for an unknown name or when the
/// feature is off.
fn std_source(path: &str) -> Option<&'static str> {
    #[cfg(feature = "std-instruments")]
    match path {
        "std:instruments" => {
            Some("@import std:orchestral\n@import std:chiptune\n@import std:drums\n")
        }
        "std:orchestral" => Some(include_str!("../../sounds/orchestral.track")),
        "std:chiptune" => Some(include_str!("../../sounds/chiptune.track")),
        "std:drums" => Some(include_str!("../../sounds/drums.track")),
        _ => None,
    }
    #[cfg(not(feature = "std-instruments"))]
    {
        let _ = path;
        None
    }
}

pub(super) fn resolve_import_path(base: Option<&Path>, path: &str) -> PathBuf {
    let candidate = PathBuf::from(path);
    if is_url(path) || is_std(path) || candidate.is_absolute() {
        return candidate;
    }
    match base {
        // `base` is a directory (a parent path); a relative import from a URL
        // directory appends to it rather than touching the filesystem.
        Some(base) if base.to_str().is_some_and(is_url) => {
            let base = base.to_string_lossy();
            PathBuf::from(format!("{}/{path}", base.trim_end_matches('/')))
        }
        Some(base) => base.join(path),
        None => candidate,
    }
}

#[cfg(not(target_arch = "wasm32"))]
fn load_import(
    target: &mut Song,
    path: &Path,
    force: bool,
    refresh: Refresh,
    visited: &mut HashSet<PathBuf>,
) -> Result<(), ParseError> {
    let path_string = path.to_string_lossy();
    let canonical = if is_url(&path_string) {
        path.to_path_buf()
    } else {
        path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
    };
    // Already loaded (a diamond/double import, or an import cycle): merge once,
    // then skip. First definition wins, so re-merging would change nothing.
    if !visited.insert(canonical) {
        return Ok(());
    }
    let source = read_source(path, force)?;
    let nested = parse(&source).map_err(|error| ParseError::ImportParse {
        path: path.to_path_buf(),
        error: Box::new(error),
    })?;
    let nested_base = path.parent();
    let origin = path.to_string_lossy();
    for nested_import in merge_imported(target, nested, &origin) {
        let resolved = resolve_import_path(nested_base, &nested_import.path);
        let force = nested_import.refresh || refresh == Refresh::Force;
        load_import(target, &resolved, force, refresh, visited)?;
    }
    Ok(())
}

/// Reads a `.track` source from a local path or a URL (cached, unless `force`).
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn read_source(path: &Path, force: bool) -> Result<String, ParseError> {
    let path_string = path.to_string_lossy();
    if is_std(&path_string) {
        return std_source(&path_string).map(str::to_string).ok_or_else(|| {
            ParseError::ImportRead {
                path: path.to_path_buf(),
                error: std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    "unknown std import (enable the 'std-instruments' feature)",
                ),
            }
        });
    }
    if is_url(&path_string) {
        fetch_url_source(&path_string, force)
    } else {
        std::fs::read_to_string(path).map_err(|error| ParseError::ImportRead {
            path: path.to_path_buf(),
            error,
        })
    }
}

/// On wasm only bundled `std:` imports resolve; local-path and URL imports have
/// no synchronous source (no filesystem, `fetch` is async) and return an error.
#[cfg(target_arch = "wasm32")]
pub(super) fn read_source(path: &Path, _force: bool) -> Result<String, ParseError> {
    let path_string = path.to_string_lossy();
    if is_std(&path_string) {
        return std_source(&path_string).map(str::to_string).ok_or_else(|| {
            ParseError::ImportRead {
                path: path.to_path_buf(),
                error: std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    "unknown std import (enable the 'std-instruments' feature)",
                ),
            }
        });
    }
    Err(ParseError::ImportRead {
        path: path.to_path_buf(),
        error: std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            "file and URL imports need a native target",
        ),
    })
}

/// Fetches a URL import's source, using the on-disk cache unless `force`.
///
/// With the `online` feature disabled this always errors, so offline builds
/// pull no network dependency.
#[cfg(not(target_arch = "wasm32"))]
fn fetch_url_source(url: &str, force: bool) -> Result<String, ParseError> {
    #[cfg(feature = "online")]
    {
        online::fetch(url, force)
    }
    #[cfg(not(feature = "online"))]
    {
        let _ = force;
        Err(ParseError::ImportOffline {
            url: url.to_string(),
        })
    }
}

#[cfg(all(not(target_arch = "wasm32"), feature = "online"))]
mod online {
    use super::ParseError;
    use std::{
        hash::{Hash, Hasher},
        path::PathBuf,
    };

    fn cache_path(url: &str) -> Option<PathBuf> {
        let mut hasher = std::collections::hash_map::DefaultHasher::new();
        url.hash(&mut hasher);
        let name = format!("{:016x}.track", hasher.finish());
        Some(dirs::cache_dir()?.join("game-audio").join(name))
    }

    fn download(url: &str) -> Result<String, ParseError> {
        ureq::get(url)
            .call()
            .map_err(|error| ParseError::ImportDownload {
                url: url.to_string(),
                message: error.to_string(),
            })?
            .body_mut()
            .read_to_string()
            .map_err(|error| ParseError::ImportDownload {
                url: url.to_string(),
                message: error.to_string(),
            })
    }

    pub fn fetch(url: &str, force: bool) -> Result<String, ParseError> {
        let cache = cache_path(url);
        if !force
            && let Some(path) = &cache
            && let Ok(source) = std::fs::read_to_string(path)
        {
            return Ok(source);
        }
        let source = download(url)?;
        if let Some(path) = &cache {
            if let Some(parent) = path.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            let _ = std::fs::write(path, &source);
        }
        Ok(source)
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used, clippy::expect_used)]
    use super::super::song::{ramp_offset, volume_ramp_target};
    use super::{Curve, Note, Refresh, parse};

    #[test]
    fn note_line_has_sound_and_absolute_start() {
        let song = parse("# Layers\n\n## A\n\nguitar 4 7 1\n").unwrap();
        let notes = &song.layers[0].notes;
        assert_eq!(notes.len(), 1);
        assert_eq!(notes[0].sound, "guitar");
        assert_eq!(notes[0].start.time, 4.0);
        assert_eq!(notes[0].start.pitch, 7.0);
        assert_eq!(notes[0].duration(), 1.0);
    }

    #[test]
    fn notes_are_sorted_by_start_regardless_of_sound() {
        let src = "# Layers\n\n## A\n\nbell 1.5 10 1\nguitar 0 0 2\nbell 1 7 1\n";
        let song = parse(src).unwrap();
        let starts: Vec<f32> = song.layers[0]
            .notes
            .iter()
            .map(|note| note.start.time)
            .collect();
        assert_eq!(starts, vec![0.0, 1.0, 1.5]);
    }

    #[test]
    fn many_notes_of_same_sound_coexist() {
        // With a flat note list there is no "pattern" object, so multiple notes
        // of the same sound on one track are just entries — never in conflict.
        let src = "# Layers\n\n## A\n\nbell 0 0 1\nbell 2 4 1\nbell 4 7 1\n";
        let song = parse(src).unwrap();
        let notes = &song.layers[0].notes;
        assert_eq!(notes.len(), 3);
        assert!(notes.iter().all(|note| note.sound == "bell"));
    }

    #[test]
    fn audio_clip_lines_error_with_sampler_hint() {
        let result = parse("# Layers\n\n## A\n\ndrums.wav 2\n");
        let message = result.unwrap_err().to_string();
        assert!(message.contains("sample \"drums.wav\""), "{message}");
    }

    #[test]
    fn sample_sound_zones_parse() {
        let source = "# Sounds\npiano: sample \"p_low.wav\" @ -24..-6, sample \"p_high.wav\"\n";
        let song = parse(source).unwrap();
        let zones = &song.sounds["piano"].samples;
        assert_eq!(zones.len(), 2);
        assert_eq!(zones[0].file, "p_low.wav");
        assert_eq!(zones[0].pitches, [-24.0, -6.0]);
        assert_eq!(zones[1].file, "p_high.wav");
        assert!(zones[1].covers_all());
    }

    fn first_note(src: &str) -> Note {
        let song = parse(src).unwrap();
        song.layers[0].notes[0].clone()
    }

    #[test]
    fn note_defaults_have_no_glide_full_volume() {
        let note = first_note("# Layers\n\n## A\n\nguitar 0 0 1\n");
        assert!(note.pitch_curve().is_empty());
        assert_eq!(note.volume, 1.0);
        assert!(note.volume_curve.is_empty());
    }

    #[test]
    fn note_glide_with_curve_and_volume() {
        let note = first_note("# Layers\n\n## A\n\nguitar 0 0 2 glide 7 ease-out volume 0.5\n");
        assert_eq!(note.start.pitch, 0.0);
        assert_eq!(note.duration(), 2.0);
        assert_eq!(
            ramp_offset(&note.pitch_curve()),
            Some((
                7.0,
                Curve {
                    ease_in: false,
                    ease_out: true
                }
            ))
        );
        assert_eq!(note.volume, 0.5);
    }

    #[test]
    fn note_glide_default_curve_is_smooth() {
        let note = first_note("# Layers\n\n## A\n\nbell 0 0 2 glide 12\n");
        assert_eq!(
            ramp_offset(&note.pitch_curve()),
            Some((12.0, Curve::SMOOTH))
        );
    }

    #[test]
    fn explicit_linear_token_opts_out_of_smooth() {
        let note = first_note("# Layers\n\n## A\n\nbell 0 0 2 glide 12 linear\n");
        assert_eq!(
            ramp_offset(&note.pitch_curve()),
            Some((12.0, Curve::LINEAR))
        );
    }

    #[test]
    fn note_volume_ramp_with_curve() {
        let note = first_note("# Layers\n\n## A\n\nbell 0 0 2 volume 0.2 vol-ramp 0.7 ease\n");
        assert_eq!(note.volume, 0.2);
        assert_eq!(
            volume_ramp_target(&note.volume_curve),
            Some((0.7, Curve::SMOOTH))
        );
        // The ramp is a factor curve: from ×1 down to ×0.7 of the volume.
        assert_eq!(note.volume_curve[0].y, 1.0);
        assert_eq!(note.volume_curve[1].y, 0.7);
    }

    #[test]
    fn multi_point_pitch_curve_parses() {
        let note = first_note("# Layers\n\n## A\n\nbell 0 0 2\n  pitch 0 0, 0.5 7 ease, 1 0\n");
        assert_eq!(note.pitch_curve().len(), 3);
        assert_eq!(note.pitch_curve()[1].x, 0.5);
        assert_eq!(note.pitch_curve()[1].y, 7.0);
        assert!(ramp_offset(&note.pitch_curve()).is_none());
    }

    #[test]
    fn pattern_block_syntax_is_rejected() {
        assert!(parse("# Layers\n\n## A\n\npattern bell 0\n  0 0 2\n").is_err());
    }

    #[test]
    fn volume_without_glide() {
        let note = first_note("# Layers\n\n## A\n\nguitar 0 0 1 volume 0.25\n");
        assert!(note.pitch_curve().is_empty());
        assert_eq!(note.volume, 0.25);
    }

    #[test]
    fn sound_defaults_are_neutral() {
        let song = parse("# Sounds\nlead: sine 1.0\n").unwrap();
        let sound = &song.sounds["lead"];
        assert_eq!(sound.sustain, 1.0);
        assert_eq!(sound.noise, 0.0);
        assert_eq!(sound.vibrato.depth, 0.0);
        assert_eq!(sound.tremolo.depth, 0.0);
        assert_eq!(sound.slide.time, 0.0);
    }

    #[test]
    fn sound_release_round_trips() {
        let src = "# Sounds\n\npiano: sine 1, decay 0.05, release 0.4 linear\n";
        let song = parse(src).unwrap();
        assert_eq!(song.sounds["piano"].release.duration, 0.4);
        assert_eq!(song.sounds["piano"].release.curve, Curve::LINEAR);
        let reparsed = parse(&song.to_string()).unwrap();
        assert_eq!(reparsed.sounds["piano"].release.duration, 0.4);
    }

    #[test]
    fn sound_modulation_tokens_parse() {
        let src = "# Sounds\nlead: sine 1.0, sustain 0.7, noise 0.2, vibrato 6 0.5, tremolo 4 0.3, slide 12 0.1\n";
        let song = parse(src).unwrap();
        let sound = &song.sounds["lead"];
        assert_eq!(sound.sustain, 0.7);
        assert_eq!(sound.noise, 0.2);
        assert_eq!(
            sound.vibrato,
            super::Modulation {
                rate: 6.0,
                depth: 0.5,
                wave: String::new(),
            }
        );
        assert_eq!(
            sound.tremolo,
            super::Modulation {
                rate: 4.0,
                depth: 0.3,
                wave: String::new(),
            }
        );
        assert_eq!(
            sound.slide,
            super::Slide {
                amount: 12.0,
                time: 0.1
            }
        );
    }

    #[test]
    fn modulation_missing_depth_errors() {
        assert!(parse("# Sounds\nlead: sine 1.0, vibrato 6\n").is_err());
    }

    #[test]
    fn modulation_waveform_parses() {
        let song =
            parse("# Sounds\nlead: sine 1.0, vibrato 6 0.5 saw, tremolo 4 0.3 hard_square\n")
                .unwrap();
        let sound = &song.sounds["lead"];
        assert_eq!(sound.vibrato.wave, "saw");
        assert_eq!(sound.tremolo.wave, "square");
    }

    #[test]
    fn harmonic_new_waveforms_parse() {
        let song = parse("# Sounds\nlead: saw 0.5, hill 0.3, hard_square 0.2\n").unwrap();
        let tables: Vec<&str> = song.sounds["lead"]
            .harmonics
            .iter()
            .map(|harmonic| harmonic.wave.as_str())
            .collect();
        assert_eq!(tables, vec!["saw", "hill", "square"]);
    }

    #[test]
    fn motif_definition_is_kept_sound_less_and_sorted() {
        let src = "# Motifs\n\n## hook\n2 7 1\n0 0 2\n1 4 1 glide 12 ease-out volume 0.5\n";
        let song = parse(src).unwrap();
        let notes = &song.motifs["hook"];
        assert_eq!(notes.len(), 3);
        let starts: Vec<f32> = notes.iter().map(|note| note.start.time).collect();
        assert_eq!(starts, vec![0.0, 1.0, 2.0]);
        let glided = &notes[1];
        assert_eq!(glided.start.pitch, 4.0);
        assert_eq!(
            ramp_offset(&glided.pitch_curve()),
            Some((
                12.0,
                Curve {
                    ease_in: false,
                    ease_out: true
                }
            ))
        );
        assert_eq!(glided.volume, 0.5);
    }

    #[test]
    fn motif_instance_kept_as_reference_not_expanded() {
        let src = "# Motifs\n\n## hook\n0 0 1\n1 7 1\n\n# Layers\n\n## A\nmotif hook 4 guitar transpose 12\n";
        let song = parse(src).unwrap();
        // Definition still present, track holds a reference (not notes).
        assert_eq!(song.motifs["hook"].len(), 2);
        assert!(song.layers[0].notes.is_empty());
        let instances = &song.layers[0].motif_instances;
        assert_eq!(instances.len(), 1);
        assert_eq!(instances[0].name, "hook");
        assert_eq!(instances[0].start, 4.0);
        assert_eq!(instances[0].sound, "guitar");
        assert_eq!(instances[0].transpose, 12.0);
    }

    #[test]
    fn motif_instance_default_transpose() {
        let song = parse("# Motifs\n\n## a\n0 0 1\n\n# Layers\n\n## A\nmotif a 0 bell\n").unwrap();
        assert_eq!(song.layers[0].motif_instances[0].transpose, 0.0);
        assert_eq!(song.layers[0].motif_instances[0].tuning, None);
    }

    #[test]
    fn motif_instance_tuning_override_in_any_order() {
        let song = parse(
            "# Motifs\n\n## a\n0 0 1\n\n# Layers\n\n## A\nmotif a 0 bell tuning minor transpose 3\nmotif a 4 bell\n",
        )
        .unwrap();
        let instances = &song.layers[0].motif_instances;
        assert_eq!(instances[0].tuning.as_deref(), Some("minor"));
        assert_eq!(instances[0].transpose, 3.0);
        assert_eq!(instances[1].tuning, None);
    }

    #[test]
    fn motif_note_resolves_with_sound_start_and_transpose() {
        let song = parse(
            "# Motifs\n\n## a\n1 5 2 glide 9\n\n# Layers\n\n## A\nmotif a 4 bell transpose 3\n",
        )
        .unwrap();
        let instance = &song.layers[0].motif_instances[0];
        let resolved = song.motifs["a"][0].resolve(instance, None);
        assert_eq!(resolved.sound, "bell");
        assert_eq!(resolved.start.time, 5.0);
        assert_eq!(resolved.start.pitch, 8.0);
        // glide is relative, so transpose does not touch it.
        assert_eq!(
            ramp_offset(&resolved.pitch_curve()),
            Some((9.0, Curve::SMOOTH))
        );
        assert_eq!(resolved.duration(), 2.0);
    }

    #[test]
    fn motif_instance_missing_sound_errors() {
        assert!(parse("# Layers\n\n## A\nmotif hook 0\n").is_err());
    }

    #[test]
    fn motif_note_override_parses_and_applies() {
        let song = parse(
            "# Motifs\n\n## a\n0 0 1\n1 7 1\n\n# Layers\n\n## A\nmotif a 0 bell transpose 2\n  note 1 pitch 12 volume 0.5\n",
        )
        .unwrap();
        let instance = &song.layers[0].motif_instances[0];
        assert_eq!(instance.overrides.len(), 1);
        let resolved = song.motifs["a"][1].resolve(instance, instance.overrides.first());
        // Overridden pitch 12, still shifted by transpose 2.
        assert_eq!(resolved.start.pitch, 14.0);
        assert_eq!(resolved.volume, 0.5);
        // Untouched note keeps its definition.
        let plain = song.motifs["a"][0].resolve(instance, None);
        assert_eq!(plain.start.pitch, 2.0);
    }

    #[test]
    fn motif_note_override_skip_round_trips() {
        let src =
            "# Motifs\n\n## a\n0 0 1\n1 7 1\n\n# Layers\n\n## A\nmotif a 0 bell\n  note 1 skip\n";
        let song = parse(src).unwrap();
        let instance = &song.layers[0].motif_instances[0];
        assert!(instance.overrides[0].skip);
        let reparsed = parse(&song.to_string()).unwrap();
        assert!(reparsed.layers[0].motif_instances[0].overrides[0].skip);
    }

    #[test]
    fn motif_trim_keeps_range() {
        let song =
            parse("# Motifs\n\n## a\n0 0 1\n1 0 1\n2 0 1\n\n# Layers\n\n## A\nmotif a 0 bell\n  trim 1 1\n")
                .unwrap();
        assert_eq!(
            song.layers[0].motif_instances[0].trim,
            Some(super::Trim { from: 1, count: 1 })
        );
    }

    #[test]
    fn indented_override_without_motif_errors() {
        assert!(parse("# Layers\n\n## A\n  note 0 pitch 3\n").is_err());
        assert!(parse("# Layers\n\n## A\nbell 0 0 1\n  note 0 pitch 3\n").is_err());
    }

    #[test]
    fn motif_override_bad_tokens_error() {
        assert!(
            parse(
                "# Motifs\n\n## a\n0 0 1\n\n# Layers\n\n## A\nmotif a 0 bell\n  note 0 bogus 1\n"
            )
            .is_err()
        );
        assert!(
            parse("# Motifs\n\n## a\n0 0 1\n\n# Layers\n\n## A\nmotif a 0 bell\n  wat 0\n")
                .is_err()
        );
        assert!(
            parse(
                "# Motifs\n\n## a\n0 0 1\n\n# Layers\n\n## A\nmotif a 0 bell\n  note x pitch 1\n"
            )
            .is_err()
        );
    }

    #[test]
    fn unknown_section_does_not_break_following_sections() {
        let src = "# Foo\nsome garbage line\n# Layers\n\n## A\nbell 0 0 1\n";
        let song = parse(src).unwrap();
        assert_eq!(song.layers.len(), 1);
        assert_eq!(song.layers[0].notes.len(), 1);
    }

    #[test]
    fn unknown_section_body_is_preserved() {
        let src = "# Widget stuff\nline one\nline two\n# Sounds\nlead: sine 1.0\n";
        let song = parse(src).unwrap();
        assert_eq!(song.unknown.len(), 1);
        assert_eq!(song.unknown[0].header, "Widget stuff");
        assert_eq!(song.unknown[0].lines, vec!["line one", "line two"]);
        assert!(song.sounds.contains_key("lead"));
    }

    #[test]
    fn directive_after_unknown_section_still_applies() {
        // Regression: a leading `#` comment used to start an unknown section that
        // swallowed the following `@import`/`@tempo`, silently emptying the song.
        let song = parse(
            "# a comment\n# another comment\n@tempo 90\n@import bank.track\n# Layers\n\n## A\nlead 0 0 1\n",
        )
        .unwrap();
        assert_eq!(song.tempo, 90.0);
        assert_eq!(song.imports.len(), 1);
        assert_eq!(song.layers[0].notes.len(), 1);
        assert_eq!(song.unknown.len(), 2);
    }

    #[test]
    fn unknown_directive_after_unknown_section_errors() {
        // `@` is always a directive now, so an unrecognized one errors rather
        // than being silently captured.
        assert!(parse("# note block\n@bogus thing\n# Layers\n\n## A\nlead 0 0 1\n").is_err());
    }

    #[test]
    fn multiple_unknown_sections_kept_separately() {
        let src = "# A\nx\n# B\ny\nz\n";
        let song = parse(src).unwrap();
        assert_eq!(song.unknown.len(), 2);
        assert_eq!(song.unknown[0].header, "A");
        assert_eq!(song.unknown[1].lines, vec!["y", "z"]);
    }

    #[test]
    fn loop_without_skip_defaults_to_zero() {
        let song = parse("# Layers\n\n## A\n@loop 8\nbell 0 0 1\n").unwrap();
        assert_eq!(song.layers[0].loop_length, Some(8.0));
        assert_eq!(song.layers[0].loop_skip, 0.0);
    }

    #[test]
    fn loop_with_skip_parses() {
        let song = parse("# Layers\n\n## A\n@loop 8 skip 2\nbell 0 0 1\n").unwrap();
        assert_eq!(song.layers[0].loop_length, Some(8.0));
        assert_eq!(song.layers[0].loop_skip, 2.0);
    }

    #[test]
    fn loop_bad_skip_token_errors() {
        assert!(parse("# Layers\n\n## A\n@loop 8 wat 2\nbell 0 0 1\n").is_err());
    }

    #[cfg(not(target_arch = "wasm32"))]
    #[test]
    fn diamond_import_loads_shared_file_once_without_cycle_error() {
        use super::{Refresh, resolve_imports};
        use std::io::Write;
        let dir = std::env::temp_dir().join("game-audio-diamond-import");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let write = |name: &str, body: &str| {
            std::fs::File::create(dir.join(name))
                .unwrap()
                .write_all(body.as_bytes())
                .unwrap();
        };
        // top imports both left and right, which both import shared.
        write("shared.track", "# Sounds\nbell: sine 1\n");
        write("left.track", "@import shared.track\n");
        write("right.track", "@import shared.track\n");
        write("top.track", "@import left.track\n@import right.track\n");

        let mut song = parse("@import left.track\n@import right.track\n").unwrap();
        // Diamond, not a cycle: resolves cleanly and the shared sound is present.
        resolve_imports(&mut song, Some(&dir), Refresh::Cached).unwrap();
        assert!(song.sounds.contains_key("bell"));
    }

    #[test]
    fn volume_param_binds_layer_volume() {
        let song = parse("# Layers\n\n## A\n@volume param combat\nbell 0 0 1\n").unwrap();
        let binding = song.layers[0].volume_param.as_ref().unwrap();
        assert_eq!(binding.param, "combat");
        assert_eq!(binding.scale, 1.0);

        let scaled = parse("# Layers\n\n## A\n@volume param heat 0.5\nbell 0 0 1\n").unwrap();
        assert_eq!(scaled.layers[0].volume_param.as_ref().unwrap().scale, 0.5);

        let static_volume = parse("# Layers\n\n## A\n@volume 0.7\nbell 0 0 1\n").unwrap();
        assert!(static_volume.layers[0].volume_param.is_none());
        assert_eq!(static_volume.layers[0].volume, 0.7);
    }

    #[test]
    fn volume_param_without_name_errors() {
        assert!(parse("# Layers\n\n## A\n@volume param\nbell 0 0 1\n").is_err());
    }

    #[test]
    fn jump_parses_target_and_optional_skip() {
        let song = parse("@jump loop.track\n# Layers\n\n## A\nbell 0 0 1\n").unwrap();
        assert_eq!(song.jumps[0].target, "loop.track");
        assert_eq!(song.jumps[0].skip, 0.0);

        let song = parse("@jump intro.track 8\n# Layers\n\n## A\nbell 0 0 1\n").unwrap();
        assert_eq!(song.jumps[0].target, "intro.track");
        assert_eq!(song.jumps[0].skip, 8.0);
    }

    #[test]
    fn no_jump_by_default() {
        let song = parse("# Layers\n\n## A\nbell 0 0 1\n").unwrap();
        assert!(song.jumps.is_empty());
    }

    #[test]
    fn length_from_content_uses_latest_note_end() {
        // notes end at beats 1, 3, 6 → content length 6.
        let song = parse("# Layers\n\n## A\nbell 0 0 1\nbell 2 0 1\nbell 4 0 2\n").unwrap();
        assert_eq!(song.length, None);
        assert_eq!(song.length(), 6.0);
    }

    #[test]
    fn explicit_length_overrides_content() {
        let song = parse("@length 16\n# Layers\n\n## A\nbell 0 0 1\n").unwrap();
        assert_eq!(song.length, Some(16.0));
        assert_eq!(song.length(), 16.0);
    }

    #[test]
    fn length_ignores_loops_and_spans_layers() {
        // Layer A loops at 4 but content ends at 4; Layer B ends at 10.
        let song = parse("# Layers\n\n## A\n@loop 4\nbell 0 0 4\n# Layers\n\n## B\nbell 0 0 10\n")
            .unwrap();
        assert_eq!(song.length(), 10.0);
    }

    #[cfg(feature = "std-instruments")]
    #[test]
    fn std_instruments_import_resolves_bundled_bank() {
        use super::resolve_imports_with;
        let mut song = parse("@import std:instruments\n").unwrap();
        // The closure handles no paths; only the bundled `std:` source applies.
        resolve_imports_with(&mut song, None, Refresh::Cached, |_path, _force| None);
        assert!(
            !song.sounds.is_empty(),
            "bundled instrument bank should populate sounds"
        );
    }

    #[test]
    fn import_merges_sounds_and_motifs() {
        use super::resolve_imports_with;
        use std::collections::HashMap;
        let files = HashMap::from([(
            "lib.track".to_string(),
            "# Sounds\nlead: sine 1.0\n# Motifs\n\n## hook\n0 0 1\n1 7 1\n".to_string(),
        )]);
        let mut song = parse("@import lib.track\n").unwrap();
        resolve_imports_with(&mut song, None, Refresh::Cached, |path, _force| {
            files.get(path.to_str().unwrap()).cloned()
        });
        assert!(song.sounds.contains_key("lead"));
        assert_eq!(song.motifs["hook"].len(), 2);
    }

    #[test]
    fn top_level_relative_import_resolves_against_a_url_base() {
        use super::resolve_imports_with;
        use std::{collections::HashMap, path::Path};
        // A song fetched from a URL passes its directory URL as the base; a
        // relative import resolves against it rather than the filesystem.
        let files = HashMap::from([(
            "https://p11c.xyz/songs/shared.track".to_string(),
            "# Sounds\nshared: sine 1.0\n".to_string(),
        )]);
        let mut song = parse("@import shared.track\n").unwrap();
        let base = Path::new("https://p11c.xyz/songs");
        resolve_imports_with(&mut song, Some(base), Refresh::Cached, |path, _force| {
            files.get(path.to_str().unwrap()).cloned()
        });
        assert!(song.sounds.contains_key("shared"));
    }

    #[test]
    fn url_import_passed_verbatim_not_joined_to_base() {
        use super::resolve_imports_with;
        use std::collections::HashMap;
        use std::path::Path;
        let files = HashMap::from([(
            "https://p11c.xyz/audio/instruments.track".to_string(),
            "# Sounds\nlead: sine 1.0\n".to_string(),
        )]);
        let mut song = parse("@import https://p11c.xyz/audio/instruments.track\n").unwrap();
        // A local base dir must NOT be prepended to the URL.
        resolve_imports_with(
            &mut song,
            Some(Path::new("/some/local/dir")),
            Refresh::Cached,
            |path, _force| files.get(path.to_str().unwrap()).cloned(),
        );
        assert!(song.sounds.contains_key("lead"));
    }

    #[test]
    fn relative_import_resolves_against_url_base() {
        use super::resolve_imports_with;
        use std::collections::HashMap;
        let files = HashMap::from([
            (
                "https://p11c.xyz/audio/main.track".to_string(),
                "# Sounds\nmain: sine 1.0\n@import shared.track\n".to_string(),
            ),
            (
                "https://p11c.xyz/audio/shared.track".to_string(),
                "# Sounds\nshared: sine 1.0\n".to_string(),
            ),
        ]);
        let mut song = parse("@import https://p11c.xyz/audio/main.track\n").unwrap();
        resolve_imports_with(&mut song, None, Refresh::Cached, |path, _force| {
            files.get(path.to_str().unwrap()).cloned()
        });
        assert!(song.sounds.contains_key("main"));
        assert!(song.sounds.contains_key("shared"));
    }

    #[test]
    fn async_loader_resolves_transitive_url_imports() {
        use super::resolve_imports_async;
        use std::{
            collections::HashMap,
            future::{Future, ready},
            sync::Arc,
            task::{Context, Poll, Wake},
        };
        struct Noop;
        impl Wake for Noop {
            fn wake(self: Arc<Self>) {}
        }
        fn block_on<F: Future>(fut: F) -> F::Output {
            let waker = Arc::new(Noop).into();
            let mut context = Context::from_waker(&waker);
            let mut fut = Box::pin(fut);
            loop {
                if let Poll::Ready(value) = fut.as_mut().poll(&mut context) {
                    return value;
                }
            }
        }
        let files = HashMap::from([
            (
                "https://p11c.xyz/audio/main.track".to_string(),
                "# Sounds\nmain: sine 1.0\n@import shared.track\n".to_string(),
            ),
            (
                "https://p11c.xyz/audio/shared.track".to_string(),
                "# Sounds\nshared: sine 1.0\n".to_string(),
            ),
        ]);
        let mut song = parse("@import https://p11c.xyz/audio/main.track\n").unwrap();
        block_on(resolve_imports_async(
            &mut song,
            None,
            Refresh::Cached,
            |path, _force| ready(path.to_str().and_then(|url| files.get(url).cloned())),
        ));
        assert!(song.sounds.contains_key("main"));
        assert!(song.sounds.contains_key("shared"));
    }

    #[test]
    fn meta_section_parses_and_round_trips() {
        let song = parse(
            "# Meta\n\ntitle My Song\ndescription A test song\nauthor me\n\n## Tags\n\nambient\nloop\n\n# Sounds\n\nlead: sine 1.0\n",
        )
        .unwrap();
        assert_eq!(song.title.as_deref(), Some("My Song"));
        assert_eq!(song.description.as_deref(), Some("A test song"));
        assert_eq!(song.author.as_deref(), Some("me"));
        assert_eq!(song.tags, ["ambient", "loop"]);
        let reparsed = parse(&song.to_string()).unwrap();
        assert_eq!(reparsed.title, song.title);
        assert_eq!(reparsed.description, song.description);
        assert_eq!(reparsed.author, song.author);
        assert_eq!(reparsed.tags, song.tags);
    }

    #[test]
    fn empty_meta_value_errors() {
        assert!(parse("# Meta\n\ntitle\n").is_err());
    }

    #[test]
    fn unknown_meta_key_errors() {
        assert!(parse("# Meta\n\nfoo bar\n").is_err());
    }

    #[test]
    fn note_override_pitch_curve_replaces_glide() {
        let src = "# Motifs\n\n## a\n0 0 2\n\n# Layers\n\n## A\nmotif a 0 bell\n  note 0 pitch-curve 0 0, 0.5 7 ease, 1 0\n";
        let song = parse(src).unwrap();
        let instance = &song.layers[0].motif_instances[0];
        let note_override = instance.overrides.first().unwrap();
        assert_eq!(note_override.pitch_curve.as_ref().unwrap().len(), 3);
        let resolved = song.motifs["a"][0].resolve(instance, Some(note_override));
        assert_eq!(resolved.pitch_curve().len(), 3);
        let reparsed = parse(&song.to_string()).unwrap();
        assert_eq!(
            reparsed.layers[0].motif_instances[0]
                .overrides
                .first()
                .unwrap()
                .pitch_curve,
            note_override.pitch_curve
        );
    }

    #[test]
    fn voice_ensemble_parses_and_round_trips() {
        let song = parse("# Sounds\n\nchoir: voice female tilt 0.4 ensemble 6\n").unwrap();
        let sound = &song.sounds["choir"];
        assert_eq!(sound.voice, "female");
        assert_eq!(sound.voice_ensemble, 6);
        assert_eq!(sound.voice_overrides.len(), 1);
        let reparsed = parse(&song.to_string()).unwrap();
        assert_eq!(reparsed.sounds["choir"].voice_ensemble, 6);
    }

    #[test]
    fn subtitles_parse_sorted_with_clear_marker() {
        let song = parse("# Subtitles\n\n2 second line\n0 first line\n8\n").unwrap();
        assert_eq!(song.subtitles.len(), 3);
        assert_eq!(song.subtitles[0].beat, 0.0);
        assert_eq!(song.subtitles[0].text, "first line");
        assert_eq!(song.subtitles[1].beat, 2.0);
        assert_eq!(song.subtitles[1].text, "second line");
        assert_eq!(song.subtitles[2].beat, 8.0);
        assert!(song.subtitles[2].text.is_empty());
        let reparsed = parse(&song.to_string()).unwrap();
        assert_eq!(reparsed.subtitles, song.subtitles);
    }

    #[test]
    fn per_import_refresh_forces_loader_flag() {
        use super::resolve_imports_with;
        let mut song =
            parse("@import https://a.test/x.track\n@import https://b.test/y.track refresh\n")
                .unwrap();
        assert!(!song.imports[0].refresh);
        assert!(song.imports[1].refresh);
        let mut forced = Vec::new();
        resolve_imports_with(&mut song, None, Refresh::Cached, |path, force| {
            forced.push((path.to_string_lossy().into_owned(), force));
            Some(String::new())
        });
        assert_eq!(forced[0], ("https://a.test/x.track".to_string(), false));
        assert_eq!(forced[1], ("https://b.test/y.track".to_string(), true));
    }

    #[test]
    fn global_force_refresh_flags_all_imports() {
        use super::resolve_imports_with;
        let mut song = parse("@import https://a.test/x.track\n").unwrap();
        let mut seen_force = None;
        resolve_imports_with(&mut song, None, Refresh::Force, |_path, force| {
            seen_force = Some(force);
            Some(String::new())
        });
        assert_eq!(seen_force, Some(true));
    }

    fn approx(a: f32, b: f32) -> bool {
        (a - b).abs() < 1e-3
    }

    #[test]
    fn tuning_equal_and_ratio_sections_parse() {
        let src = "# Tunings\n\n## edo\nequal 5\n# Tunings\n\n## just\nperiod 2/1\n1/1 3/2\n";
        let song = parse(src).unwrap();
        assert_eq!(song.tunings["edo"].degrees.len(), 5);
        assert_eq!(song.tunings["just"].degrees, vec![1.0, 1.5]);
    }

    #[test]
    fn tuning_ratios_normalize_to_first() {
        let song = parse("# Tunings\n\n## t\n220 264 330\n").unwrap();
        let degrees = &song.tunings["t"].degrees;
        assert!(approx(degrees[0], 1.0));
        assert!(approx(degrees[1], 1.2));
        assert!(approx(degrees[2], 1.5));
    }

    #[test]
    fn layer_tuning_and_root_directives() {
        let song = parse(
            "# Tunings\n\n## t\nequal 5\n# Layers\n\n## A\n@tuning t\n@root 220\nbell 0 0 1\n",
        )
        .unwrap();
        assert_eq!(song.layers[0].tuning.as_deref(), Some("t"));
        assert_eq!(song.layers[0].root, 220.0);
    }

    #[test]
    fn layer_defaults_no_tuning_root_440() {
        let song = parse("# Layers\n\n## A\nbell 0 0 1\n").unwrap();
        assert_eq!(song.layers[0].tuning, None);
        assert_eq!(song.layers[0].root, 440.0);
    }

    #[test]
    fn bad_ratio_denominator_errors() {
        assert!(parse("# Tunings\n\n## t\n1/0\n").is_err());
    }

    #[test]
    fn shipped_sound_banks_parse() {
        for source in [
            include_str!("../../sounds/orchestral.track"),
            include_str!("../../sounds/chiptune.track"),
            include_str!("../../sounds/drums.track"),
            include_str!("../../sounds/electronic.track"),
            include_str!("../../sounds/japanese.track"),
        ] {
            let song = parse(source).unwrap();
            assert!(!song.sounds.is_empty());
        }
        // The meta bank pulls the others in via @import.
        let all = parse(include_str!("../../sounds/all.track")).unwrap();
        assert_eq!(all.imports.len(), 5);
    }

    #[test]
    fn sfx_jitter_parses_and_round_trips() {
        let song = parse("# Sounds\nhit: sine 1, detune 0.3, vol-jitter 0.15\n").unwrap();
        assert_eq!(song.sounds["hit"].detune, 0.3);
        assert_eq!(song.sounds["hit"].volume_jitter, 0.15);
        let again = parse(&song.to_string()).unwrap();
        assert_eq!(again.sounds["hit"].detune, 0.3);
        assert_eq!(again.sounds["hit"].volume_jitter, 0.15);
    }

    #[test]
    fn tempo_ramp_parses_start_and_end_as_factor() {
        let steady = parse("@tempo 90\n").unwrap();
        assert_eq!(steady.tempo, 90.0);
        assert_eq!(steady.tempo_factor, 1.0);

        let ramp = parse("@tempo 60 120\n").unwrap();
        assert_eq!(ramp.tempo, 60.0);
        assert_eq!(ramp.tempo_factor, 2.0);
        // Round trip keeps start and end.
        let again = parse(&ramp.to_string()).unwrap();
        assert_eq!(again.tempo, 60.0);
        assert_eq!(again.tempo_factor, 2.0);
    }

    #[test]
    fn signature_defaults_to_four_four() {
        let song = parse("# Sounds\nbell: sine 1\n").unwrap();
        assert_eq!(song.signature, super::TimeSignature::default());
        assert_eq!(song.signature.beats_per_bar, 4);
        assert_eq!(song.signature.beat_unit, 4);
    }

    #[test]
    fn signature_parses_numerator_and_denominator() {
        let song = parse("@signature 6 8\n").unwrap();
        assert_eq!(song.signature.beats_per_bar, 6);
        assert_eq!(song.signature.beat_unit, 8);
    }

    #[test]
    fn signature_beat_unit_defaults_to_four() {
        let song = parse("@signature 3\n").unwrap();
        assert_eq!(song.signature.beats_per_bar, 3);
        assert_eq!(song.signature.beat_unit, 4);
    }

    #[test]
    fn signature_rejects_zero_and_junk() {
        assert!(parse("@signature 0\n").is_err());
        assert!(parse("@signature 4 x\n").is_err());
        assert!(parse("@signature\n").is_err());
        assert!(parse("@signature 4 4 4\n").is_err());
    }

    #[test]
    fn import_records_origin_of_each_name() {
        use super::resolve_imports_with;
        use std::collections::HashMap;
        let files = HashMap::from([(
            "lib.track".to_string(),
            "# Sounds\nlead: sine 1.0\n# Motifs\n\n## hook\n0 0 1\n# Tunings\n\n## t\nequal 5\n"
                .to_string(),
        )]);
        let mut song = parse("@import lib.track\n# Sounds\nlocal: sine 1.0\n").unwrap();
        resolve_imports_with(&mut song, None, Refresh::Cached, |path, _force| {
            files.get(path.to_str().unwrap()).cloned()
        });
        // imported names carry their origin file.
        assert_eq!(
            song.sound_origins.get("lead").map(String::as_str),
            Some("lib.track")
        );
        assert_eq!(
            song.motif_origins.get("hook").map(String::as_str),
            Some("lib.track")
        );
        assert_eq!(
            song.tuning_origins.get("t").map(String::as_str),
            Some("lib.track")
        );
        // local definitions have no origin entry.
        assert!(!song.sound_origins.contains_key("local"));
    }

    #[test]
    fn transitive_origin_points_to_defining_file() {
        use super::resolve_imports_with;
        use std::collections::HashMap;
        let files = HashMap::from([
            ("a.track".to_string(), "@import b.track\n".to_string()),
            (
                "b.track".to_string(),
                "# Sounds\ndeep: sine 1.0\n".to_string(),
            ),
        ]);
        let mut song = parse("@import a.track\n").unwrap();
        resolve_imports_with(&mut song, None, Refresh::Cached, |path, _force| {
            files.get(path.to_str().unwrap()).cloned()
        });
        // origin is where the name is defined (b), not the intermediate import (a).
        assert_eq!(
            song.sound_origins.get("deep").map(String::as_str),
            Some("b.track")
        );
    }

    #[test]
    fn local_definition_wins_and_keeps_no_origin() {
        use super::resolve_imports_with;
        use std::collections::HashMap;
        let files = HashMap::from([(
            "lib.track".to_string(),
            "# Sounds\nbell: saw 1.0\n".to_string(),
        )]);
        // local `bell` should win over imported `bell`.
        let mut song = parse("@import lib.track\n# Sounds\nbell: sine 1.0\n").unwrap();
        resolve_imports_with(&mut song, None, Refresh::Cached, |path, _force| {
            files.get(path.to_str().unwrap()).cloned()
        });
        assert!(song.is_local_sound("bell"));
        assert!(!song.sound_origins.contains_key("bell"));
    }

    #[test]
    fn param_directive_sets_default() {
        let song =
            parse("@param combat 0\n@param mood 1.5\n# Layers\n\n## A\nbell 0 0 1\n").unwrap();
        assert_eq!(song.params.get("combat"), Some(&0.0));
        assert_eq!(song.params.get("mood"), Some(&1.5));
    }

    #[test]
    fn multiple_jumps_collected_in_order() {
        let src = "@jump a.track if combat > 0\n@jump b.track\n# Layers\n\n## A\nbell 0 0 1\n";
        let song = parse(src).unwrap();
        assert_eq!(song.jumps.len(), 2);
        assert_eq!(song.jumps[0].target, "a.track");
        assert!(song.jumps[0].condition.is_some());
        assert_eq!(song.jumps[1].target, "b.track");
        assert!(song.jumps[1].condition.is_none());
    }

    #[test]
    fn jump_condition_sources_and_operators_parse() {
        use super::{Compare, ConditionSource};
        let song = parse(
            "@jump a.track if plays >= 3\n@jump b.track if random < 0.3\n@jump c.track 2 if heat == 1\n# Layers\n\n## A\nbell 0 0 1\n",
        )
        .unwrap();
        let plays = song.jumps[0].condition.as_ref().unwrap();
        assert_eq!(plays.source, ConditionSource::Plays);
        assert_eq!(plays.compare, Compare::GreaterEqual);
        assert_eq!(plays.value, 3.0);

        let random = song.jumps[1].condition.as_ref().unwrap();
        assert_eq!(random.source, ConditionSource::Random);
        assert_eq!(random.compare, Compare::Less);

        let param = song.jumps[2].condition.as_ref().unwrap();
        assert_eq!(param.source, ConditionSource::Param("heat".to_string()));
        assert_eq!(song.jumps[2].skip, 2.0);
    }

    #[test]
    fn jump_bad_condition_errors() {
        assert!(parse("@jump a.track if plays !! 3\n# Layers\n\n## A\nbell 0 0 1\n").is_err());
    }
}
