#[cfg(all(not(target_arch = "wasm32"), feature = "playback"))]
use super::mixer::SongLoop;
use super::{
    format::ParseError,
    song::{
        Curve, Effect, Layer, Note, NoteInfo, SampleZone, Song, Sound, Tempo, Tuning, WavePoint,
        default_instrument, pitch_to_frequency_in, sample_envelope, sample_wave, sound_jitter,
        standard_waveform,
    },
    synth::{Voice, VoiceHandle, table_sample},
};
#[cfg(feature = "playback")]
use super::{
    format::{parse, resolve_imports},
    graph::SongGraph,
    mixer::{Levels, MasterMix, MixerLayer, SharedMix},
    song::{Condition, ConditionSource, Refresh, Subtitle, VolumeBinding},
};
#[cfg(feature = "playback")]
use crate::system::SoundSystem;
#[cfg(all(not(target_arch = "wasm32"), feature = "playback"))]
use rodio::Player;
use rodio::{ChannelCount, Decoder, SampleRate, source::UniformSourceIterator};
#[cfg(feature = "playback")]
use std::{collections::BTreeMap, time::Instant};
use std::{
    collections::HashMap,
    fs::File,
    io::BufReader,
    num::NonZero,
    path::{Path, PathBuf},
    sync::{
        Arc,
        atomic::{AtomicU32, Ordering},
    },
    time::Duration,
};

pub(super) const SAMPLE_RATE: u32 = 44100;

const SOFT_CLIP_THRESHOLD: f32 = 0.8;

/// Soft-limits a sample: transparent below the threshold, tanh-compressed
/// above it, bounded to ±1. Used by the live master mix and offline renders.
pub(super) fn soft_clip(sample: f32) -> f32 {
    let magnitude = sample.abs();
    if magnitude <= SOFT_CLIP_THRESHOLD {
        return sample;
    }
    let knee = 1.0 - SOFT_CLIP_THRESHOLD;
    let compressed = SOFT_CLIP_THRESHOLD + knee * ((magnitude - SOFT_CLIP_THRESHOLD) / knee).tanh();
    sample.signum() * compressed
}
const MONO: ChannelCount = NonZero::new(1).unwrap();
const RATE: SampleRate = NonZero::new(SAMPLE_RATE).unwrap();

/// Errors that can occur while loading or playing a song.
#[derive(Debug, thiserror::Error)]
pub enum PlayerError {
    /// Failed to allocate a per-layer sink on the audio system.
    #[error("could not create sink")]
    SinkCreate,
    /// A note references a sound name that doesn't exist in the song.
    #[error("unknown sound '{0}'")]
    UnknownSound(String),
    /// A motif instance references a motif name that doesn't exist.
    #[error("unknown motif '{0}'")]
    UnknownMotif(String),
    /// A harmonic names a wave that doesn't exist in the song.
    #[error("unknown wave '{0}'")]
    UnknownWave(String),
    /// A voice sound names an unknown voice preset.
    #[error("unknown voice '{0}'")]
    UnknownVoice(String),
    /// A sung note's text contains a symbol that is not a phoneme.
    #[error("invalid note text: {0}")]
    InvalidText(String),
    /// A note on a voice sound has no text to sing.
    #[error("note on voice sound '{0}' has no text")]
    MissingText(String),
    /// A voice sound was played but the `speech` feature is not enabled.
    #[error("sound '{0}' is a voice sound; enable the 'speech' feature")]
    SpeechDisabled(String),
    /// An audio file referenced by a clip could not be opened.
    #[error("could not open audio file '{path}': {error}")]
    AudioOpen {
        /// Path that failed to open.
        path: PathBuf,
        /// Underlying I/O error.
        error: std::io::Error,
    },
    /// An audio file could not be decoded.
    #[error("could not decode audio file '{path}': {error}")]
    AudioDecode {
        /// Path that failed.
        path: PathBuf,
        /// Underlying decoder error.
        error: rodio::decoder::DecoderError,
    },
    /// Reading a song file from disk failed.
    #[error("read error: {0}")]
    Read(std::io::Error),
    /// Parsing a song file failed.
    #[error("parse error: {0}")]
    Parse(#[from] ParseError),
}

#[cfg(feature = "playback")]
/// State for playing through a [`SongGraph`], following `@jump` at song ends.
struct GraphPlayback {
    graph: SongGraph,
    current: PathBuf,
    /// When the current song reaches its length and should follow its jump.
    jump_at: Option<Instant>,
    /// How many times each song has finished, keyed by its path.
    plays: BTreeMap<PathBuf, u32>,
    /// Running state for the random condition source.
    rng: u32,
}

#[cfg(feature = "playback")]
impl GraphPlayback {
    /// Next uniform random draw in `0.0..1.0` (xorshift).
    fn next_random(&mut self) -> f32 {
        self.rng ^= self.rng << 13;
        self.rng ^= self.rng >> 17;
        self.rng ^= self.rng << 5;
        self.rng as f32 / u32::MAX as f32
    }

    /// Evaluates a jump condition against the current play count and the
    /// current song's parameter defaults (overridden by host-set values).
    fn test_condition(
        &mut self,
        condition: &Condition,
        plays: u32,
        params: &BTreeMap<String, f32>,
        defaults: &BTreeMap<String, f32>,
    ) -> bool {
        let left = match &condition.source {
            ConditionSource::Plays => plays as f32,
            ConditionSource::Random => self.next_random(),
            ConditionSource::Param(name) => param_value(params, defaults, name),
        };
        condition.compare.test(left, condition.value)
    }
}

#[cfg(feature = "playback")]
/// Looks up a playtime parameter: host-set value, else the song's `@param`
/// default, else 0.
fn param_value(
    params: &BTreeMap<String, f32>,
    defaults: &BTreeMap<String, f32>,
    name: &str,
) -> f32 {
    params
        .get(name)
        .or_else(|| defaults.get(name))
        .copied()
        .unwrap_or(0.0)
}

#[cfg(feature = "playback")]
/// Effective volume of a parameter-bound layer: `param × scale`, floored at 0.
fn eval_volume_binding(
    binding: &VolumeBinding,
    params: &BTreeMap<String, f32>,
    defaults: &BTreeMap<String, f32>,
) -> f32 {
    (param_value(params, defaults, &binding.param) * binding.scale).max(0.0)
}

/// Plays a [`Song`] through a [`SoundSystem`].
///
/// Each layer is pre-rendered into a stereo buffer; all layers then run
/// through one master mix source with a soft-clip limiter, live per-layer and
/// master volume, sample-exact position and peak/RMS metering. Looping layers
/// repeat from their loop point. The mix keeps playing until
/// [`SongPlayer::stop`] or another `play` call.
///
/// For multi-song playback, [`SongPlayer::play_graph`] follows `@jump`
/// directives; call [`SongPlayer::update`] periodically to advance jumps.
/// [`SongPlayer::update`] also drives `@volume param` layer fades, so call it
/// periodically whenever layers bind their volume to a parameter, graph or not.
#[cfg(feature = "playback")]
pub struct SongPlayer {
    #[cfg(not(target_arch = "wasm32"))]
    sink: Option<Player>,
    #[cfg(target_arch = "wasm32")]
    web_song: Option<crate::system::WebSong>,
    shared_mix: Option<Arc<SharedMix>>,
    mixer_channels: Vec<Option<usize>>,
    /// Parameter-driven volume of each layer (parallel to `song.layers`);
    /// `None` = static volume. Evaluated each [`SongPlayer::update`].
    volume_bindings: Vec<Option<VolumeBinding>>,
    /// The current song's `@param` defaults, under any host overrides.
    param_defaults: BTreeMap<String, f32>,
    /// Host-set parameter values, layered over `param_defaults`; persists
    /// across songs and `play` calls until overwritten.
    params: BTreeMap<String, f32>,
    master_volume: f32,
    output_latency: Duration,
    mute_solo: MuteSolo,
    graph_playback: Option<GraphPlayback>,
    /// The current song's subtitles (sorted) and tempo, for `current_subtitle`.
    subtitles: Vec<Subtitle>,
    subtitle_tempo: Tempo,
}

#[cfg(feature = "playback")]
impl Default for SongPlayer {
    fn default() -> Self {
        Self {
            #[cfg(not(target_arch = "wasm32"))]
            sink: None,
            #[cfg(target_arch = "wasm32")]
            web_song: None,
            shared_mix: None,
            mixer_channels: Vec::new(),
            volume_bindings: Vec::new(),
            param_defaults: BTreeMap::new(),
            params: BTreeMap::new(),
            master_volume: 1.0,
            output_latency: Duration::ZERO,
            mute_solo: MuteSolo::Ignore,
            graph_playback: None,
            subtitles: Vec::new(),
            subtitle_tempo: Tempo::new(120.0, 1.0, 0.0),
        }
    }
}

#[cfg(feature = "playback")]
impl SongPlayer {
    /// Creates an idle player with no layers playing.
    pub fn new() -> Self {
        Self::default()
    }

    /// Loads a `.track` file from disk and starts playing it.
    ///
    /// Resolves `@import` references relative to the file's directory.
    pub fn play_path(
        &mut self,
        system: &SoundSystem,
        path: impl AsRef<Path>,
    ) -> Result<(), PlayerError> {
        self.play_path_with(system, path, Refresh::Cached)
    }

    /// Like [`SongPlayer::play_path`], but `refresh` controls whether cached
    /// URL imports are reused ([`Refresh::Cached`]) or re-downloaded
    /// ([`Refresh::Force`]).
    pub fn play_path_with(
        &mut self,
        system: &SoundSystem,
        path: impl AsRef<Path>,
        refresh: Refresh,
    ) -> Result<(), PlayerError> {
        let path = path.as_ref();
        let source = std::fs::read_to_string(path).map_err(PlayerError::Read)?;
        let mut song = parse(&source)?;
        resolve_imports(&mut song, path.parent(), refresh)?;
        self.play(system, &song, path.parent(), 0.0)
    }

    /// Plays a song that has already been loaded.
    ///
    /// `base_directory` is used to resolve relative paths in audio clips.
    /// `offset_beats` skips the start of the song without changing scheduling.
    pub fn play(
        &mut self,
        system: &SoundSystem,
        song: &Song,
        base_directory: Option<&Path>,
        offset_beats: f32,
    ) -> Result<(), PlayerError> {
        self.stop();
        self.master_volume = 1.0;
        self.output_latency = system.output_latency();
        self.graph_playback = None;
        self.start_song(system, song, base_directory, offset_beats, None)
    }

    /// Builds and starts the master mix for one song (no graph state). With
    /// `loop_from`, the mix wraps back to that beat at the song's end instead
    /// of stopping — a gapless self-loop needing no re-render.
    fn start_song(
        &mut self,
        system: &SoundSystem,
        song: &Song,
        base_directory: Option<&Path>,
        offset_beats: f32,
        loop_from: Option<f32>,
    ) -> Result<(), PlayerError> {
        #[cfg(not(target_arch = "wasm32"))]
        {
            if let Some(sink) = &self.sink {
                sink.stop();
            }
            self.sink = None;
        }
        #[cfg(target_arch = "wasm32")]
        {
            if let Some(song) = &self.web_song {
                song.stop();
            }
            self.web_song = None;
        }
        self.shared_mix = None;
        self.mixer_channels.clear();
        self.param_defaults = song.params.clone();
        self.volume_bindings = song
            .layers
            .iter()
            .map(|layer| layer.volume_param.clone())
            .collect();
        let params = self.params.clone();
        let tempo = song.tempo_map();
        self.subtitles.clone_from(&song.subtitles);
        self.subtitle_tempo = tempo;
        let offset_frames = (tempo.seconds_at(offset_beats).max(0.0) * SAMPLE_RATE as f32) as usize;

        let mut layers: Vec<MixerLayer> = Vec::new();
        let mut volumes: Vec<f32> = Vec::new();
        let buffers = render_audible_layers(song, base_directory, tempo, self.mute_solo)?;
        for (layer, buffer) in song.layers.iter().zip(buffers) {
            let Some(buffer) = buffer else {
                self.mixer_channels.push(None);
                continue;
            };
            if buffer.is_empty() {
                self.mixer_channels.push(None);
                continue;
            }
            let loop_start_frame = layer.loop_length.map(|_| {
                (tempo.seconds_at(layer.loop_skip.max(0.0)) * SAMPLE_RATE as f32) as usize
            });
            self.mixer_channels.push(Some(layers.len()));
            layers.push(MixerLayer::new(buffer, loop_start_frame, offset_frames));
            let volume = match &layer.volume_param {
                Some(binding) => eval_volume_binding(binding, &params, &song.params),
                None => layer.volume,
            };
            volumes.push(volume);
        }
        if layers.is_empty() {
            return Ok(());
        }

        #[cfg(not(target_arch = "wasm32"))]
        {
            let song_loop = loop_from.map(|skip| SongLoop {
                start_frame: (tempo.seconds_at(skip.max(0.0)) * SAMPLE_RATE as f32) as usize,
                end_frame: (tempo.seconds_at(song.length()) * SAMPLE_RATE as f32) as usize,
            });
            let shared = SharedMix::new(&volumes, self.master_volume);
            let sink = system.create_sink().ok_or(PlayerError::SinkCreate)?;
            sink.append(MasterMix::new(
                layers,
                Arc::clone(&shared),
                offset_frames,
                song_loop,
            ));
            sink.play();
            self.sink = Some(sink);
            self.shared_mix = Some(shared);
        }
        #[cfg(target_arch = "wasm32")]
        {
            // Web Audio plays a baked buffer: render the whole song once and let
            // the buffer-source node loop it. Master volume rides a gain node so
            // it stays live, so the buffer bakes in only per-layer volumes.
            let shared = SharedMix::new(&volumes, 1.0);
            let total_frames = (tempo.seconds_at(song.length()) * SAMPLE_RATE as f32) as usize;
            let sample_count = total_frames.saturating_sub(offset_frames) * 2;
            let mut mix = MasterMix::new(layers, Arc::clone(&shared), offset_frames, None);
            let samples: Vec<f32> = mix.by_ref().take(sample_count).collect();
            let offset_seconds = f64::from(tempo.seconds_at(offset_beats).max(0.0));
            let loop_start = loop_from
                .map(|skip| f64::from(tempo.seconds_at(skip.max(0.0))).max(offset_seconds))
                .map(|start| start - offset_seconds);
            let song = system.play_song(&samples, SAMPLE_RATE, offset_seconds, loop_start);
            if let Some(song) = &song {
                song.set_master_volume(self.master_volume);
            }
            self.web_song = song;
        }
        Ok(())
    }

    /// Loads a `.track` file and every song reachable through `@jump`, then
    /// starts the entry song. Call [`SongPlayer::update`] periodically so jumps
    /// are followed when a song reaches its end.
    pub fn play_graph_path(
        &mut self,
        system: &SoundSystem,
        path: impl AsRef<Path>,
    ) -> Result<(), PlayerError> {
        self.play_graph_path_with(system, path, Refresh::Cached)
    }

    /// Like [`SongPlayer::play_graph_path`], but `refresh` controls whether
    /// cached URL imports are reused ([`Refresh::Cached`]) or re-downloaded
    /// ([`Refresh::Force`]).
    pub fn play_graph_path_with(
        &mut self,
        system: &SoundSystem,
        path: impl AsRef<Path>,
        refresh: Refresh,
    ) -> Result<(), PlayerError> {
        let graph = SongGraph::load(path, refresh)?;
        self.play_graph(system, graph)
    }

    /// Starts playing a preloaded [`SongGraph`] from its entry song, following
    /// `@jump` on [`SongPlayer::update`].
    pub fn play_graph(
        &mut self,
        system: &SoundSystem,
        graph: SongGraph,
    ) -> Result<(), PlayerError> {
        self.stop();
        self.master_volume = 1.0;
        self.output_latency = system.output_latency();
        let entry = graph.entry.clone();
        self.graph_playback = Some(GraphPlayback {
            graph,
            current: entry.clone(),
            jump_at: None,
            plays: BTreeMap::new(),
            rng: 0x2545_f491,
        });
        self.enter_song(system, &entry, 0.0)
    }

    /// Sets a playtime parameter. May be called before or during playback; the
    /// value persists across songs and `play` calls and overrides a song's
    /// `@param` default of the same name. Parameters drive conditional `@jump`
    /// selection and `@volume param` layer fades, both applied on
    /// [`SongPlayer::update`].
    pub fn set_parameter(&mut self, name: impl Into<String>, value: f32) {
        self.params.insert(name.into(), value);
    }

    /// Fades every parameter-bound layer toward its current target volume. The
    /// mixer ramps the applied gain, so the change is click-free.
    fn apply_volume_bindings(&self) {
        for (index, binding) in self.volume_bindings.iter().enumerate() {
            if let Some(binding) = binding {
                let target = eval_volume_binding(binding, &self.params, &self.param_defaults);
                self.set_layer_volume(index, target);
            }
        }
    }

    /// Advances playback: applies `@volume param` layer fades, then, in graph
    /// mode, follows the current song's `@jump` once it reaches its length.
    /// Call periodically. Returns an error if starting the next song fails.
    pub fn update(&mut self, system: &SoundSystem) -> Result<(), PlayerError> {
        self.apply_volume_bindings();
        let params = self.params.clone();
        let Some(playback) = &mut self.graph_playback else {
            return Ok(());
        };
        let Some(jump_at) = playback.jump_at else {
            return Ok(());
        };
        if Instant::now() < jump_at {
            return Ok(());
        }
        // The song has ended: count this play, then pick the first jump whose
        // condition holds.
        let current = playback.current.clone();
        let plays = playback.plays.entry(current.clone()).or_insert(0);
        *plays += 1;
        let plays = *plays;
        let song = &playback.graph.songs[&current];
        let jumps = song.jumps.clone();
        let defaults = song.params.clone();
        let chosen = jumps.into_iter().find(|jump| {
            jump.condition.as_ref().is_none_or(|condition| {
                playback.test_condition(condition, plays, &params, &defaults)
            })
        });
        let Some(jump) = chosen else {
            self.graph_playback = None;
            return Ok(());
        };
        let base = current.parent();
        let target = super::format::resolve_import_path(base, &jump.target);
        let target_key = graph_key(&target);
        if !playback.graph.songs.contains_key(&target_key) {
            self.graph_playback = None;
            return Ok(());
        }
        self.enter_song(system, &target_key, jump.skip)
    }

    /// Starts a graph song by key, records it as current, and schedules its
    /// jump from its length.
    fn enter_song(
        &mut self,
        system: &SoundSystem,
        key: &Path,
        offset_beats: f32,
    ) -> Result<(), PlayerError> {
        let Some(playback) = self.graph_playback.as_mut() else {
            return Ok(());
        };
        playback.current = key.to_path_buf();
        let song = playback.graph.songs[key].clone();
        let base = key.parent().map(Path::to_path_buf);
        let tempo = song.tempo_map();
        let loop_from = self_loop_skip(&song, key);
        let jump_at = if loop_from.is_some() {
            None
        } else {
            let remaining_seconds =
                (tempo.seconds_at(song.length()) - tempo.seconds_at(offset_beats)).max(0.0);
            Some(Instant::now() + Duration::from_secs_f32(remaining_seconds))
        };
        if let Some(playback) = &mut self.graph_playback {
            playback.jump_at = jump_at;
        }
        self.start_song(system, &song, base.as_deref(), offset_beats, loop_from)
    }

    /// Stops playback and drops the master mix.
    pub fn stop(&mut self) {
        #[cfg(not(target_arch = "wasm32"))]
        {
            if let Some(sink) = &self.sink {
                sink.stop();
            }
            self.sink = None;
        }
        #[cfg(target_arch = "wasm32")]
        {
            if let Some(song) = &self.web_song {
                song.stop();
            }
            self.web_song = None;
        }
        self.shared_mix = None;
        self.mixer_channels.clear();
        self.graph_playback = None;
        self.subtitles.clear();
    }

    /// Returns true while at least one layer is still producing sound.
    ///
    /// Looping layers never finish, so this stays true until [`SongPlayer::stop`].
    #[cfg(not(target_arch = "wasm32"))]
    pub fn is_playing(&self) -> bool {
        self.sink.as_ref().is_some_and(|sink| !sink.empty())
    }

    /// Returns true while the song buffer is still playing (a looping song plays
    /// until [`SongPlayer::stop`]).
    #[cfg(target_arch = "wasm32")]
    pub fn is_playing(&self) -> bool {
        self.web_song
            .as_ref()
            .is_some_and(crate::system::WebSong::is_playing)
    }

    /// Returns true while graph playback is active and a further song will
    /// start (the current song has a pending `@jump`).
    pub fn follows_jump(&self) -> bool {
        self.graph_playback
            .as_ref()
            .is_some_and(|playback| playback.jump_at.is_some())
    }

    /// Path of the song currently playing during graph playback, which changes
    /// as `@jump` moves between files. `None` for single-song playback (no
    /// graph) — there the caller already knows the path it started.
    pub fn current_song(&self) -> Option<&Path> {
        self.graph_playback
            .as_ref()
            .map(|playback| playback.current.as_path())
    }

    /// Beat position within the song currently playing during graph playback,
    /// using that song's tempo. `None` for single-song playback (no graph) —
    /// there the caller can derive beats from [`SongPlayer::position`] itself.
    ///
    /// The position is derived from the jump clock (the same one
    /// [`SongPlayer::update`] fires on), so it advances steadily until the
    /// song's length even when individual layers end early.
    pub fn current_song_beat(&self) -> Option<f32> {
        let playback = self.graph_playback.as_ref()?;
        let song = playback.graph.songs.get(&playback.current)?;
        let tempo = song.tempo_map();
        if let Some(jump_at) = playback.jump_at {
            let remaining_seconds = jump_at
                .saturating_duration_since(Instant::now())
                .as_secs_f32();
            let elapsed = (tempo.seconds_at(song.length()) - remaining_seconds).max(0.0);
            return Some(tempo.beat_at(elapsed));
        }
        Some(tempo.beat_at(self.position().as_secs_f32()))
    }

    /// The subtitle line active at the current playback position, or `None` when
    /// nothing is showing (before the first line, after a clear marker, or when
    /// the song has no `# Subtitles`). Tracks the current song across `@jump` and
    /// wraps with a looping song, so a game shows synced text without timing it.
    pub fn current_subtitle(&self) -> Option<&str> {
        let beat = self.subtitle_tempo.beat_at(self.position().as_secs_f32());
        active_subtitle(&self.subtitles, beat)
    }

    /// Sets playback speed for all layers (1.0 = normal, 2.0 = double).
    ///
    /// Speed also shifts pitch, like a tape player. Applies to layers
    /// currently playing; a later [`SongPlayer::play`] resets to 1.0.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn set_speed(&self, speed: f32) {
        if let Some(sink) = &self.sink {
            sink.set_speed(speed);
        }
    }

    /// Playback speed is not adjustable for the Web Audio backend; this is a
    /// no-op on wasm.
    #[cfg(target_arch = "wasm32")]
    pub fn set_speed(&self, _speed: f32) {}

    /// Pauses playback, keeping the position.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn pause(&self) {
        if let Some(sink) = &self.sink {
            sink.pause();
        }
    }

    /// Pauses playback, keeping the position.
    #[cfg(target_arch = "wasm32")]
    pub fn pause(&self) {
        if let Some(song) = &self.web_song {
            song.pause();
        }
    }

    /// Resumes paused playback.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn resume(&self) {
        if let Some(sink) = &self.sink {
            sink.play();
        }
    }

    /// Resumes paused playback.
    #[cfg(target_arch = "wasm32")]
    pub fn resume(&self) {
        if let Some(song) = &self.web_song {
            song.resume();
        }
    }

    /// Returns true if playback is currently paused.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn is_paused(&self) -> bool {
        self.sink.as_ref().is_some_and(Player::is_paused)
    }

    /// Returns true if playback is currently paused.
    #[cfg(target_arch = "wasm32")]
    pub fn is_paused(&self) -> bool {
        self.web_song
            .as_ref()
            .is_some_and(crate::system::WebSong::is_paused)
    }

    /// Sets a master volume multiplier applied on top of each layer's own
    /// volume (1.0 = unchanged), before the limiter.
    pub fn set_master_volume(&mut self, volume: f32) {
        self.master_volume = volume.max(0.0);
        #[cfg(not(target_arch = "wasm32"))]
        if let Some(shared) = &self.shared_mix {
            shared.set_master_volume(self.master_volume);
        }
        #[cfg(target_arch = "wasm32")]
        if let Some(song) = &self.web_song {
            song.set_master_volume(self.master_volume);
        }
    }

    /// Returns the current master volume multiplier.
    pub fn master_volume(&self) -> f32 {
        self.master_volume
    }

    /// Returns the playback position of what is currently *heard* since the
    /// song started (looping layers keep advancing it rather than wrapping).
    ///
    /// The raw rendered position is compensated by [`SongPlayer::output_latency`]
    /// so a playhead tracks the ear rather than the frames already handed to the
    /// audio output but not yet audible.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn position(&self) -> Duration {
        self.shared_mix.as_ref().map_or(Duration::ZERO, |shared| {
            shared.position().saturating_sub(self.output_latency)
        })
    }

    /// Playback position from the audio context clock (see the native variant).
    #[cfg(target_arch = "wasm32")]
    pub fn position(&self) -> Duration {
        self.web_song
            .as_ref()
            .map_or(Duration::ZERO, crate::system::WebSong::position)
    }

    /// Latency between a frame being fed to the audio output and it becoming
    /// audible; [`SongPlayer::position`] subtracts it so it reports what is
    /// currently heard.
    ///
    /// Seeded from [`crate::SoundSystem::output_latency`] on each `play`, which
    /// only covers rodio's output buffer. A backend (`PulseAudio`, `PipeWire`)
    /// may buffer more; set this to calibrate for it, the way DAWs expose a
    /// manual audio offset. A later `play` resets it to the buffer estimate.
    pub fn set_output_latency(&mut self, latency: Duration) {
        self.output_latency = latency;
    }

    /// Returns the current output-latency compensation (see
    /// [`SongPlayer::set_output_latency`]).
    pub fn output_latency(&self) -> Duration {
        self.output_latency
    }

    /// Sets whether playback honours `@mute`/`@solo` layer flags. The default
    /// is [`MuteSolo::Ignore`] (every layer plays), so a song shipped with an
    /// accidental solo still sounds complete in a game; an editor sets
    /// [`MuteSolo::Honor`] to preview its mix. Takes effect on the next `play`.
    pub fn set_mute_solo(&mut self, mute_solo: MuteSolo) {
        self.mute_solo = mute_solo;
    }

    /// Returns the current mute/solo handling (see
    /// [`SongPlayer::set_mute_solo`]).
    pub fn mute_solo(&self) -> MuteSolo {
        self.mute_solo
    }

    /// Seeks the mix to the given position from the start (looping layers
    /// wrap into their loop region).
    ///
    /// Returns an error if the source does not support seeking.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn seek(&self, position: Duration) -> Result<(), rodio::source::SeekError> {
        if let Some(sink) = &self.sink {
            sink.try_seek(position)?;
        }
        Ok(())
    }

    /// Seeks the song buffer (looping songs wrap into their loop region).
    #[cfg(target_arch = "wasm32")]
    pub fn seek(&self, position: Duration) -> Result<(), rodio::source::SeekError> {
        if let Some(song) = &self.web_song {
            song.seek(position);
        }
        Ok(())
    }

    /// Post-fader peak/RMS levels per song layer (parallel to `song.layers`;
    /// silent entries for muted or empty layers). Empty when nothing plays.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn layer_levels(&self) -> Vec<Levels> {
        let Some(shared) = &self.shared_mix else {
            return Vec::new();
        };
        self.mixer_channels
            .iter()
            .map(|channel| channel.map_or_else(Levels::default, |index| shared.layer_levels(index)))
            .collect()
    }

    /// Per-layer metering is not computed for the baked Web Audio buffer; this
    /// returns an empty vector on wasm.
    #[cfg(target_arch = "wasm32")]
    pub fn layer_levels(&self) -> Vec<Levels> {
        Vec::new()
    }

    /// Peak/RMS level of the master mix after the limiter.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn master_levels(&self) -> Levels {
        self.shared_mix
            .as_ref()
            .map_or_else(Levels::default, |shared| shared.master_levels())
    }

    /// Master metering is not computed for the baked Web Audio buffer; this
    /// returns a default level on wasm.
    #[cfg(target_arch = "wasm32")]
    pub fn master_levels(&self) -> Levels {
        Levels::default()
    }

    /// Sets one layer's live volume by its index in `song.layers`, replacing
    /// the layer's `@volume` value for the current playback. Ignored for
    /// muted layers and out-of-range indices.
    #[cfg(not(target_arch = "wasm32"))]
    pub fn set_layer_volume(&self, layer_index: usize, volume: f32) {
        if let (Some(shared), Some(Some(channel))) =
            (&self.shared_mix, self.mixer_channels.get(layer_index))
        {
            shared.set_layer_volume(*channel, volume.max(0.0));
        }
    }

    /// The Web Audio backend plays a baked buffer, so per-layer volume is fixed
    /// at render time; this is a no-op on wasm (re-play to apply new volumes).
    #[cfg(target_arch = "wasm32")]
    pub fn set_layer_volume(&self, _layer_index: usize, _volume: f32) {}
}

/// Renders one layer into an interleaved stereo `Vec<f32>` at 44100 Hz.
///
/// All notes and audio clips are summed at their beat positions, panned
/// equal-power, and (if the layer has a `loop_length`) trimmed to that
/// length so the master mix produces seamless loops.
pub fn render_layer(
    layer: &Layer,
    song: &Song,
    base_directory: Option<&Path>,
    tempo: Tempo,
) -> Result<Vec<f32>, PlayerError> {
    let (gain_left, gain_right) = pan_gains(layer.pan);
    let mut buffer: Vec<f32> = Vec::new();

    let default_tuning = Tuning::default();
    let tuning = layer
        .tuning
        .as_ref()
        .and_then(|name| song.tunings.get(name))
        .unwrap_or(&default_tuning);
    let voicing = Voicing {
        tuning,
        root: layer.root,
    };

    let loop_length_samples = layer
        .loop_length
        .map(|length| (tempo.seconds_at(length) * SAMPLE_RATE as f32) as usize);

    let mut voice_notes: Vec<(Note, Voicing)> = Vec::new();
    let mut sample_cache: HashMap<String, Arc<Vec<f32>>> = HashMap::new();
    let mut wave_cache: HashMap<String, Arc<Vec<Vec<f32>>>> = HashMap::new();

    for note in &layer.notes {
        if sound_is_voice(song, note)? {
            voice_notes.push((note.clone(), voicing));
            continue;
        }
        render_note(
            song,
            &mut buffer,
            note,
            voicing,
            tempo,
            gain_left,
            gain_right,
            loop_length_samples,
            base_directory,
            &mut sample_cache,
            &mut wave_cache,
        )?;
    }
    for instance in &layer.motif_instances {
        let motif = song
            .motifs
            .get(&instance.name)
            .ok_or_else(|| PlayerError::UnknownMotif(instance.name.clone()))?;
        let instance_tuning = instance
            .tuning
            .as_ref()
            .and_then(|name| song.tunings.get(name));
        let instance_voicing = instance_tuning.map_or(voicing, |tuning| Voicing {
            tuning,
            root: layer.root,
        });
        for (index, motif_note) in motif.iter().enumerate() {
            if let Some(trim) = instance.trim
                && (index < trim.from || index >= trim.from + trim.count)
            {
                continue;
            }
            let note_override = instance.overrides.iter().find(|o| o.index == index);
            if note_override.is_some_and(|o| o.skip) {
                continue;
            }
            let resolved = motif_note.resolve(instance, note_override);
            if sound_is_voice(song, &resolved)? {
                voice_notes.push((resolved, instance_voicing));
                continue;
            }
            render_note(
                song,
                &mut buffer,
                &resolved,
                instance_voicing,
                tempo,
                gain_left,
                gain_right,
                loop_length_samples,
                base_directory,
                &mut sample_cache,
                &mut wave_cache,
            )?;
        }
    }
    render_voice_notes(
        song,
        &mut buffer,
        voice_notes,
        tempo,
        gain_left,
        gain_right,
        loop_length_samples,
    )?;
    if let Some(loop_samples) = loop_length_samples {
        let needed = loop_samples * 2;
        if buffer.len() < needed {
            buffer.resize(needed, 0.0);
        } else {
            buffer.truncate(needed);
        }
    }

    Ok(buffer)
}

const WAVE_RESOLUTION: usize = 0x100;

/// Renders each harmonic of `sound` into sample data: a duty pulse when set,
/// otherwise its named table (a custom `# Waves` entry or a built-in
/// standard waveform), erroring on unknown names.
fn resolve_waves(song: &Song, sound: &Sound) -> Result<Vec<Vec<f32>>, PlayerError> {
    sound
        .harmonics
        .iter()
        .map(|harmonic| {
            if harmonic.duty > 0.0 {
                return Ok(pulse_table(harmonic.duty, WAVE_RESOLUTION));
            }
            resolve_table(song, &harmonic.wave)
        })
        .collect()
}

/// Resolves a waveform name to sample data: a custom `# Waves` entry wins,
/// else a built-in standard waveform, else an error.
fn resolve_table(song: &Song, name: &str) -> Result<Vec<f32>, PlayerError> {
    if let Some(segments) = song.waves.get(name) {
        Ok(sample_wave(segments, WAVE_RESOLUTION))
    } else if let Some(samples) = standard_waveform(name, WAVE_RESOLUTION) {
        Ok(samples)
    } else {
        Err(PlayerError::UnknownWave(name.to_string()))
    }
}

/// Resolves an LFO waveform name, defaulting an empty name to `sine`.
fn resolve_lfo_table(song: &Song, name: &str) -> Result<Vec<f32>, PlayerError> {
    resolve_table(song, if name.is_empty() { "sine" } else { name })
}

/// A pulse wave of the given duty (fraction high) sampled at `resolution`.
fn pulse_table(duty: f32, resolution: usize) -> Vec<f32> {
    (0..resolution)
        .map(|index| {
            if (index as f32 / resolution as f32) < duty {
                1.0
            } else {
                -1.0
            }
        })
        .collect()
}

/// Picks the sample zone covering `pitch`, or the nearest one.
fn select_zone(zones: &[SampleZone], pitch: f32) -> &SampleZone {
    zones
        .iter()
        .find(|zone| zone.pitches[0] <= pitch && pitch <= zone.pitches[1])
        .unwrap_or_else(|| {
            zones
                .iter()
                .min_by(|a, b| {
                    let distance = |zone: &SampleZone| {
                        (zone.pitches[0] - pitch)
                            .abs()
                            .min((zone.pitches[1] - pitch).abs())
                    };
                    distance(a).total_cmp(&distance(b))
                })
                .unwrap_or(&zones[0])
        })
}

/// Loads and decodes an audio file to mono 44.1 kHz, caching per render pass.
fn load_sample(
    path: &Path,
    cache: &mut HashMap<String, Arc<Vec<f32>>>,
) -> Result<Arc<Vec<f32>>, PlayerError> {
    let key = path.to_string_lossy().to_string();
    if let Some(samples) = cache.get(&key) {
        return Ok(Arc::clone(samples));
    }
    let reader = File::open(path).map_err(|error| PlayerError::AudioOpen {
        path: path.to_path_buf(),
        error,
    })?;
    let decoder =
        Decoder::new(BufReader::new(reader)).map_err(|error| PlayerError::AudioDecode {
            path: path.to_path_buf(),
            error,
        })?;
    let samples: Arc<Vec<f32>> =
        Arc::new(UniformSourceIterator::new(decoder, MONO, RATE).collect());
    cache.insert(key, Arc::clone(&samples));
    Ok(samples)
}

/// Plays `source` at `factor` speed (linear interpolation) and applies the
/// sound volume plus the note's volume envelope over the resulting length.
fn resample_with_volume(
    source: &[f32],
    factor: f32,
    sound_volume: f32,
    volume: &[WavePoint],
) -> Vec<f32> {
    if source.is_empty() || factor <= 0.0 {
        return Vec::new();
    }
    let length = ((source.len() as f32 / factor) as usize).max(1);
    (0..length)
        .map(|index| {
            let position = index as f32 * factor;
            let source_index = (position as usize).min(source.len() - 1);
            let fraction = position - source_index as f32;
            let current = source[source_index];
            let next = source.get(source_index + 1).copied().unwrap_or(current);
            let gain = sample_envelope(volume, index as f32 / length as f32);
            (current + (next - current) * fraction) * gain * sound_volume
        })
        .collect()
}

/// Builds the frequency envelope of a note: each pitch breakpoint (a semitone
/// offset from the note's pitch, plus a `detune` jitter offset) converted to Hz
/// through the voicing. Empty pitch curve = a single steady frequency.
fn frequency_envelope(note: &Note, voicing: Voicing, detune: f32) -> Vec<WavePoint> {
    let frequency = |semitones| {
        pitch_to_frequency_in(
            voicing.tuning,
            note.start.pitch + detune + semitones,
            voicing.root,
        )
    };
    let pitch_curve = note.pitch_curve();
    if pitch_curve.is_empty() {
        return vec![WavePoint {
            x: 0.0,
            y: frequency(0.0),
            curve: Curve::LINEAR,
        }];
    }
    pitch_curve
        .iter()
        .map(|point| WavePoint {
            x: point.x,
            y: frequency(point.y),
            curve: point.curve,
        })
        .collect()
}

/// Builds the volume envelope of a note: each breakpoint factor multiplied
/// with the note's base volume, all scaled by the `jitter` factor. Empty
/// volume curve = a single steady level.
fn volume_envelope(note: &Note, jitter: f32) -> Vec<WavePoint> {
    if note.volume_curve.is_empty() {
        return vec![WavePoint {
            x: 0.0,
            y: note.volume * jitter,
            curve: Curve::LINEAR,
        }];
    }
    note.volume_curve
        .iter()
        .map(|point| WavePoint {
            x: point.x,
            y: note.volume * point.y * jitter,
            curve: point.curve,
        })
        .collect()
}

/// Samples a note's volume curve as a factor: 1 (unchanged) when the curve is
/// empty, since an empty envelope samples to 0.
#[cfg(feature = "speech")]
fn volume_factor(curve: &[WavePoint], x: f32) -> f32 {
    if curve.is_empty() {
        1.0
    } else {
        sample_envelope(curve, x)
    }
}

/// A deterministic per-note seed for jitter, stable across render order (so
/// parallel layers stay reproducible): the sound name, start and pitch.
fn note_seed(note: &Note) -> u32 {
    let mut seed = 0x811c_9dc5u32;
    for byte in note.sound.bytes() {
        seed = (seed ^ u32::from(byte)).wrapping_mul(0x0100_0193);
    }
    seed ^ note.start.time.to_bits() ^ note.start.pitch.to_bits().rotate_left(16)
}

/// Resolves a note's sound: a `# Sounds` definition (or import) if present,
/// otherwise a built-in [`default_instrument`] for a standard waveform name.
fn resolve_sound(song: &Song, name: &str) -> Result<Sound, PlayerError> {
    song.sounds
        .get(name)
        .cloned()
        .or_else(|| default_instrument(name))
        .ok_or_else(|| PlayerError::UnknownSound(name.to_string()))
}

fn sound_is_voice(song: &Song, note: &Note) -> Result<bool, PlayerError> {
    // A default instrument is never a voice, so a missing but built-in name is
    // simply not a voice rather than an error.
    match song.sounds.get(&note.sound) {
        Some(sound) => Ok(!sound.voice.is_empty()),
        None if default_instrument(&note.sound).is_some() => Ok(false),
        None => Err(PlayerError::UnknownSound(note.sound.clone())),
    }
}

/// Renders all sung notes of a layer. A legato note attaches to the previous
/// note on the same voice sound; each such phrase is rendered with one `sing`
/// call for smooth junctions.
#[cfg(feature = "speech")]
fn render_voice_notes(
    song: &Song,
    buffer: &mut Vec<f32>,
    mut notes: Vec<(Note, Voicing)>,
    tempo: Tempo,
    gain_left: f32,
    gain_right: f32,
    limit_samples: Option<usize>,
) -> Result<(), PlayerError> {
    use super::{
        song::Articulation,
        speech::{PhraseNote, render_phrase},
    };

    notes.sort_by(|a, b| a.0.start.time.total_cmp(&b.0.start.time));

    let mut phrases: Vec<(String, Vec<PhraseNote>)> = Vec::new();
    for (note, voicing) in notes {
        // The sung pitch follows the note's full trajectory: each breakpoint's
        // semitone offset becomes a Hz breakpoint, and `render_phrase` bends
        // through them (volume still uses the two endpoints, v1-flat).
        let frequency =
            |offset| pitch_to_frequency_in(voicing.tuning, note.start.pitch + offset, voicing.root);
        let pitch_curve = note.pitch_curve();
        let pitch_hz = if pitch_curve.is_empty() {
            vec![WavePoint {
                x: 0.0,
                y: frequency(0.0),
                curve: Curve::default(),
            }]
        } else {
            pitch_curve
                .iter()
                .map(|point| WavePoint {
                    x: point.x,
                    y: frequency(point.y),
                    curve: point.curve,
                })
                .collect()
        };
        let spec = PhraseNote {
            pitch_hz,
            start_seconds: tempo.seconds_at(note.start.time),
            seconds: tempo.span_seconds(note.start.time, note.duration()),
            text: note.text,
            articulation: note.articulation,
            volume: [
                note.volume * volume_factor(&note.volume_curve, 0.0),
                note.volume * volume_factor(&note.volume_curve, 1.0),
            ],
            volume_curve: note
                .volume_curve
                .first()
                .map_or_else(Curve::default, |point| point.curve),
        };
        if spec.articulation == Articulation::Legato
            && let Some((last_sound, last_notes)) = phrases.last_mut()
            && *last_sound == note.sound
        {
            last_notes.push(spec);
            continue;
        }
        phrases.push((note.sound, vec![spec]));
    }

    for (sound_name, phrase) in &phrases {
        let sound = song
            .sounds
            .get(sound_name)
            .ok_or_else(|| PlayerError::UnknownSound(sound_name.clone()))?;
        let samples = render_phrase(sound_name, sound, phrase)?;
        let start_sample = (phrase[0].start_seconds * SAMPLE_RATE as f32) as usize;
        mix_mono_into_stereo(
            buffer,
            samples.into_iter(),
            start_sample,
            gain_left,
            gain_right,
            limit_samples,
        );
    }
    Ok(())
}

#[cfg(not(feature = "speech"))]
fn render_voice_notes(
    _song: &Song,
    _buffer: &mut Vec<f32>,
    notes: Vec<(Note, Voicing)>,
    _tempo: Tempo,
    _gain_left: f32,
    _gain_right: f32,
    _limit_samples: Option<usize>,
) -> Result<(), PlayerError> {
    match notes.into_iter().next() {
        Some((note, _)) => Err(PlayerError::SpeechDisabled(note.sound)),
        None => Ok(()),
    }
}

/// Whether a song's `@mute`/`@solo` layer flags affect rendering.
///
/// They are an editor mixing aid, not a runtime instruction: a `.track` shipped
/// with an accidental `@solo` would otherwise play only that layer in-game.
/// Game-facing playback [`Ignore`](MuteSolo::Ignore)s them; an editor bounce
/// [`Honor`](MuteSolo::Honor)s them to match what it previews.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MuteSolo {
    /// Render every layer, ignoring `@mute`/`@solo` (the safe default for games).
    #[default]
    Ignore,
    /// Silence muted layers, and non-solo layers when any layer solos.
    Honor,
}

/// Renders every audible layer in parallel, applying layer effects, honouring
/// `@mute`/`@solo` per `mute_solo`. Entries are `None` for silent layers,
/// parallel to `song.layers`.
pub(super) fn render_audible_layers(
    song: &Song,
    base_directory: Option<&Path>,
    tempo: Tempo,
    mute_solo: MuteSolo,
) -> Result<Vec<Option<Vec<f32>>>, PlayerError> {
    // `wasm32-unknown-unknown` has no working `std::thread`, so the threaded
    // path would deadlock on `join()` and freeze the tab forever — even on a
    // one-note song. Render layers sequentially there instead.
    #[cfg(target_arch = "wasm32")]
    {
        render_layers_sequential(song, base_directory, tempo, mute_solo)
    }
    #[cfg(not(target_arch = "wasm32"))]
    {
        render_layers_threaded(song, base_directory, tempo, mute_solo)
    }
}

/// True unless the layer is silenced by an honored mute/solo state.
fn layer_is_audible(layer: &Layer, honor: bool, any_solo: bool) -> bool {
    !(honor && (layer.muted || (any_solo && !layer.solo)))
}

/// Renders one layer's buffer with its effects applied.
fn render_layer_buffer(
    layer: &Layer,
    song: &Song,
    base_directory: Option<&Path>,
    tempo: Tempo,
) -> Result<Vec<f32>, PlayerError> {
    let mut buffer = render_layer(layer, song, base_directory, tempo)?;
    apply_effects(&mut buffer, &layer.effects, SAMPLE_RATE);
    Ok(buffer)
}

/// Renders each audible layer on its own thread (native only). Equivalent to
/// [`render_layers_sequential`] but parallel across layers.
#[cfg(not(target_arch = "wasm32"))]
fn render_layers_threaded(
    song: &Song,
    base_directory: Option<&Path>,
    tempo: Tempo,
    mute_solo: MuteSolo,
) -> Result<Vec<Option<Vec<f32>>>, PlayerError> {
    let honor = mute_solo == MuteSolo::Honor;
    let any_solo = honor && song.layers.iter().any(|layer| layer.solo);
    std::thread::scope(|scope| {
        let handles: Vec<Option<_>> = song
            .layers
            .iter()
            .map(|layer| {
                if !layer_is_audible(layer, honor, any_solo) {
                    return None;
                }
                Some(scope.spawn(move || render_layer_buffer(layer, song, base_directory, tempo)))
            })
            .collect();
        handles
            .into_iter()
            .map(|handle| match handle {
                None => Ok(None),
                Some(handle) => match handle.join() {
                    Ok(result) => result.map(Some),
                    Err(panic) => std::panic::resume_unwind(panic),
                },
            })
            .collect()
    })
}

/// Renders each audible layer one after another. Used on wasm, where threads
/// are unavailable; kept testable on native so the two paths stay in sync.
#[cfg(any(target_arch = "wasm32", all(test, feature = "playback")))]
fn render_layers_sequential(
    song: &Song,
    base_directory: Option<&Path>,
    tempo: Tempo,
    mute_solo: MuteSolo,
) -> Result<Vec<Option<Vec<f32>>>, PlayerError> {
    let honor = mute_solo == MuteSolo::Honor;
    let any_solo = honor && song.layers.iter().any(|layer| layer.solo);
    song.layers
        .iter()
        .map(|layer| {
            if !layer_is_audible(layer, honor, any_solo) {
                return Ok(None);
            }
            render_layer_buffer(layer, song, base_directory, tempo).map(Some)
        })
        .collect()
}

pub(super) fn apply_effects(buffer: &mut [f32], effects: &[Effect], sample_rate: u32) {
    for effect in effects {
        match *effect {
            Effect::Lowpass { cutoff } => apply_lowpass(buffer, cutoff, sample_rate),
            Effect::Highpass { cutoff } => apply_highpass(buffer, cutoff, sample_rate),
            Effect::Echo {
                delay,
                feedback,
                mix,
            } => apply_echo(buffer, delay, feedback, mix, sample_rate),
            Effect::Tremolo { rate, depth } => apply_tremolo(buffer, rate, depth, sample_rate),
            Effect::Bitcrusher { bits } => apply_bitcrusher(buffer, bits),
        }
    }
}

fn apply_lowpass(buffer: &mut [f32], cutoff: f32, sample_rate: u32) {
    let alpha = lowpass_alpha(cutoff, sample_rate);
    let mut y_l = 0.0_f32;
    let mut y_r = 0.0_f32;
    for chunk in buffer.chunks_exact_mut(2) {
        y_l += alpha * (chunk[0] - y_l);
        y_r += alpha * (chunk[1] - y_r);
        chunk[0] = y_l;
        chunk[1] = y_r;
    }
}

fn apply_highpass(buffer: &mut [f32], cutoff: f32, sample_rate: u32) {
    let alpha = lowpass_alpha(cutoff, sample_rate);
    let mut y_l = 0.0_f32;
    let mut y_r = 0.0_f32;
    let mut prev_l = 0.0_f32;
    let mut prev_r = 0.0_f32;
    for chunk in buffer.chunks_exact_mut(2) {
        y_l = (1.0 - alpha) * (y_l + chunk[0] - prev_l);
        y_r = (1.0 - alpha) * (y_r + chunk[1] - prev_r);
        prev_l = chunk[0];
        prev_r = chunk[1];
        chunk[0] = y_l;
        chunk[1] = y_r;
    }
}

fn lowpass_alpha(cutoff: f32, sample_rate: u32) -> f32 {
    let exponent = -std::f32::consts::TAU * cutoff.max(0.0) / sample_rate as f32;
    1.0 - exponent.exp()
}

fn apply_echo(buffer: &mut [f32], delay: f32, feedback: f32, mix: f32, sample_rate: u32) {
    let delay_samples = ((delay.max(0.0) * sample_rate as f32) as usize).max(1);
    let feedback = feedback.clamp(0.0, 0.95);
    let mix = mix.clamp(0.0, 1.0);
    let mut delay_l = vec![0.0_f32; delay_samples];
    let mut delay_r = vec![0.0_f32; delay_samples];
    let mut position = 0;
    for chunk in buffer.chunks_exact_mut(2) {
        let echoed_l = delay_l[position];
        let echoed_r = delay_r[position];
        let new_l = chunk[0] + echoed_l * feedback;
        let new_r = chunk[1] + echoed_r * feedback;
        delay_l[position] = new_l;
        delay_r[position] = new_r;
        chunk[0] = chunk[0] * (1.0 - mix) + echoed_l * mix;
        chunk[1] = chunk[1] * (1.0 - mix) + echoed_r * mix;
        position = (position + 1) % delay_samples;
    }
}

fn apply_tremolo(buffer: &mut [f32], rate: f32, depth: f32, sample_rate: u32) {
    let depth = depth.clamp(0.0, 1.0);
    let phase_step = std::f32::consts::TAU * rate / sample_rate as f32;
    let mut phase = 0.0_f32;
    for chunk in buffer.chunks_exact_mut(2) {
        let envelope = 1.0 - depth + depth * (phase.sin() * 0.5 + 0.5);
        chunk[0] *= envelope;
        chunk[1] *= envelope;
        phase += phase_step;
    }
}

fn apply_bitcrusher(buffer: &mut [f32], bits: u8) {
    let levels = 2.0_f32.powi(bits.clamp(1, 16) as i32);
    for sample in buffer.iter_mut() {
        *sample = (*sample * levels).round() / levels;
    }
}

/// How a layer maps pitches to frequencies: a tuning and its root frequency.
#[derive(Clone, Copy)]
struct Voicing<'a> {
    tuning: &'a Tuning,
    root: f32,
}

#[allow(clippy::too_many_arguments)]
fn render_note(
    song: &Song,
    buffer: &mut Vec<f32>,
    note: &Note,
    voicing: Voicing,
    tempo: Tempo,
    gain_left: f32,
    gain_right: f32,
    limit_samples: Option<usize>,
    base_directory: Option<&Path>,
    sample_cache: &mut HashMap<String, Arc<Vec<f32>>>,
    wave_cache: &mut HashMap<String, Arc<Vec<Vec<f32>>>>,
) -> Result<(), PlayerError> {
    let mut sound_def = resolve_sound(song, &note.sound)?;
    if !note.arpeggio.offsets.is_empty() {
        sound_def.arpeggio = note.arpeggio.clone();
    }
    let (detune, volume_jitter) =
        sound_jitter(sound_def.detune, sound_def.volume_jitter, note_seed(note));
    let start_sample = (tempo.seconds_at(note.start.time) * SAMPLE_RATE as f32) as usize;
    if !sound_def.samples.is_empty() {
        let zone = select_zone(&sound_def.samples, note.start.pitch);
        let path = resolve_path(base_directory, &zone.file);
        let source = load_sample(&path, sample_cache)?;
        let factor = pitch_to_frequency_in(voicing.tuning, note.start.pitch + detune, voicing.root)
            / pitch_to_frequency_in(voicing.tuning, 0.0, voicing.root);
        let samples = resample_with_volume(
            &source,
            factor,
            sound_def.volume,
            &volume_envelope(note, volume_jitter),
        );
        mix_mono_into_stereo(
            buffer,
            samples.into_iter(),
            start_sample,
            gain_left,
            gain_right,
            limit_samples,
        );
    }
    if sound_def.harmonics.is_empty() && sound_def.noise == 0.0 {
        return Ok(());
    }
    let tables = if let Some(tables) = wave_cache.get(&note.sound) {
        Arc::clone(tables)
    } else {
        let tables = Arc::new(resolve_waves(song, &sound_def)?);
        wave_cache.insert(note.sound.clone(), Arc::clone(&tables));
        tables
    };
    let vibrato_table = if sound_def.vibrato.depth > 0.0 {
        resolve_lfo_table(song, &sound_def.vibrato.wave)?
    } else {
        Vec::new()
    };
    let tremolo_table = if sound_def.tremolo.depth > 0.0 {
        resolve_lfo_table(song, &sound_def.tremolo.wave)?
    } else {
        Vec::new()
    };
    let voice = Voice::ramped(
        sound_def,
        frequency_envelope(note, voicing, detune),
        volume_envelope(note, volume_jitter),
        Duration::from_secs_f32(tempo.span_seconds(note.start.time, note.duration())),
    )
    .with_tables(tables.as_ref().clone())
    .with_lfo_tables(vibrato_table, tremolo_table);
    mix_mono_into_stereo(
        buffer,
        voice,
        start_sample,
        gain_left,
        gain_right,
        limit_samples,
    );
    Ok(())
}

/// Builds a one-shot [`Voice`] for a sound played on its own, for runtime sound
/// effects: `system.play(sound_oneshot(&song, "hit", pitch, dur, seed)?)`.
///
/// The sound's `detune`/`vol-jitter` are applied freshly from `seed` — pass a
/// changing seed (a counter or random value) each trigger so repeats vary.
/// Resolves harmonics and LFOs against the song's waves. Errors on an
/// unknown sound, and on a pure sampler sound (only synthesized sounds are
/// supported as one-shots for now).
pub fn sound_oneshot(
    song: &Song,
    sound_name: &str,
    pitch: f32,
    duration: Duration,
    seed: u32,
) -> Result<Voice, PlayerError> {
    let sound = resolve_sound(song, sound_name)?;
    if sound.harmonics.is_empty() && sound.noise == 0.0 {
        return Err(PlayerError::UnknownSound(sound_name.to_string()));
    }
    let (detune, volume_jitter) = sound_jitter(sound.detune, sound.volume_jitter, seed);
    let default_tuning = Tuning::default();
    let voicing = Voicing {
        tuning: &default_tuning,
        root: 440.0,
    };
    let note = Note {
        sound: sound_name.to_string(),
        start: NoteInfo { pitch, time: 0.0 },
        volume: 1.0,
        ..Note::default()
    };
    let tables = resolve_waves(song, &sound)?;
    let vibrato_table = if sound.vibrato.depth > 0.0 {
        resolve_lfo_table(song, &sound.vibrato.wave)?
    } else {
        Vec::new()
    };
    let tremolo_table = if sound.tremolo.depth > 0.0 {
        resolve_lfo_table(song, &sound.tremolo.wave)?
    } else {
        Vec::new()
    };
    Ok(Voice::ramped(
        sound,
        frequency_envelope(&note, voicing, detune),
        volume_envelope(&note, volume_jitter),
        duration,
    )
    .with_tables(tables)
    .with_lfo_tables(vibrato_table, tremolo_table))
}

/// Builds a held [`Voice`] and a [`VoiceHandle`] to end it, for live keyboard or
/// audition playback.
///
/// Play the voice through a `SoundSystem` and call [`VoiceHandle::release`] on
/// note-off, instead of choosing a duration up front. Resolves harmonics and
/// LFOs like [`sound_oneshot`] and errors the same way (unknown sound, or a pure
/// sampler sound).
pub fn sound_sustained(
    song: &Song,
    sound_name: &str,
    pitch: f32,
) -> Result<(Voice, VoiceHandle), PlayerError> {
    let sound = resolve_sound(song, sound_name)?;
    if sound.harmonics.is_empty() && sound.noise == 0.0 {
        return Err(PlayerError::UnknownSound(sound_name.to_string()));
    }
    let default_tuning = Tuning::default();
    let frequency = pitch_to_frequency_in(&default_tuning, pitch, 440.0);
    let tables = resolve_waves(song, &sound)?;
    let vibrato_table = if sound.vibrato.depth > 0.0 {
        resolve_lfo_table(song, &sound.vibrato.wave)?
    } else {
        Vec::new()
    };
    let tremolo_table = if sound.tremolo.depth > 0.0 {
        resolve_lfo_table(song, &sound.tremolo.wave)?
    } else {
        Vec::new()
    };
    let (voice, handle) = Voice::sustained(sound, frequency);
    Ok((
        voice
            .with_tables(tables)
            .with_lfo_tables(vibrato_table, tremolo_table),
        handle,
    ))
}

/// Builds a one-shot sampler source for a sound played on its own — the
/// counterpart to [`sound_oneshot`] for sampler sounds (footsteps, impacts).
///
/// The sound's `detune`/`vol-jitter` are applied freshly from `seed`; the chosen
/// sample plays at the speed set by `pitch` and runs to its natural end (mono,
/// 44.1 kHz). `base_directory` resolves the sample paths. Errors on an unknown
/// sound and on a synth-only (non-sampler) sound.
pub fn sample_oneshot(
    song: &Song,
    sound_name: &str,
    pitch: f32,
    base_directory: Option<&Path>,
    seed: u32,
) -> Result<rodio::buffer::SamplesBuffer, PlayerError> {
    let sound = resolve_sound(song, sound_name)?;
    if sound.samples.is_empty() {
        return Err(PlayerError::UnknownSound(sound_name.to_string()));
    }
    let (detune, volume_jitter) = sound_jitter(sound.detune, sound.volume_jitter, seed);
    let default_tuning = Tuning::default();
    let zone = select_zone(&sound.samples, pitch);
    let path = resolve_path(base_directory, &zone.file);
    let mut cache = HashMap::new();
    let source = load_sample(&path, &mut cache)?;
    let factor = pitch_to_frequency_in(&default_tuning, pitch + detune, 440.0)
        / pitch_to_frequency_in(&default_tuning, 0.0, 440.0);
    let volume = [WavePoint {
        x: 0.0,
        y: volume_jitter,
        curve: Curve::LINEAR,
    }];
    let samples = resample_with_volume(&source, factor, sound.volume, &volume);
    Ok(rodio::buffer::SamplesBuffer::new(MONO, RATE, samples))
}

static ONESHOT_SEED: AtomicU32 = AtomicU32::new(0);

fn next_oneshot_seed() -> u32 {
    ONESHOT_SEED.fetch_add(1, Ordering::Relaxed)
}

/// Like [`sound_oneshot`] but draws a fresh seed from an internal counter each
/// call.
///
/// Consecutive triggers vary without the host tracking a seed:
/// `system.play(sound_oneshot_varied(&song, "hit", pitch, dur)?)`.
pub fn sound_oneshot_varied(
    song: &Song,
    sound_name: &str,
    pitch: f32,
    duration: Duration,
) -> Result<Voice, PlayerError> {
    sound_oneshot(song, sound_name, pitch, duration, next_oneshot_seed())
}

/// Like [`sample_oneshot`] but draws a fresh seed from an internal counter each
/// call, the sampler counterpart to [`sound_oneshot_varied`].
pub fn sample_oneshot_varied(
    song: &Song,
    sound_name: &str,
    pitch: f32,
    base_directory: Option<&Path>,
) -> Result<rodio::buffer::SamplesBuffer, PlayerError> {
    sample_oneshot(song, sound_name, pitch, base_directory, next_oneshot_seed())
}

fn edge_fade(index: usize, total: usize, fade: usize) -> f32 {
    if fade == 0 {
        return 1.0;
    }
    let rising = index as f32 / fade as f32;
    let falling = (total - index) as f32 / fade as f32;
    rising.min(falling).min(1.0)
}

/// Plays a single named wave as a bare oscillator: a `frequency`-Hz tone of
/// that waveform for `duration`, no envelope beyond a short click-free edge fade.
///
/// Resolves custom `# Waves` and the built-in waveform names, erroring on an
/// unknown one. A song is only needed for its wave definitions — for a
/// built-in shape, an empty [`Song`] works:
/// `system.play(wave_oscillator(&song, "sine", 220.0, dur)?)`.
pub fn wave_oscillator(
    song: &Song,
    table_name: &str,
    frequency: f32,
    duration: Duration,
) -> Result<rodio::buffer::SamplesBuffer, PlayerError> {
    let table = resolve_table(song, table_name)?;
    let total = (duration.as_secs_f32() * SAMPLE_RATE as f32) as usize;
    let fade = ((0.005 * SAMPLE_RATE as f32) as usize).min(total / 2);
    let step = frequency / SAMPLE_RATE as f32;
    let mut phase = 0.0f32;
    let mut samples = Vec::with_capacity(total);
    for index in 0..total {
        samples.push(
            table_sample(&table, phase * std::f32::consts::TAU) * edge_fade(index, total, fade),
        );
        phase = (phase + step).fract();
    }
    Ok(rodio::buffer::SamplesBuffer::new(MONO, RATE, samples))
}

fn mix_mono_into_stereo<S: Iterator<Item = f32>>(
    buffer: &mut Vec<f32>,
    source: S,
    start_sample: usize,
    gain_left: f32,
    gain_right: f32,
    limit_samples: Option<usize>,
) {
    for (offset, sample) in source.enumerate() {
        let absolute = start_sample + offset;
        if let Some(limit) = limit_samples
            && absolute >= limit
        {
            break;
        }
        let buffer_index = absolute * 2;
        if buffer_index + 1 >= buffer.len() {
            buffer.resize(buffer_index + 2, 0.0);
        }
        buffer[buffer_index] += sample * gain_left;
        buffer[buffer_index + 1] += sample * gain_right;
    }
}

fn pan_gains(pan: f32) -> (f32, f32) {
    let clamped = pan.clamp(-1.0, 1.0);
    let angle = (clamped + 1.0) * std::f32::consts::FRAC_PI_4;
    (angle.cos(), angle.sin())
}

fn resolve_path(base: Option<&Path>, file: &str) -> PathBuf {
    let candidate = PathBuf::from(file);
    if candidate.is_absolute() {
        return candidate;
    }
    match base {
        Some(base) => base.join(file),
        None => candidate,
    }
}

#[cfg(feature = "playback")]
/// Canonical key for a song path in a [`SongGraph`], matching how the graph
/// keys its songs (URLs verbatim, local paths canonicalized).
fn graph_key(path: &Path) -> PathBuf {
    if path.to_string_lossy().contains("://") {
        path.to_path_buf()
    } else {
        path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
    }
}

#[cfg(feature = "playback")]
/// The last subtitle at or before `beat`, unless it is a clear marker (empty
/// text). `subtitles` must be sorted ascending by beat.
fn active_subtitle(subtitles: &[Subtitle], beat: f32) -> Option<&str> {
    subtitles
        .iter()
        .rev()
        .find(|subtitle| subtitle.beat <= beat)
        .and_then(|subtitle| (!subtitle.text.is_empty()).then_some(subtitle.text.as_str()))
}

#[cfg(feature = "playback")]
/// The loop-back beat for a song whose sole jump is an unconditional jump to
/// itself. Such a song loops gaplessly in the mixer, so playback never
/// re-renders or swaps the sink to follow it.
fn self_loop_skip(song: &Song, key: &Path) -> Option<f32> {
    let [jump] = song.jumps.as_slice() else {
        return None;
    };
    if jump.condition.is_some() || jump.skip >= song.length() {
        return None;
    }
    let target = super::format::resolve_import_path(key.parent(), &jump.target);
    (graph_key(&target) == graph_key(key)).then_some(jump.skip)
}

// The bulk of these tests drive a SongPlayer; running them only with playback
// keeps the cfg noise out (default features include playback anyway).
#[cfg(all(test, feature = "playback"))]
mod tests {
    #![allow(clippy::unwrap_used)]
    use super::{SongGraph, SongPlayer};
    use crate::system::SoundSystem;
    use std::{io::Write, time::Duration};

    fn write_track(dir: &std::path::Path, name: &str, body: &str) -> std::path::PathBuf {
        let path = dir.join(name);
        std::fs::File::create(&path)
            .unwrap()
            .write_all(body.as_bytes())
            .unwrap();
        path
    }

    // The wasm build renders layers sequentially (no threads); prove it matches
    // the native threaded path so the two never drift.
    #[test]
    fn sequential_layer_render_matches_threaded() {
        use super::{MuteSolo, render_layers_sequential, render_layers_threaded};
        use crate::track::parse;
        let song = parse(
            "# Sounds\nlead: hard_square 0.5\nbell: sine 1\n# Layers\n\n\
             ## A\n@effect lowpass 800\nlead 0 0 1\nlead 1 4 1\n\n\
             ## B\nbell 0 7 2\n\n## C\n@mute true\nlead 0 2 1\n",
        )
        .unwrap();
        let tempo = song.tempo_map();
        for mode in [MuteSolo::Ignore, MuteSolo::Honor] {
            let threaded = render_layers_threaded(&song, None, tempo, mode).unwrap();
            let sequential = render_layers_sequential(&song, None, tempo, mode).unwrap();
            assert_eq!(threaded, sequential, "mismatch for {mode:?}");
        }
    }

    #[test]
    fn sample_oneshot_requires_a_sampler_sound() {
        use super::sample_oneshot;
        use crate::track::parse;
        let song = parse("# Sounds\nblip: sine 1\n").unwrap();
        // A synth sound is not a sampler.
        assert!(sample_oneshot(&song, "blip", 0.0, None, 0).is_err());
        // An unknown, non-default-instrument name errors too.
        assert!(sample_oneshot(&song, "ghost", 0.0, None, 0).is_err());
    }

    #[test]
    fn sound_oneshot_plays_and_varies_with_seed() {
        use super::sound_oneshot;
        use crate::track::parse;
        use std::time::Duration;

        let song = parse("# Sounds\nblip: sine 1, detune 1.0\n").unwrap();
        let render = |seed| {
            sound_oneshot(&song, "blip", 0.0, Duration::from_millis(100), seed)
                .unwrap()
                .collect::<Vec<f32>>()
        };
        let first = render(1);
        assert!(first.iter().any(|sample| sample.abs() > 0.01));
        // A different seed detunes differently, so the waveforms diverge.
        assert_ne!(first, render(2));
        // Same seed is reproducible.
        assert_eq!(first, render(1));
        // A pure sampler (or unknown) sound is not a valid one-shot.
        assert!(sound_oneshot(&song, "ghost", 0.0, Duration::from_millis(10), 0).is_err());
    }

    #[test]
    fn volume_binding_evaluates_param_scale_and_floor() {
        use super::{VolumeBinding, eval_volume_binding};
        use std::collections::BTreeMap;

        let mut defaults = BTreeMap::new();
        defaults.insert("combat".to_string(), 0.25);
        let mut params = BTreeMap::new();
        let binding = VolumeBinding {
            param: "combat".to_string(),
            scale: 2.0,
        };
        // No host value yet → falls back to the @param default.
        assert_eq!(eval_volume_binding(&binding, &params, &defaults), 0.5);
        // Host override wins over the default.
        params.insert("combat".to_string(), 0.4);
        assert_eq!(eval_volume_binding(&binding, &params, &defaults), 0.8);
        // Negative products are floored at silence.
        params.insert("combat".to_string(), -1.0);
        assert_eq!(eval_volume_binding(&binding, &params, &defaults), 0.0);
    }

    #[test]
    fn output_latency_defaults_zero_and_roundtrips() {
        let mut player = SongPlayer::new();
        assert_eq!(player.output_latency(), Duration::ZERO);
        assert_eq!(player.position(), Duration::ZERO);
        player.set_output_latency(Duration::from_millis(80));
        assert_eq!(player.output_latency(), Duration::from_millis(80));
        // Idle player still reports zero (no underflow past the raw position).
        assert_eq!(player.position(), Duration::ZERO);
    }

    #[test]
    fn graph_playback_follows_jump_after_song_length() {
        let system = SoundSystem::new();
        if !system.is_active() {
            eprintln!("no audio device; skipping graph playback test");
            return;
        }
        let dir = std::env::temp_dir().join("game-audio-player-jump");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        // Very short intro (length 0.05 beats @ 6000 bpm ≈ 0.5ms) that jumps to loop.
        write_track(
            &dir,
            "loop.track",
            "@tempo 6000\n# Sounds\nbell: sine 1\n# Layers\n\n## A\n@loop 1\nbell 0 0 1\n",
        );
        let entry = write_track(
            &dir,
            "intro.track",
            "@tempo 6000\n@length 0.05\n@jump loop.track\n# Sounds\nbell: sine 1\n# Layers\n\n## A\nbell 0 0 1\n",
        );

        let graph = SongGraph::load(&entry, crate::track::Refresh::Cached).unwrap();
        let loop_key = graph
            .songs
            .keys()
            .find(|key| key.ends_with("loop.track"))
            .unwrap()
            .clone();

        let mut player = SongPlayer::new();
        player.play_graph(&system, graph).unwrap();
        assert!(player.follows_jump());
        // Before the jump the current song is the entry.
        assert_eq!(player.current_song(), Some(entry.as_path()));
        // Beat position within the current graph song is available.
        assert!(player.current_song_beat().is_some());

        // Wait past the intro length, then update should switch to the loop song.
        std::thread::sleep(Duration::from_millis(20));
        player.update(&system).unwrap();
        let current = player.graph_playback.as_ref().unwrap().current.clone();
        assert_eq!(current, loop_key);
        // The accessor now reports the jumped-to song, not the entry.
        assert_eq!(player.current_song(), Some(loop_key.as_path()));
    }

    #[test]
    fn current_song_beat_advances_past_a_short_first_layer() {
        let system = SoundSystem::new();
        if !system.is_active() {
            eprintln!("no audio device; skipping beat position test");
            return;
        }
        let dir = std::env::temp_dir().join("game-audio-player-beat");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        // The first layer ends almost immediately while the song keeps going;
        // the reported beat must follow the jump clock, not the first sink. The
        // entry jumps to a distinct song, so the jump clock stays in play.
        let entry = write_track(
            &dir,
            "song.track",
            "@tempo 6000\n@length 1000\n@jump loop.track\n# Sounds\nbell: sine 1\n# Layers\n\n## Short\nbell 0 0 0.01\n# Layers\n\n## Long\nbell 0 0 900\n",
        );
        write_track(
            &dir,
            "loop.track",
            "@tempo 6000\n# Sounds\nbell: sine 1\n# Layers\n\n## L\n@loop 1\nbell 0 0 1\n",
        );

        let mut player = SongPlayer::new();
        player.play_graph_path(&system, &entry).unwrap();
        std::thread::sleep(Duration::from_millis(50));
        let beat = player.current_song_beat().unwrap();
        assert!(beat > 1.0, "beat position froze at {beat}");
        assert!(beat < 1000.0);
    }

    #[test]
    fn self_loop_plays_gaplessly_without_scheduling_a_jump() {
        let system = SoundSystem::new();
        if !system.is_active() {
            eprintln!("no audio device; skipping self-loop test");
            return;
        }
        let dir = std::env::temp_dir().join("game-audio-self-loop");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        // Its sole jump is unconditional and points back to itself, so the mix
        // loops in place — no wall-clock jump, no periodic re-render or sink swap.
        let entry = write_track(
            &dir,
            "loop.track",
            "@tempo 6000\n@length 4\n@jump loop.track\n# Sounds\nbell: sine 1\n# Layers\n\n## L\nbell 0 0 4\n",
        );

        let mut player = SongPlayer::new();
        player.play_graph_path(&system, &entry).unwrap();
        assert!(player.graph_playback.as_ref().unwrap().jump_at.is_none());
    }

    #[test]
    fn active_subtitle_picks_current_line_and_clears() {
        use super::active_subtitle;
        use crate::track::Subtitle;
        let subtitles = vec![
            Subtitle {
                beat: 0.0,
                text: "first".to_string(),
            },
            Subtitle {
                beat: 4.0,
                text: "second".to_string(),
            },
            Subtitle {
                beat: 8.0,
                text: String::new(),
            },
        ];
        assert_eq!(active_subtitle(&subtitles, -1.0), None);
        assert_eq!(active_subtitle(&subtitles, 0.0), Some("first"));
        assert_eq!(active_subtitle(&subtitles, 3.9), Some("first"));
        assert_eq!(active_subtitle(&subtitles, 4.0), Some("second"));
        assert_eq!(active_subtitle(&subtitles, 9.0), None);
    }

    #[test]
    fn wave_oscillator_renders_a_toned_buffer() {
        use super::wave_oscillator;
        use crate::track::parse;
        let song = parse("# Waves\n\n## smooth\n\n0 0\n0.25 1\n0.75 -1\n").unwrap();
        let osc = wave_oscillator(&song, "smooth", 220.0, Duration::from_millis(100)).unwrap();
        let samples: Vec<f32> = osc.collect();
        assert_eq!(samples.len(), 4410);
        let mid = samples[2000..2400]
            .iter()
            .fold(0.0f32, |m, s| m.max(s.abs()));
        assert!(mid > 0.1, "oscillator body is silent (peak {mid})");
        assert!(samples[0].abs() < 1e-3, "start should fade in click-free");
        // Built-in waveform names work with an empty song; unknown names error.
        assert!(wave_oscillator(&song, "sine", 110.0, Duration::from_millis(10)).is_ok());
        assert!(wave_oscillator(&song, "nope", 220.0, Duration::from_millis(10)).is_err());
    }

    fn conditional_graph(dir_name: &str) -> (std::path::PathBuf, std::path::PathBuf) {
        let dir = std::env::temp_dir().join(dir_name);
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        write_track(
            &dir,
            "action.track",
            "@tempo 6000\n# Sounds\nb: sine 1\n# Layers\n\n## A\n@loop 1\nb 0 0 1\n",
        );
        write_track(
            &dir,
            "calm.track",
            "@tempo 6000\n# Sounds\nb: sine 1\n# Layers\n\n## A\n@loop 1\nb 0 0 1\n",
        );
        let entry = write_track(
            &dir,
            "intro.track",
            "@tempo 6000\n@length 0.05\n@param combat 0\n@jump action.track if combat > 0\n@jump calm.track\n# Sounds\nb: sine 1\n# Layers\n\n## A\nb 0 0 1\n",
        );
        (entry, dir)
    }

    fn key_ending(player: &SongPlayer, suffix: &str) -> bool {
        player
            .graph_playback
            .as_ref()
            .unwrap()
            .current
            .ends_with(suffix)
    }

    #[test]
    fn conditional_jump_takes_fallback_when_param_unset() {
        let system = SoundSystem::new();
        if !system.is_active() {
            eprintln!("no audio device; skipping conditional jump test");
            return;
        }
        let (entry, _dir) = conditional_graph("game-audio-cond-fallback");
        let graph = SongGraph::load(&entry, crate::track::Refresh::Cached).unwrap();
        let mut player = SongPlayer::new();
        player.play_graph(&system, graph).unwrap();
        std::thread::sleep(Duration::from_millis(20));
        player.update(&system).unwrap();
        // combat defaults to 0, so `combat > 0` fails → fallback to calm.
        assert!(key_ending(&player, "calm.track"));
    }

    #[test]
    fn conditional_jump_follows_condition_when_param_set() {
        let system = SoundSystem::new();
        if !system.is_active() {
            eprintln!("no audio device; skipping conditional jump test");
            return;
        }
        let (entry, _dir) = conditional_graph("game-audio-cond-action");
        let graph = SongGraph::load(&entry, crate::track::Refresh::Cached).unwrap();
        let mut player = SongPlayer::new();
        player.play_graph(&system, graph).unwrap();
        player.set_parameter("combat", 1.0);
        std::thread::sleep(Duration::from_millis(20));
        player.update(&system).unwrap();
        // combat = 1 makes `combat > 0` hold → first jump wins → action.
        assert!(key_ending(&player, "action.track"));
    }
}
