use std::collections::{BTreeMap, BTreeSet};

/// Top-level song with tempo, sound definitions, and layers.
#[derive(Debug, Clone)]
pub struct Song {
    /// Tempo in beats per minute.
    pub tempo: f32,
    /// `.track` files imported for their sounds and motifs.
    pub imports: Vec<Import>,
    /// All sounds available to tracks (locally defined and imported).
    pub sounds: BTreeMap<String, Sound>,
    /// Names of sounds that were defined directly in this song's source
    /// (not pulled in via `@import`).
    pub local_sound_names: BTreeSet<String>,
    /// Named motif definitions, each a sound-less list of notes.
    pub motifs: BTreeMap<String, Vec<MotifNote>>,
    /// Named scale definitions available to layers via `@tuning`.
    pub tunings: BTreeMap<String, Tuning>,
    /// For each imported sound, the path/URL of the file that defined it.
    /// Locally defined sounds have no entry. Filled by `resolve_imports`.
    pub sound_origins: BTreeMap<String, String>,
    /// For each imported motif, the path/URL of the file that defined it.
    pub motif_origins: BTreeMap<String, String>,
    /// For each imported tuning, the path/URL of the file that defined it.
    pub tuning_origins: BTreeMap<String, String>,
    /// Default values for named parameters (`@param`); overridable at playtime.
    pub params: BTreeMap<String, f32>,
    /// Simultaneously-mixed layers in display order.
    pub layers: Vec<Layer>,
    /// Explicit song length in beats (`@length`); `None` = derive from content.
    pub length: Option<f32>,
    /// Time signature (`@signature`), for bar grouping; default 4/4. Metadata
    /// only — playback counts beats regardless.
    pub signature: TimeSignature,
    /// Where to continue when the song ends (`@jump`), in order; the first whose
    /// condition holds is taken (an unconditional jump is a fallback). Empty =
    /// stop at song end.
    pub jumps: Vec<Jump>,
    /// Sections whose header this parser did not recognize, kept verbatim so
    /// consumers can round-trip unknown content.
    pub unknown: Vec<UnknownSection>,
}

/// A time signature (`@signature`): how beats group into bars.
///
/// Metadata for consumers that draw bar lines (editors, notation); playback
/// counts beats and ignores it. Default is 4/4.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeSignature {
    /// Beats in one bar (the numerator).
    pub beats_per_bar: u32,
    /// Note value that gets one beat (the denominator: 4 = quarter, 8 = eighth).
    pub beat_unit: u32,
}

impl Default for TimeSignature {
    fn default() -> Self {
        Self {
            beats_per_bar: 4,
            beat_unit: 4,
        }
    }
}

/// Where a song continues when it reaches its end.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Jump {
    /// Path or URL of the target `.track` file (a self-reference loops).
    pub target: String,
    /// Beats to skip at the start of the target on arrival.
    pub skip: f32,
    /// Condition to take this jump (`None` = unconditional fallback).
    pub condition: Option<Condition>,
}

/// A comparison deciding whether a conditional jump is taken.
///
/// Read-only: the `.track` file cannot assign to any of these, keeping jumps a
/// finite-state graph rather than a program.
#[derive(Debug, Clone, PartialEq)]
pub struct Condition {
    /// What is being compared.
    pub source: ConditionSource,
    /// The comparison operator.
    pub compare: Compare,
    /// The constant compared against.
    pub value: f32,
}

/// The quantity a [`Condition`] tests.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConditionSource {
    /// A named playtime parameter (`@param`, set by the host).
    Param(String),
    /// How many times the current song has already played (starts at 0).
    Plays,
    /// A fresh uniform random draw in `0.0..1.0` each time it is evaluated.
    Random,
}

/// Comparison operators for a [`Condition`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compare {
    /// Strictly less than.
    Less,
    /// Less than or equal.
    LessEqual,
    /// Strictly greater than.
    Greater,
    /// Greater than or equal.
    GreaterEqual,
    /// Equal.
    Equal,
    /// Not equal.
    NotEqual,
}

impl Compare {
    /// Applies the comparison to two values.
    pub fn test(self, left: f32, right: f32) -> bool {
        match self {
            Self::Less => left < right,
            Self::LessEqual => left <= right,
            Self::Greater => left > right,
            Self::GreaterEqual => left >= right,
            Self::Equal => left == right,
            Self::NotEqual => left != right,
        }
    }
}

/// One `@import` reference: a path or URL, optionally forcing a cache refresh.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Import {
    /// Local path or URL to the imported `.track` file.
    pub path: String,
    /// If true, a cached copy of a URL import is bypassed and re-downloaded.
    pub refresh: bool,
}

/// Whether URL imports may use the local cache or must be re-fetched.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Refresh {
    /// Use a cached copy when present (per-import `refresh` still forces a fetch).
    #[default]
    Cached,
    /// Ignore the cache for every URL import and re-download.
    Force,
}

/// A section with an unrecognized header, preserved line by line.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UnknownSection {
    /// Header text after the leading `#`, trimmed.
    pub header: String,
    /// Body lines under the header, trimmed, in source order.
    pub lines: Vec<String>,
}

impl Default for Song {
    fn default() -> Self {
        Self {
            tempo: 120.0,
            imports: Vec::new(),
            sounds: BTreeMap::new(),
            local_sound_names: BTreeSet::new(),
            motifs: BTreeMap::new(),
            tunings: BTreeMap::new(),
            sound_origins: BTreeMap::new(),
            motif_origins: BTreeMap::new(),
            tuning_origins: BTreeMap::new(),
            params: BTreeMap::new(),
            layers: Vec::new(),
            length: None,
            signature: TimeSignature::default(),
            jumps: Vec::new(),
            unknown: Vec::new(),
        }
    }
}

impl Song {
    /// Returns true when the named sound is defined locally (not imported).
    pub fn is_local_sound(&self, name: &str) -> bool {
        self.local_sound_names.contains(name)
    }

    /// Multiplies every layer's volume by `factor`.
    pub fn with_volume(mut self, factor: f32) -> Self {
        for layer in &mut self.layers {
            layer.volume *= factor;
        }
        self
    }

    /// Length of the song in beats: the explicit `@length` if set, otherwise
    /// the end of the latest note or clip across all layers (loops ignored).
    pub fn length(&self) -> f32 {
        if let Some(length) = self.length {
            return length;
        }
        self.layers
            .iter()
            .map(|layer| self.layer_content_end(layer))
            .fold(0.0, f32::max)
    }

    fn layer_content_end(&self, layer: &Layer) -> f32 {
        let notes = layer.notes.iter().map(|note| note.start + note.duration);
        let audio = layer.audio.iter().map(|clip| clip.start);
        let motifs = layer.motif_instances.iter().map(|instance| {
            let motif_end = self.motifs.get(&instance.name).map_or(0.0, |notes| {
                notes
                    .iter()
                    .map(|note| note.start + note.duration)
                    .fold(0.0, f32::max)
            });
            instance.start + motif_end
        });
        notes.chain(audio).chain(motifs).fold(0.0, f32::max)
    }
}

/// Basic waveform shape used by a harmonic or a modulation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Waveform {
    /// Pure sine wave.
    #[default]
    Sine,
    /// Triangle wave (warmer than sine).
    Triangle,
    /// Soft square wave (buzzy, tanh-shaped).
    SoftSquare,
    /// Hard square wave (harsh, full ±1 edges).
    Square,
    /// Sawtooth wave (bright, rising ramp).
    Saw,
    /// Rectified-sine hill wave (rounder than triangle).
    Hill,
}

/// One spectral component of a sound.
///
/// The harmonic plays at `multiplier × fundamental_frequency`, scaled by
/// `weight`. Multiple harmonics sum to form the timbre.
#[derive(Debug, Clone, Copy)]
pub struct Harmonic {
    /// Waveform of this component.
    pub waveform: Waveform,
    /// Frequency ratio relative to the note's fundamental
    /// (1.0 = fundamental, 2.0 = octave, 1.5 = fifth, 0.5 = sub).
    pub multiplier: f32,
    /// Mix weight (relative loudness within the timbre).
    pub weight: f32,
}

/// Interpolation shape for a ramp, as independent easing at each end.
///
/// Easing flattens the tangent at that end (sinusoidal). No easing is linear;
/// easing both ends gives a full S-curve. The default is smooth (both ends
/// eased), since a linear ramp sounds harsh on most envelopes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Curve {
    /// Smooth (flat-tangent) start.
    pub ease_in: bool,
    /// Smooth (flat-tangent) end.
    pub ease_out: bool,
}

impl Default for Curve {
    fn default() -> Self {
        Self::SMOOTH
    }
}

impl Curve {
    /// A straight ramp (no easing at either end).
    pub const LINEAR: Self = Self {
        ease_in: false,
        ease_out: false,
    };
    /// An S-curve (both ends eased). The default.
    pub const SMOOTH: Self = Self {
        ease_in: true,
        ease_out: true,
    };
}

/// One envelope segment: how long it takes and what shape its ramp has.
#[derive(Debug, Clone, Copy, Default)]
pub struct Envelope {
    /// Length of the segment in seconds.
    pub duration: f32,
    /// Ramp shape.
    pub curve: Curve,
}

/// A slow oscillation modulating pitch (vibrato) or amplitude (tremolo).
///
/// A `depth` of 0 means no modulation (the default).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Modulation {
    /// Oscillation rate in Hz.
    pub rate: f32,
    /// Modulation depth (0 = off). For vibrato in semitones, tremolo in 0..1.
    pub depth: f32,
    /// Shape of the modulating oscillation.
    pub waveform: Waveform,
}

/// Initial pitch offset that glides to the target pitch over a fixed time.
///
/// A `time` of 0 means no slide (the default).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Slide {
    /// Starting offset in semitones relative to the note's pitch.
    pub amount: f32,
    /// Time in seconds to reach the target pitch (0 = off).
    pub time: f32,
}

/// Waveform mix and envelope describing one synthesizable instrument voice.
#[derive(Debug, Clone)]
pub struct Sound {
    /// Spectral components summed to produce the voice.
    pub harmonics: Vec<Harmonic>,
    /// Tanh-saturation amount applied to the mixed signal.
    pub distortion: f32,
    /// Attack envelope at note start (`duration` 0 = no fade-in).
    pub attack: Envelope,
    /// Decay/release envelope at note end (`duration` 0 = no fade-out).
    pub decay: Envelope,
    /// Sustain level held between attack and decay (1.0 = full).
    pub sustain: f32,
    /// White-noise weight mixed alongside the harmonics (0.0 = none).
    pub noise: f32,
    /// Pitch modulation (`depth` 0 = no vibrato).
    pub vibrato: Modulation,
    /// Amplitude modulation (`depth` 0 = no tremolo).
    pub tremolo: Modulation,
    /// Initial pitch glide (`time` 0 = none).
    pub slide: Slide,
    /// Voice volume multiplier applied after envelope and distortion.
    pub volume: f32,
}

impl Default for Sound {
    fn default() -> Self {
        Self {
            harmonics: Vec::new(),
            distortion: 0.0,
            attack: Envelope::default(),
            decay: Envelope::default(),
            sustain: 1.0,
            noise: 0.0,
            vibrato: Modulation::default(),
            tremolo: Modulation::default(),
            slide: Slide::default(),
            volume: 1.0,
        }
    }
}

impl Sound {
    /// Classic chiptune lead: triangle-heavy with subtle harmonics.
    pub fn chiptune() -> Self {
        Self {
            harmonics: vec![
                Harmonic {
                    waveform: Waveform::Triangle,
                    multiplier: 1.0,
                    weight: 0.6,
                },
                Harmonic {
                    waveform: Waveform::SoftSquare,
                    multiplier: 1.0,
                    weight: 0.2,
                },
                Harmonic {
                    waveform: Waveform::Sine,
                    multiplier: 2.0,
                    weight: 0.1,
                },
                Harmonic {
                    waveform: Waveform::Triangle,
                    multiplier: 0.5,
                    weight: 0.1,
                },
            ],
            volume: 0.05,
            ..Self::default()
        }
    }

    /// Overdriven guitar with rich harmonics.
    pub fn guitar() -> Self {
        Self {
            harmonics: vec![
                Harmonic {
                    waveform: Waveform::Sine,
                    multiplier: 1.0,
                    weight: 0.3,
                },
                Harmonic {
                    waveform: Waveform::Triangle,
                    multiplier: 1.0,
                    weight: 0.3,
                },
                Harmonic {
                    waveform: Waveform::SoftSquare,
                    multiplier: 1.0,
                    weight: 0.2,
                },
                Harmonic {
                    waveform: Waveform::Sine,
                    multiplier: 2.0,
                    weight: 0.15,
                },
                Harmonic {
                    waveform: Waveform::Sine,
                    multiplier: 1.5,
                    weight: 0.1,
                },
            ],
            distortion: 3.0,
            volume: 0.05,
            ..Self::default()
        }
    }

    /// Deep bass: sine-heavy with sub-octave warmth.
    pub fn bass() -> Self {
        Self {
            harmonics: vec![
                Harmonic {
                    waveform: Waveform::Sine,
                    multiplier: 1.0,
                    weight: 0.5,
                },
                Harmonic {
                    waveform: Waveform::Triangle,
                    multiplier: 1.0,
                    weight: 0.3,
                },
                Harmonic {
                    waveform: Waveform::Triangle,
                    multiplier: 0.5,
                    weight: 0.2,
                },
            ],
            volume: 0.06,
            ..Self::default()
        }
    }

    /// Buzzy bass: distorted with grit.
    pub fn bass_dirty() -> Self {
        Self {
            harmonics: vec![
                Harmonic {
                    waveform: Waveform::Sine,
                    multiplier: 1.0,
                    weight: 0.4,
                },
                Harmonic {
                    waveform: Waveform::Triangle,
                    multiplier: 1.0,
                    weight: 0.2,
                },
                Harmonic {
                    waveform: Waveform::SoftSquare,
                    multiplier: 1.0,
                    weight: 0.1,
                },
                Harmonic {
                    waveform: Waveform::Triangle,
                    multiplier: 0.5,
                    weight: 0.3,
                },
            ],
            distortion: 2.0,
            volume: 0.05,
            ..Self::default()
        }
    }
}

/// One layer in a song: a simultaneously-mixed voice with mix settings,
/// patterns, audio clips, and effects.
#[derive(Debug, Clone)]
pub struct Layer {
    /// Display name.
    pub name: String,
    /// Per-layer volume multiplier (1.0 = unity).
    pub volume: f32,
    /// Stereo pan from -1.0 (left) to 1.0 (right).
    pub pan: f32,
    /// Layer is silenced.
    pub muted: bool,
    /// Layer is in solo mode (silences all non-solo layers).
    pub solo: bool,
    /// If set, the layer's content repeats every N beats.
    pub loop_length: Option<f32>,
    /// Beats skipped at the start of every repeat after the first full pass
    /// (0 = loop the whole thing). Only meaningful with `loop_length`.
    pub loop_skip: f32,
    /// Synthesized notes on this layer, each carrying its own sound.
    pub notes: Vec<Note>,
    /// Motif placements on this layer (references, resolved at render time).
    pub motif_instances: Vec<MotifInstance>,
    /// External audio clips on this layer.
    pub audio: Vec<AudioClip>,
    /// Effects applied to the rendered layer buffer in order.
    pub effects: Vec<Effect>,
    /// Name of the tuning to interpret pitches with (`None` = 12-TET default).
    pub tuning: Option<String>,
    /// Frequency in Hz of pitch 0 (the scale root); default 440 (A4).
    pub root: f32,
}

/// Audio effect applied to a layer's rendered output.
#[derive(Debug, Clone, Copy)]
pub enum Effect {
    /// One-pole lowpass at the given cutoff in Hz.
    Lowpass {
        /// Cutoff frequency in Hz.
        cutoff: f32,
    },
    /// One-pole highpass at the given cutoff in Hz.
    Highpass {
        /// Cutoff frequency in Hz.
        cutoff: f32,
    },
    /// Echo with delay (seconds), feedback (0..1), and wet/dry mix (0..1).
    Echo {
        /// Delay length in seconds.
        delay: f32,
        /// Feedback fraction fed back into the delay line.
        feedback: f32,
        /// Wet mix (0 = dry, 1 = full echo).
        mix: f32,
    },
    /// Tremolo: amplitude modulation by a sine LFO.
    Tremolo {
        /// Rate of the LFO in Hz.
        rate: f32,
        /// Modulation depth (0 = off, 1 = full).
        depth: f32,
    },
    /// Bitcrusher: quantize samples to `bits` levels.
    Bitcrusher {
        /// Bit depth (1..16).
        bits: u8,
    },
}

impl Layer {
    /// Creates an empty layer with the given name and default mix settings.
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            volume: 1.0,
            pan: 0.0,
            muted: false,
            solo: false,
            loop_length: None,
            loop_skip: 0.0,
            notes: Vec::new(),
            motif_instances: Vec::new(),
            audio: Vec::new(),
            effects: Vec::new(),
            tuning: None,
            root: 440.0,
        }
    }
}

/// An external audio file played on a track at a given beat position.
#[derive(Debug, Clone)]
pub struct AudioClip {
    /// Path to the audio file (relative to the song file).
    pub file: String,
    /// Beat position where the file begins playing.
    pub start: f32,
}

/// One synthesized note on a track.
#[derive(Debug, Clone, PartialEq)]
pub struct Note {
    /// Name of the sound to synthesize this note with.
    pub sound: String,
    /// Pitch in semitones relative to A4 (0 = 440Hz, 12 = 880Hz).
    pub pitch: f32,
    /// Absolute beat position where the note starts.
    pub start: f32,
    /// Length in beats.
    pub duration: f32,
    /// Semitones the pitch glides by over the note (0.0 = steady).
    pub glide: f32,
    /// Interpolation shape for the pitch glide.
    pub pitch_curve: Curve,
    /// Per-note volume multiplier (1.0 = unchanged).
    pub volume: f32,
    /// Volume change added over the note (0.0 = steady); end = volume + ramp.
    pub volume_ramp: f32,
    /// Interpolation shape for the volume ramp.
    pub volume_curve: Curve,
}

impl Default for Note {
    fn default() -> Self {
        Self {
            sound: String::new(),
            pitch: 0.0,
            start: 0.0,
            duration: 0.0,
            glide: 0.0,
            pitch_curve: Curve::default(),
            volume: 1.0,
            volume_ramp: 0.0,
            volume_curve: Curve::default(),
        }
    }
}

/// One note inside a motif definition.
///
/// Like [`Note`] but without a sound: the sound comes from the
/// [`MotifInstance`] that places the motif on a track.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MotifNote {
    /// Pitch in semitones relative to A4 (0 = 440Hz, 12 = 880Hz).
    pub pitch: f32,
    /// Beat position relative to the motif start.
    pub start: f32,
    /// Length in beats.
    pub duration: f32,
    /// Semitones the pitch glides by over the note (0.0 = steady).
    pub glide: f32,
    /// Interpolation shape for the pitch glide.
    pub pitch_curve: Curve,
    /// Per-note volume multiplier (1.0 = unchanged).
    pub volume: f32,
    /// Volume change added over the note (0.0 = steady); end = volume + ramp.
    pub volume_ramp: f32,
    /// Interpolation shape for the volume ramp.
    pub volume_curve: Curve,
}

/// Places a named motif on a track with a chosen sound and transposition.
///
/// The instance keeps a reference to the motif by `name` rather than copying
/// its notes, so editing the motif definition updates every instance.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MotifInstance {
    /// Name of the motif in [`Song::motifs`].
    pub name: String,
    /// Absolute beat position where the motif starts.
    pub start: f32,
    /// Sound to synthesize the motif's notes with.
    pub sound: String,
    /// Semitones added to every note's pitch.
    pub transpose: f32,
    /// Tuning to render this instance with, overriding the layer's tuning
    /// (`None` = use the layer's tuning). Enables per-instance scale shifts
    /// (e.g. the same motif in major and minor).
    pub tuning: Option<String>,
    /// Per-note value overrides for this placement, addressed by note index
    /// (see [`NoteOverride`]). An override for a missing index is ignored.
    pub overrides: Vec<NoteOverride>,
    /// Keeps only a contiguous range of the motif's notes for this placement
    /// (`None` = all). Trims leading/trailing notes without editing the motif.
    pub trim: Option<Trim>,
}

/// Overrides selected values of one motif note for a single placement.
///
/// Addressed by `index` into the motif's notes (in play order, sorted by
/// start). Each field is `None` when not overridden; otherwise it replaces the
/// motif definition's value before the instance's transpose/start offset apply.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct NoteOverride {
    /// Index of the note to override, in the motif's play order.
    pub index: usize,
    /// Replacement pitch in semitones (still shifted by the instance transpose).
    pub pitch: Option<f32>,
    /// Replacement start beat relative to the motif (still offset by the start).
    pub start: Option<f32>,
    /// Replacement length in beats.
    pub duration: Option<f32>,
    /// Replacement glide in semitones (relative, unaffected by transpose).
    pub glide: Option<f32>,
    /// Replacement per-note volume multiplier.
    pub volume: Option<f32>,
}

/// A contiguous run of a motif's notes to keep in one placement.
///
/// `from` is the first note index (play order) to keep; `count` is how many.
/// Notes outside the run are dropped for that placement only.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Trim {
    /// First note index to keep (play order).
    pub from: usize,
    /// Number of notes to keep.
    pub count: usize,
}

impl MotifNote {
    /// Resolves this motif note into a playable [`Note`] using the instance's
    /// sound, start offset, and transposition. `overrides`, if given, replaces
    /// this note's values before the instance transform applies.
    pub fn resolve(&self, instance: &MotifInstance, overrides: Option<&NoteOverride>) -> Note {
        let pitch = overrides.and_then(|o| o.pitch).unwrap_or(self.pitch);
        let start = overrides.and_then(|o| o.start).unwrap_or(self.start);
        let duration = overrides.and_then(|o| o.duration).unwrap_or(self.duration);
        let glide = overrides.and_then(|o| o.glide).unwrap_or(self.glide);
        let volume = overrides.and_then(|o| o.volume).unwrap_or(self.volume);
        Note {
            sound: instance.sound.clone(),
            pitch: pitch + instance.transpose,
            start: instance.start + start,
            duration,
            glide,
            pitch_curve: self.pitch_curve,
            volume,
            volume_ramp: self.volume_ramp,
            volume_curve: self.volume_curve,
        }
    }
}

/// Converts a pitch in semitones (0 = A4) to a frequency in Hz.
pub fn pitch_to_frequency(pitch: f32) -> f32 {
    440.0 * 2.0_f32.powf(pitch / 12.0)
}

/// A musical scale as frequency ratios relative to a root, repeating every
/// `period`.
///
/// `degrees[0]` is the root ratio (always 1.0); each further entry is that
/// scale step's ratio within one period. A pitch is a scale-degree index:
/// integer pitches land on degrees, fractional pitches interpolate
/// geometrically between neighbours. Reaching `degrees.len()` moves up one
/// period. The default is 12-tone equal temperament, so an unset tuning
/// reproduces [`pitch_to_frequency`].
#[derive(Debug, Clone, PartialEq)]
pub struct Tuning {
    /// Ratio between one period and the next (2.0 = octave).
    pub period: f32,
    /// Ratio of each scale degree within a period, starting at 1.0.
    pub degrees: Vec<f32>,
}

impl Default for Tuning {
    fn default() -> Self {
        Self::equal(12)
    }
}

impl Tuning {
    /// Builds an equal-tempered tuning: `steps` equal divisions of the octave.
    pub fn equal(steps: usize) -> Self {
        let steps = steps.max(1);
        let degrees = (0..steps)
            .map(|step| 2.0_f32.powf(step as f32 / steps as f32))
            .collect();
        Self {
            period: 2.0,
            degrees,
        }
    }

    /// Builds a tuning from explicit degree ratios, normalized so the first
    /// degree is 1.0 (writing `220 264 330` is the same as `1 1.2 1.5`).
    pub fn from_ratios(period: f32, ratios: &[f32]) -> Self {
        let base = ratios.first().copied().filter(|r| *r != 0.0).unwrap_or(1.0);
        let degrees = if ratios.is_empty() {
            vec![1.0]
        } else {
            ratios.iter().map(|ratio| ratio / base).collect()
        };
        Self { period, degrees }
    }

    /// Frequency ratio of `pitch` (a scale-degree index) relative to the root.
    pub fn ratio(&self, pitch: f32) -> f32 {
        let len = self.degrees.len().max(1) as f32;
        let period_index = (pitch / len).floor();
        let within = pitch - period_index * len;
        let lower = within.floor();
        let frac = within - lower;
        let lower_ratio = self.degree_ratio(lower as usize);
        let base = self.period.powf(period_index) * lower_ratio;
        if frac == 0.0 {
            return base;
        }
        let upper_ratio = self.step_up_ratio(lower as usize);
        base * (upper_ratio / lower_ratio).powf(frac)
    }

    /// Ratio of degree `index` within one period (index 0..len).
    fn degree_ratio(&self, index: usize) -> f32 {
        self.degrees.get(index).copied().unwrap_or(1.0)
    }

    /// Ratio of the degree above `index`, wrapping into the next period.
    fn step_up_ratio(&self, index: usize) -> f32 {
        if index + 1 < self.degrees.len() {
            self.degrees[index + 1]
        } else {
            self.period * self.degrees.first().copied().unwrap_or(1.0)
        }
    }
}

/// Converts a scale-degree `pitch` to a frequency in Hz under `tuning`,
/// with `root_hz` as the frequency of pitch 0.
pub fn pitch_to_frequency_in(tuning: &Tuning, pitch: f32, root_hz: f32) -> f32 {
    root_hz * tuning.ratio(pitch)
}

/// Returns true if the file name ends in a known audio file extension
/// (`.wav`, `.mp3`, `.ogg`, `.flac`).
pub fn is_audio_filename(name: &str) -> bool {
    let lower = name.to_ascii_lowercase();
    lower.ends_with(".wav")
        || lower.ends_with(".mp3")
        || lower.ends_with(".ogg")
        || lower.ends_with(".flac")
}

#[cfg(test)]
mod tests {
    use super::{Tuning, pitch_to_frequency, pitch_to_frequency_in};

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

    #[test]
    fn default_tuning_matches_12tet() {
        let tuning = Tuning::default();
        for pitch in [-12.0, -5.0, 0.0, 1.0, 7.0, 12.0, 19.0] {
            let expected = pitch_to_frequency(pitch);
            let got = pitch_to_frequency_in(&tuning, pitch, 440.0);
            assert!(approx(got, expected), "pitch {pitch}: {got} vs {expected}");
        }
    }

    #[test]
    fn from_ratios_normalizes_to_first_degree() {
        let a = Tuning::from_ratios(2.0, &[220.0, 264.0, 330.0]);
        let b = Tuning::from_ratios(2.0, &[1.0, 1.2, 1.5]);
        assert_eq!(a.degrees, b.degrees);
        assert!(approx(a.degrees[1], 1.2));
        assert!(approx(a.degrees[2], 1.5));
    }

    #[test]
    fn integer_pitch_lands_on_degree() {
        let tuning = Tuning::from_ratios(2.0, &[1.0, 1.25, 1.5]);
        assert!(approx(tuning.ratio(0.0), 1.0));
        assert!(approx(tuning.ratio(1.0), 1.25));
        assert!(approx(tuning.ratio(2.0), 1.5));
        // one full period up = root ratio times period.
        assert!(approx(tuning.ratio(3.0), 2.0));
        assert!(approx(tuning.ratio(4.0), 2.5));
    }

    #[test]
    fn fractional_pitch_interpolates_geometrically() {
        let tuning = Tuning::from_ratios(2.0, &[1.0, 2.0]);
        // halfway between ratio 1 and 2 geometrically = sqrt(2).
        assert!(approx(tuning.ratio(0.5), 2.0_f32.sqrt()));
    }

    #[test]
    fn non_octave_period_works() {
        // Bohlen-Pierce style: period 3.0.
        let tuning = Tuning::from_ratios(3.0, &[1.0, 5.0 / 3.0]);
        assert!(approx(tuning.ratio(0.0), 1.0));
        assert!(approx(tuning.ratio(2.0), 3.0));
    }
}
