use super::{
    format::{ParseError, parse, resolve_imports},
    graph::SongGraph,
    song::{
        Condition, ConditionSource, Effect, Layer, Note, Refresh, Song, Tuning,
        pitch_to_frequency_in,
    },
    synth::Voice,
};
use crate::system::SoundSystem;
use rodio::{
    ChannelCount, Decoder, Player, SampleRate, Source,
    buffer::SamplesBuffer,
    source::{SeekError, UniformSourceIterator},
};
use std::{
    collections::BTreeMap,
    fs::File,
    io::BufReader,
    num::NonZero,
    path::{Path, PathBuf},
    time::{Duration, Instant},
};

const SAMPLE_RATE: u32 = 44100;
const MONO: ChannelCount = NonZero::new(1).unwrap();
const STEREO: ChannelCount = NonZero::new(2).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),
    /// 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),
}

struct LayerSink {
    player: Player,
    base_volume: f32,
    loop_duration: Option<Duration>,
}

/// 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>,
    /// Host-set parameter overrides, layered over each song's `@param` defaults.
    params: BTreeMap<String, f32>,
    /// How many times each song has finished, keyed by its path.
    plays: BTreeMap<PathBuf, u32>,
    /// Running state for the random condition source.
    rng: u32,
}

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,
        defaults: &BTreeMap<String, f32>,
    ) -> bool {
        let left = match &condition.source {
            ConditionSource::Plays => plays as f32,
            ConditionSource::Random => self.next_random(),
            ConditionSource::Param(name) => self
                .params
                .get(name)
                .or_else(|| defaults.get(name))
                .copied()
                .unwrap_or(0.0),
        };
        condition.compare.test(left, condition.value)
    }
}

/// Plays a [`Song`] through a [`SoundSystem`].
///
/// Each layer is pre-mixed into a stereo buffer and submitted to its own
/// sink. Looping layers repeat from their loop point. The player keeps the
/// sinks alive until [`SongPlayer::stop`] or another `play` call.
///
/// For multi-song playback, [`SongPlayer::play_graph`] follows `@jump`
/// directives; call [`SongPlayer::update`] periodically to advance jumps.
pub struct SongPlayer {
    layer_sinks: Vec<LayerSink>,
    master_volume: f32,
    graph_playback: Option<GraphPlayback>,
}

impl Default for SongPlayer {
    fn default() -> Self {
        Self {
            layer_sinks: Vec::new(),
            master_volume: 1.0,
            graph_playback: None,
        }
    }
}

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.graph_playback = None;
        self.start_song(system, song, base_directory, offset_beats)
    }

    /// Builds and starts the per-layer sinks for one song (no graph state).
    fn start_song(
        &mut self,
        system: &SoundSystem,
        song: &Song,
        base_directory: Option<&Path>,
        offset_beats: f32,
    ) -> Result<(), PlayerError> {
        for sink in &self.layer_sinks {
            sink.player.stop();
        }
        self.layer_sinks.clear();
        let beat_duration = 60.0 / song.tempo.max(1.0);
        let any_solo = song.layers.iter().any(|layer| layer.solo);
        let offset_seconds = (offset_beats * beat_duration).max(0.0);

        for layer in &song.layers {
            if layer.muted {
                continue;
            }
            if any_solo && !layer.solo {
                continue;
            }

            let mut buffer = render_layer(layer, song, base_directory, beat_duration)?;
            if buffer.is_empty() {
                continue;
            }
            apply_effects(&mut buffer, &layer.effects, SAMPLE_RATE);

            let sink = system.create_sink().ok_or(PlayerError::SinkCreate)?;
            sink.set_volume(layer.volume * self.master_volume);

            if layer.loop_length.is_some() {
                let loop_start_frame =
                    (layer.loop_skip.max(0.0) * beat_duration * SAMPLE_RATE as f32) as usize;
                let source = LoopPoint::new(buffer, loop_start_frame);
                sink.append(source.skip_duration(Duration::from_secs_f32(offset_seconds)));
            } else {
                let source = SamplesBuffer::new(STEREO, RATE, buffer);
                sink.append(source.skip_duration(Duration::from_secs_f32(offset_seconds)));
            }

            sink.play();
            let loop_duration = layer
                .loop_length
                .map(|length| Duration::from_secs_f32(length * beat_duration));
            self.layer_sinks.push(LayerSink {
                player: sink,
                base_volume: layer.volume,
                loop_duration,
            });
        }
        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;
        let entry = graph.entry.clone();
        self.graph_playback = Some(GraphPlayback {
            graph,
            current: entry.clone(),
            jump_at: None,
            params: BTreeMap::new(),
            plays: BTreeMap::new(),
            rng: 0x2545_f491,
        });
        self.enter_song(system, &entry, 0.0)
    }

    /// Sets a playtime parameter used by conditional jumps. May be called
    /// before or during graph playback; the value persists across songs and
    /// overrides a song's `@param` default of the same name.
    pub fn set_parameter(&mut self, name: impl Into<String>, value: f32) {
        if let Some(playback) = &mut self.graph_playback {
            playback.params.insert(name.into(), value);
        }
    }

    /// Advances graph playback: if the current song has reached its length,
    /// follows its `@jump`. Does nothing for single-song playback. Returns an
    /// error if starting the next song fails.
    pub fn update(&mut self, system: &SoundSystem) -> Result<(), PlayerError> {
        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, &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 beat_duration = 60.0 / song.tempo.max(1.0);
        let remaining_beats = (song.length() - offset_beats).max(0.0);
        let jump_at = Instant::now() + Duration::from_secs_f32(remaining_beats * beat_duration);
        if let Some(playback) = &mut self.graph_playback {
            playback.jump_at = Some(jump_at);
        }
        self.start_song(system, &song, base.as_deref(), offset_beats)
    }

    /// Stops all per-layer sinks created by this player.
    pub fn stop(&mut self) {
        for sink in &self.layer_sinks {
            sink.player.stop();
        }
        self.layer_sinks.clear();
        self.graph_playback = None;
    }

    /// Returns true while at least one layer is still producing sound.
    ///
    /// Looping layers never finish, so this stays true until [`SongPlayer::stop`].
    pub fn is_playing(&self) -> bool {
        self.layer_sinks.iter().any(|sink| !sink.player.empty())
    }

    /// 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.
    pub fn current_song_beat(&self) -> Option<f32> {
        let playback = self.graph_playback.as_ref()?;
        let song = playback.graph.songs.get(&playback.current)?;
        let beat_duration = 60.0 / song.tempo.max(1.0);
        Some(self.position().as_secs_f32() / beat_duration)
    }

    /// 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.
    pub fn set_speed(&self, speed: f32) {
        for sink in &self.layer_sinks {
            sink.player.set_speed(speed);
        }
    }

    /// Pauses all layers, keeping their position.
    pub fn pause(&self) {
        for sink in &self.layer_sinks {
            sink.player.pause();
        }
    }

    /// Resumes all paused layers.
    pub fn resume(&self) {
        for sink in &self.layer_sinks {
            sink.player.play();
        }
    }

    /// Returns true if playback is currently paused.
    pub fn is_paused(&self) -> bool {
        self.layer_sinks
            .first()
            .is_some_and(|sink| sink.player.is_paused())
    }

    /// Sets a master volume multiplier applied on top of each layer's own
    /// volume (1.0 = unchanged).
    pub fn set_master_volume(&mut self, volume: f32) {
        self.master_volume = volume.max(0.0);
        for sink in &self.layer_sinks {
            sink.player
                .set_volume(sink.base_volume * self.master_volume);
        }
    }

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

    /// Returns the playback position of the first layer.
    ///
    /// For a looping layer the position wraps back to zero each loop, so it
    /// stays within the loop length rather than growing without bound.
    pub fn position(&self) -> Duration {
        self.layer_sinks.first().map_or(Duration::ZERO, |sink| {
            let position = sink.player.get_pos();
            match sink.loop_duration {
                Some(loop_duration) if !loop_duration.is_zero() => {
                    Duration::from_secs_f64(position.as_secs_f64() % loop_duration.as_secs_f64())
                }
                _ => position,
            }
        })
    }

    /// Seeks all layers to the given position from the start.
    ///
    /// Returns an error if any layer's source does not support seeking.
    pub fn seek(&self, position: Duration) -> Result<(), rodio::source::SeekError> {
        for sink in &self.layer_sinks {
            sink.player.try_seek(position)?;
        }
        Ok(())
    }
}

/// 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 a downstream [`LoopPoint`] produces seamless loops.
pub fn render_layer(
    layer: &Layer,
    song: &Song,
    base_directory: Option<&Path>,
    beat_duration: f32,
) -> 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| (length * beat_duration * SAMPLE_RATE as f32) as usize);

    for note in &layer.notes {
        render_note(
            song,
            &mut buffer,
            note,
            voicing,
            beat_duration,
            gain_left,
            gain_right,
            loop_length_samples,
        )?;
    }
    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);
            render_note(
                song,
                &mut buffer,
                &motif_note.resolve(instance, note_override),
                instance_voicing,
                beat_duration,
                gain_left,
                gain_right,
                loop_length_samples,
            )?;
        }
    }
    for clip in &layer.audio {
        let path = resolve_path(base_directory, &clip.file);
        let reader = File::open(&path).map_err(|error| PlayerError::AudioOpen {
            path: path.clone(),
            error,
        })?;
        let decoder =
            Decoder::new(BufReader::new(reader)).map_err(|error| PlayerError::AudioDecode {
                path: path.clone(),
                error,
            })?;
        let normalized = UniformSourceIterator::new(decoder, MONO, RATE);
        let start_sample = (clip.start * beat_duration * SAMPLE_RATE as f32) as usize;
        mix_mono_into_stereo(
            &mut buffer,
            normalized,
            start_sample,
            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)
}

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,
    beat_duration: f32,
    gain_left: f32,
    gain_right: f32,
    limit_samples: Option<usize>,
) -> Result<(), PlayerError> {
    let sound_def = song
        .sounds
        .get(&note.sound)
        .ok_or_else(|| PlayerError::UnknownSound(note.sound.clone()))?
        .clone();
    let start_sample = (note.start * beat_duration * SAMPLE_RATE as f32) as usize;
    let voice = Voice::ramped(
        sound_def,
        [
            pitch_to_frequency_in(voicing.tuning, note.pitch, voicing.root),
            pitch_to_frequency_in(voicing.tuning, note.pitch + note.glide, voicing.root),
        ],
        note.pitch_curve,
        [note.volume, note.volume + note.volume_ramp],
        note.volume_curve,
        Duration::from_secs_f32(note.duration * beat_duration),
    );
    mix_mono_into_stereo(
        buffer,
        voice,
        start_sample,
        gain_left,
        gain_right,
        limit_samples,
    );
    Ok(())
}

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,
    }
}

/// 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())
    }
}

/// A stereo buffer that plays once in full, then repeats endlessly from a
/// loop point (`loop_start_frame`). With `loop_start_frame == 0` this is a
/// plain infinite repeat; a positive value skips an intro on every repeat
/// after the first pass.
struct LoopPoint {
    buffer: Vec<f32>,
    loop_start_sample: usize,
    position: usize,
}

impl LoopPoint {
    fn new(buffer: Vec<f32>, loop_start_frame: usize) -> Self {
        let loop_start_sample = (loop_start_frame * 2).min(buffer.len());
        Self {
            buffer,
            loop_start_sample,
            position: 0,
        }
    }
}

impl Iterator for LoopPoint {
    type Item = f32;
    fn next(&mut self) -> Option<f32> {
        if self.buffer.is_empty() {
            return None;
        }
        if self.position >= self.buffer.len() {
            if self.loop_start_sample >= self.buffer.len() {
                return None;
            }
            self.position = self.loop_start_sample;
        }
        let sample = self.buffer[self.position];
        self.position += 1;
        Some(sample)
    }
}

impl Source for LoopPoint {
    fn current_span_len(&self) -> Option<usize> {
        None
    }
    fn channels(&self) -> ChannelCount {
        STEREO
    }
    fn sample_rate(&self) -> SampleRate {
        RATE
    }
    fn total_duration(&self) -> Option<Duration> {
        None
    }
    fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> {
        // Map the time to a stereo-frame sample index; a target past the buffer
        // wraps into the loop region so seeking a looping layer stays in sync.
        let frame = (position.as_secs_f64() * SAMPLE_RATE as f64) as usize;
        let mut sample = frame * 2;
        if sample >= self.buffer.len() && self.loop_start_sample < self.buffer.len() {
            let loop_length = self.buffer.len() - self.loop_start_sample;
            sample = self.loop_start_sample + (sample - self.buffer.len()) % loop_length;
        }
        self.position = sample.min(self.buffer.len());
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)]
    use super::{LoopPoint, SAMPLE_RATE};
    use rodio::Source;

    #[test]
    fn zero_skip_repeats_whole_buffer() {
        let source = LoopPoint::new(vec![1.0, 2.0, 3.0, 4.0], 0);
        let taken: Vec<f32> = source.take(10).collect();
        assert_eq!(
            taken,
            vec![1.0, 2.0, 3.0, 4.0, 1.0, 2.0, 3.0, 4.0, 1.0, 2.0]
        );
    }

    #[test]
    fn skip_loops_from_loop_point_after_first_pass() {
        // 2 stereo frames; loop point at frame 1 → skip frame 0 on repeats.
        let source = LoopPoint::new(vec![1.0, 1.0, 2.0, 2.0], 1);
        let taken: Vec<f32> = source.take(8).collect();
        // full first pass, then repeats from sample index 2 (frame 1).
        assert_eq!(taken, vec![1.0, 1.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0]);
    }

    #[test]
    fn loop_point_past_end_plays_once() {
        let source = LoopPoint::new(vec![1.0, 2.0], 5);
        let taken: Vec<f32> = source.take(10).collect();
        assert_eq!(taken, vec![1.0, 2.0]);
    }

    #[test]
    fn empty_buffer_yields_nothing() {
        let source = LoopPoint::new(vec![], 0);
        assert_eq!(source.take(4).count(), 0);
    }

    #[test]
    fn seek_lands_on_stereo_frame() {
        // One second of stereo samples (SAMPLE_RATE frames), no loop.
        let mut source = LoopPoint::new(vec![0.0; SAMPLE_RATE as usize * 2], SAMPLE_RATE as usize);
        source.try_seek(Duration::from_millis(500)).unwrap();
        // 0.5s → frame SAMPLE_RATE/2 → even sample index (left channel).
        assert_eq!(source.position, SAMPLE_RATE as usize);
        assert_eq!(source.position % 2, 0);
    }

    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
    }

    #[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# Layer 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# Layer 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()));
    }

    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# Layer A\n@loop 1\nb 0 0 1\n",
        );
        write_track(
            &dir,
            "calm.track",
            "@tempo 6000\n# Sounds\nb: sine 1\n# Layer 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# Layer 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"));
    }
}
