// Copyright (C) 2026 Michael Wilson <mike@mdwn.dev>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, version 3.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.
//
use std::fs::File;
use std::path::Path;

use symphonia::core::audio::sample::Sample;
use symphonia::core::audio::{Audio, AudioBuffer, GenericAudioBufferRef};
use symphonia::core::codecs::audio::well_known::{
    CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE, CODEC_ID_PCM_F64BE, CODEC_ID_PCM_F64LE,
};
use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::packet::Packet;
use symphonia::core::units::Timestamp;
use symphonia::default::{get_codecs, get_probe};

use super::error::SampleSourceError;
use super::traits::SampleSource;

#[cfg(test)]
use super::traits::SampleSourceTestExt;

/// A sample source that reads audio files (WAV, MP3, FLAC, etc.) and provides scaled samples
/// This uses symphonia to decode various audio formats - no transcoding logic
pub struct AudioSampleSource {
    format_reader: Box<dyn FormatReader>,
    decoder: Box<dyn AudioDecoder>,
    track_id: u32,
    is_finished: bool,
    // Buffered reading to reduce I/O operations
    sample_buffer: Vec<f32>,
    buffer_position: usize,
    buffer_size: usize,
    // Leftover samples from the last decoded packet that didn't fit in the buffer
    leftover_samples: Vec<f32>,
    // Interleaved samples to discard from the front of the decoded stream so the
    // first emitted sample lands exactly on a requested seek target. Computed
    // lazily from the first decoded packet's timestamp (see `refill_buffer` and
    // `pending_seek_target_ts`), because the seek lands at a codec packet
    // boundary at or before the target and the per-packet `ts()` is the only
    // reliable anchor across codecs.
    skip_samples: usize,
    // Set to the requested seek target (in frames) after an accurate seek, until
    // the first decoded packet lets us turn it into `skip_samples`. `None` when
    // no seek is pending.
    pending_seek_target_ts: Option<u64>,
    // WAV / PCM metadata for scaling & reporting
    bits_per_sample: u16,
    channels: u16,
    sample_rate: u32,
    sample_format: crate::audio::SampleFormat,
    duration: std::time::Duration,
}

impl SampleSource for AudioSampleSource {
    fn next_sample(&mut self) -> Result<Option<f32>, SampleSourceError> {
        if self.is_finished {
            return Ok(None);
        }

        // Check if we need to refill the buffer
        if self.buffer_position >= self.sample_buffer.len() {
            self.refill_buffer()?;

            // If buffer is still empty after refill, we're finished
            if self.sample_buffer.is_empty() {
                self.is_finished = true;
                return Ok(None);
            }
        }

        // Return the next sample from the buffer
        let sample = self.sample_buffer[self.buffer_position];
        self.buffer_position += 1;
        Ok(Some(sample))
    }

    fn channel_count(&self) -> u16 {
        self.channels
    }

    fn sample_rate(&self) -> u32 {
        self.sample_rate
    }

    fn bits_per_sample(&self) -> u16 {
        self.bits_per_sample
    }

    fn sample_format(&self) -> crate::audio::SampleFormat {
        self.sample_format
    }

    fn duration(&self) -> Option<std::time::Duration> {
        Some(self.duration)
    }
}

impl AudioSampleSource {
    /// Creates a new audio sample source from a file path
    /// Supports WAV, MP3, FLAC, and other formats supported by symphonia
    pub fn from_file<P: AsRef<Path>>(
        path: P,
        start_time: Option<std::time::Duration>,
        buffer_size: usize,
    ) -> Result<Self, SampleSourceError> {
        // Open the file (include path in error so user sees which file failed)
        let path_ref = path.as_ref();
        let file = File::open(path_ref).map_err(|e| {
            SampleSourceError::IoError(std::io::Error::new(
                e.kind(),
                format!("{}: {}", path_ref.display(), e),
            ))
        })?;
        let mss = MediaSourceStream::new(Box::new(file), Default::default());

        // Create a hint to help the format registry guess the format
        let mut hint = Hint::new();
        if let Some(extension) = path.as_ref().extension().and_then(|ext| ext.to_str()) {
            hint.with_extension(extension);
        }

        // Probe the format
        let meta_opts: MetadataOptions = Default::default();
        let fmt_opts: FormatOptions = Default::default();
        let probe = get_probe();
        let file_path = path.as_ref().to_string_lossy().to_string();
        let mut format_reader = probe.probe(&hint, mss, fmt_opts, meta_opts).map_err(|e| {
            SampleSourceError::SampleConversionFailed(format!("'{}': {}", file_path, e))
        })?;

        // Find the first audio track and build its decoder. We extract the scalar
        // metadata we need inside this block so the immutable borrow of
        // `format_reader` (via `tracks()`) is released before we use it mutably
        // for channel detection / seeking below. `make_audio_decoder` returns an
        // owned decoder, so it carries no borrow out of the block.
        let decoder_opts = AudioDecoderOptions::default();
        let (
            mut decoder,
            track_id,
            num_frames,
            sample_rate,
            bits_per_sample,
            channels_meta,
            codec_id,
        ) = {
            let track = format_reader
                .tracks()
                .iter()
                .find(|t| t.codec_params.as_ref().is_some_and(|p| p.audio().is_some()))
                .ok_or_else(|| {
                    SampleSourceError::SampleConversionFailed("No audio track found".to_string())
                })?;
            let audio = track
                .codec_params
                .as_ref()
                .and_then(|p| p.audio())
                .expect("track filtered to audio above");
            let sample_rate = audio.sample_rate.ok_or_else(|| {
                SampleSourceError::SampleConversionFailed("Sample rate not specified".to_string())
            })?;
            // Default to 16-bit if not specified.
            let bits_per_sample = audio.bits_per_sample.unwrap_or(16) as u16;
            let channels_meta = audio
                .channels
                .as_ref()
                .map(|c| c.count() as u16)
                .unwrap_or(0);
            let codec_id = audio.codec;
            let decoder = get_codecs()
                .make_audio_decoder(audio, &decoder_opts)
                .map_err(|e| {
                    SampleSourceError::SampleConversionFailed(format!("'{}': {}", file_path, e))
                })?;
            (
                decoder,
                track.id,
                track.num_frames,
                sample_rate,
                bits_per_sample,
                channels_meta,
                codec_id,
            )
        };

        // Determine sample format from codec
        let sample_format = if codec_id == CODEC_ID_PCM_F32LE
            || codec_id == CODEC_ID_PCM_F32BE
            || codec_id == CODEC_ID_PCM_F64LE
            || codec_id == CODEC_ID_PCM_F64BE
        {
            crate::audio::SampleFormat::Float
        } else {
            crate::audio::SampleFormat::Int
        };

        // Calculate duration
        let duration = if let Some(n_frames) = num_frames {
            std::time::Duration::from_secs_f64(n_frames as f64 / sample_rate as f64)
        } else {
            // If duration is unknown, we'll set it to zero and update it as we read
            std::time::Duration::ZERO
        };

        // Determine channels. Prefer container/codec metadata, but if it's
        // missing we proactively decode the first audio packet to derive the
        // actual channel count. If we still can't determine it, we fail.
        //
        // Here, a value of 0 means "unspecified" and is only used inside this
        // constructor; if it remains 0 after probing, we return an error and
        // never construct an AudioSampleSource with channel_count == 0.
        let channels = channels_meta;

        // In tests we sometimes want to exercise the channel‑detection path
        // even for formats where the container/codec already reports the
        // channel count. This is controlled by an env var so production
        // behaviour is unaffected.
        let force_detect = cfg!(test) && std::env::var("MTRACK_FORCE_DETECT_CHANNELS").is_ok();

        let (channels, initial_leftover) = if channels > 0 && !force_detect {
            (channels, Vec::new())
        } else {
            Self::detect_channels_and_prime_buffer(
                format_reader.as_mut(),
                decoder.as_mut(),
                track_id,
            )?
        };

        let mut source = Self {
            format_reader,
            decoder,
            track_id,
            is_finished: false,
            sample_buffer: Vec::with_capacity(buffer_size * channels as usize),
            buffer_position: 0,
            buffer_size,
            leftover_samples: initial_leftover,
            skip_samples: 0,
            pending_seek_target_ts: None,
            bits_per_sample,
            channels,
            sample_rate,
            sample_format,
            duration,
        };

        // If start_time is provided, seek to that position
        if let Some(start) = start_time {
            // Any samples decoded while probing for channels belong to the
            // beginning of the stream. If the caller requested a non‑zero
            // start time, those samples are no longer relevant and must not
            // be returned ahead of the seek target.
            source.leftover_samples.clear();

            // Target frame from the requested time (track timebase == frames).
            let target_ts = (start.as_secs_f64() * sample_rate as f64).round() as u64;

            // Seek a margin *before* the target so the codec decoder has
            // preceding frames to rebuild its state. Lossy codecs (MP3 bit
            // reservoir, MP3/AAC/Vorbis MDCT overlap-add) otherwise emit a
            // corrupted region right after a seek — a few ms for MP3/AAC, and a
            // ~6ms apparent position shift for Vorbis from its overlap lapping.
            // Decoding through the pre-roll and discarding it warms the decoder
            // so the first emitted sample is clean and exactly at the target.
            // For lossless codecs (FLAC/WAV) this just decodes a little extra
            // and discards it — no correctness effect.
            const SEEK_PREROLL_FRAMES: u64 = 4096;
            let preroll_ts = target_ts.saturating_sub(SEEK_PREROLL_FRAMES);

            let seek_to = SeekTo::Timestamp {
                ts: Timestamp::new(preroll_ts as i64),
                track_id,
            };
            source.format_reader.seek(SeekMode::Accurate, seek_to)?;

            // Defer computing how much to discard until the first packet is
            // actually decoded: anchor the skip to that packet's `ts()` rather
            // than to the seek's reported `actual_ts`. The seek lands at a packet
            // boundary at or before the target, but `actual_ts` is not a reliable
            // anchor for every codec — Vorbis precedes its first output packet
            // with an empty overlap-lapping packet, so `actual_ts` points before
            // where decoded samples actually begin. `packet.ts()` of the first
            // packet that yields output is exact for all codecs. The decode
            // helper already skips the empty lapping packet, so the first
            // returned packet's ts is the true position of its first sample;
            // discard `target - that_ts` frames to land exactly on `start`.
            source.pending_seek_target_ts = Some(target_ts);
        }

        Ok(source)
    }

    /// Helper function to read the next packet with common error handling.
    /// Returns:
    /// - `Ok(Some(packet))` if a packet was successfully read
    /// - `Ok(None)` if EOF was reached (UnexpectedEof or DecodeError)
    /// - `Err(...)` if an error occurred that should be returned
    ///
    /// Note: ResetRequired errors are propagated to callers so they can reset the decoder.
    fn read_next_packet(
        format_reader: &mut dyn FormatReader,
    ) -> Result<Option<Packet>, SampleSourceError> {
        match format_reader.next_packet() {
            // 0.6: next_packet() returns Ok(None) at end of stream.
            Ok(packet) => Ok(packet),
            Err(SymphoniaError::ResetRequired) => {
                // ResetRequired is propagated to callers so they can reset the decoder
                Err(SampleSourceError::AudioError(SymphoniaError::ResetRequired))
            }
            Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
                // End of file - we're done reading
                Ok(None)
            }
            Err(SymphoniaError::DecodeError(_)) => {
                // Some decoders return DecodeError at EOF instead of IoError
                Ok(None)
            }
            Err(e) => Err(SampleSourceError::AudioError(e)),
        }
    }

    /// Reads and decodes the next packet for the given track. Handles ResetRequired by
    /// resetting the decoder and retrying. Returns `Ok(Some((samples, channels)))` when
    /// a packet was decoded, `Ok(None)` on EOF, or `Err` on other errors.
    /// Reads and decodes the next packet for the track that yields output.
    /// Returns the decoded interleaved samples, the channel count, and the
    /// packet's timestamp (`ts`, in frames) — the content position of the first
    /// returned sample. Empty packets (e.g. a Vorbis overlap-lapping packet) are
    /// skipped, so the returned `ts` always corresponds to real output.
    fn read_and_decode_next_packet_for_track(
        format_reader: &mut dyn FormatReader,
        decoder: &mut dyn AudioDecoder,
        track_id: u32,
    ) -> Result<Option<(Vec<f32>, usize, u64)>, SampleSourceError> {
        loop {
            let packet = match Self::read_next_packet(format_reader) {
                Ok(Some(packet)) => packet,
                Ok(None) => return Ok(None),
                Err(SampleSourceError::AudioError(SymphoniaError::ResetRequired)) => {
                    decoder.reset();
                    continue;
                }
                Err(e) => return Err(e),
            };
            if packet.track_id != track_id {
                continue;
            }
            // `pts` is the presentation timestamp in frames (Timestamp wraps i64).
            let packet_ts = packet.pts.get().max(0) as u64;
            let decoded = match decoder.decode(&packet) {
                Ok(decoded) => decoded,
                Err(SymphoniaError::ResetRequired) => {
                    decoder.reset();
                    match decoder.decode(&packet) {
                        Ok(decoded) => decoded,
                        Err(e) => return Err(SampleSourceError::AudioError(e)),
                    }
                }
                Err(e) => return Err(SampleSourceError::AudioError(e)),
            };
            let (samples, channels) = Self::decode_buffer_to_f32(decoded)?;
            if channels > 0 && !samples.is_empty() {
                return Ok(Some((samples, channels, packet_ts)));
            }
        }
    }

    /// Reads up to `buf.len()` interleaved samples into `buf`, returning
    /// the number of samples actually written. Returns 0 at EOF.
    /// This avoids per-sample virtual dispatch by copying directly from
    /// the internal decode buffer.
    pub fn read_samples(&mut self, buf: &mut [f32]) -> Result<usize, SampleSourceError> {
        if self.is_finished || buf.is_empty() {
            return Ok(0);
        }

        let mut written = 0;
        while written < buf.len() {
            // Drain what's available in the current buffer
            let available = self.sample_buffer.len() - self.buffer_position;
            if available > 0 {
                let to_copy = available.min(buf.len() - written);
                buf[written..written + to_copy].copy_from_slice(
                    &self.sample_buffer[self.buffer_position..self.buffer_position + to_copy],
                );
                self.buffer_position += to_copy;
                written += to_copy;
            } else {
                // Need more data
                self.refill_buffer()?;
                if self.sample_buffer.is_empty() {
                    self.is_finished = true;
                    break;
                }
            }
        }
        Ok(written)
    }

    /// Refills the sample buffer by reading a chunk from the audio file
    fn refill_buffer(&mut self) -> Result<(), SampleSourceError> {
        // Clear the buffer and reset position
        self.sample_buffer.clear();
        self.buffer_position = 0;

        let mut samples_read = 0;
        let target_samples = self.buffer_size * self.channels as usize;
        // First, add any leftover samples from the previous buffer fill
        if !self.leftover_samples.is_empty() {
            let to_take = target_samples.min(self.leftover_samples.len());
            self.sample_buffer
                .extend_from_slice(&self.leftover_samples[..to_take]);
            samples_read += to_take;

            // Keep the rest as leftover for next time
            if self.leftover_samples.len() > to_take {
                self.leftover_samples.drain(..to_take);
            } else {
                self.leftover_samples.clear();
            }
            // If leftover samples completely filled the buffer, we're done for this iteration
            if samples_read >= target_samples {
                return Ok(());
            }
        }

        // Read packets until we reach the end of the file or fill our buffer.
        //
        // NOTE: We intentionally *do not* special‑case "no progress" here based on
        // samples_read. Some formats (e.g. Ogg/Vorbis) have multiple header packets
        // that decode to zero PCM frames before the first audio packet. Treating
        // those as "no progress" would cause us to bail out early and never see
        // the real audio data.
        loop {
            let (mut samples, _decoded_channels, packet_ts) =
                match Self::read_and_decode_next_packet_for_track(
                    self.format_reader.as_mut(),
                    self.decoder.as_mut(),
                    self.track_id,
                ) {
                    Ok(Some((samples, ch, ts))) => (samples, ch, ts),
                    Ok(None) => break,
                    Err(e) => {
                        // For very small files, some errors might indicate EOF
                        if samples_read == 0 && self.sample_buffer.is_empty() {
                            break;
                        }
                        return Err(e);
                    }
                };

            // First packet after a seek: anchor the skip to this packet's actual
            // timestamp (the true content position of its first sample) rather
            // than the seek's reported landing point, so the first emitted sample
            // is exactly at the requested target across all codecs.
            if let Some(target_ts) = self.pending_seek_target_ts.take() {
                let skip_frames = target_ts.saturating_sub(packet_ts);
                self.skip_samples = skip_frames as usize * self.channels as usize;
            }

            // Discard the residual between the packet's first sample and the
            // requested seek target.
            if self.skip_samples > 0 {
                let drop = self.skip_samples.min(samples.len());
                samples.drain(..drop);
                self.skip_samples -= drop;
                // Whole packet consumed by the skip — read the next one.
                if samples.is_empty() {
                    continue;
                }
            }

            // Add samples to the buffer
            if !samples.is_empty() {
                let remaining = target_samples.saturating_sub(samples_read);
                let to_take = remaining.min(samples.len());
                self.sample_buffer.extend_from_slice(&samples[..to_take]);
                samples_read += to_take;

                // If we have more samples than we can fit, save them as leftover
                if samples.len() > to_take {
                    self.leftover_samples.extend_from_slice(&samples[to_take..]);
                    // Buffer is full for this iteration, break to avoid infinite loops
                    // Leftover samples will be used in the next refill_buffer call
                    break;
                }

                // For very small files, if we got a small number of samples (less than 32 total),
                // the file is likely exhausted. Break immediately to avoid calling next_packet() again
                // which might block indefinitely. This handles edge cases with tiny files
                // (like the test file with only 3 samples).
                // We use a fixed threshold (32) rather than channels-based to catch all tiny files.
                // This is critical for preventing hangs on very small audio files.
                if samples.len() < 32 {
                    break;
                }
            }

            // If we've filled our target buffer, break for this iteration
            // This prevents infinite loops while still allowing us to read all samples
            // across multiple refill_buffer calls
            if samples_read >= target_samples {
                break;
            }
        }

        // If we read no samples and have no leftovers, we're at the end of the file
        if samples_read == 0 && self.leftover_samples.is_empty() {
            self.is_finished = true;
        }

        Ok(())
    }

    /// When codec/channel metadata is missing, read and decode packets until we
    /// see the first audio buffer for our track, and derive the channel count
    /// from that buffer. The decoded samples are returned so they can be used
    /// as the initial contents of the sample buffer.
    fn detect_channels_and_prime_buffer(
        format_reader: &mut dyn FormatReader,
        decoder: &mut dyn AudioDecoder,
        track_id: u32,
    ) -> Result<(u16, Vec<f32>), SampleSourceError> {
        match Self::read_and_decode_next_packet_for_track(format_reader, decoder, track_id)? {
            Some((samples, channels, _ts)) => Ok((channels as u16, samples)),
            None => Err(SampleSourceError::SampleConversionFailed(
                "Channels not specified".to_string(),
            )),
        }
    }

    /// Converts a decoded buffer to a Vec<f32> of interleaved samples and returns
    /// the channel count as observed in the decoded buffer.
    #[cfg_attr(test, allow(dead_code))]
    pub(crate) fn decode_buffer_to_f32(
        decoded: GenericAudioBufferRef,
    ) -> Result<(Vec<f32>, usize), SampleSourceError> {
        match decoded {
            GenericAudioBufferRef::F32(buf) => {
                Ok(Self::interleave_planar_samples(buf, |sample| sample))
            }
            GenericAudioBufferRef::F64(buf) => {
                Ok(Self::interleave_planar_samples(buf, |sample| sample as f32))
            }
            GenericAudioBufferRef::S8(buf) => {
                Ok(Self::interleave_planar_samples(buf, Self::scale_s8))
            }
            GenericAudioBufferRef::S16(buf) => {
                Ok(Self::interleave_planar_samples(buf, Self::scale_s16))
            }
            GenericAudioBufferRef::S24(buf) => Ok(Self::interleave_planar_samples(buf, |sample| {
                Self::scale_s24(sample.inner())
            })),
            GenericAudioBufferRef::S32(buf) => {
                Ok(Self::interleave_planar_samples(buf, Self::scale_s32))
            }
            GenericAudioBufferRef::U8(buf) => {
                Ok(Self::interleave_planar_samples(buf, Self::scale_u8))
            }
            GenericAudioBufferRef::U16(buf) => {
                Ok(Self::interleave_planar_samples(buf, Self::scale_u16))
            }
            GenericAudioBufferRef::U24(buf) => Ok(Self::interleave_planar_samples(buf, |sample| {
                Self::scale_u24(sample.inner())
            })),
            GenericAudioBufferRef::U32(buf) => {
                Ok(Self::interleave_planar_samples(buf, Self::scale_u32))
            }
        }
    }

    /// Helper to interleave planar samples from a generic AudioBuffer.
    /// The closure receives a single sample value and returns the f32 sample value.
    fn interleave_planar_samples<T, F>(buf: &AudioBuffer<T>, convert: F) -> (Vec<f32>, usize)
    where
        T: Sample,
        F: Fn(T) -> f32,
    {
        let frames = buf.frames();
        let channels = buf.spec().channels().count();
        // Collect the per-channel planes once; `plane(ch)` returns the whole
        // channel slice (planar layout).
        let planes: Vec<&[T]> = (0..channels).filter_map(|ch| buf.plane(ch)).collect();
        let mut samples = Vec::with_capacity(frames * channels);
        for frame_idx in 0..frames {
            for plane in &planes {
                samples.push(convert(plane[frame_idx]));
            }
        }
        (samples, channels)
    }

    // Scaling helpers for all integer formats. These are `pub(crate)` so they can
    // be validated directly in unit tests.

    #[inline]
    pub(crate) fn scale_s8(sample: i8) -> f32 {
        sample as f32 / (1i64 << 7) as f32
    }

    #[inline]
    pub(crate) fn scale_s16(sample: i16) -> f32 {
        sample as f32 / (1i64 << 15) as f32
    }

    #[inline]
    pub(crate) fn scale_s24(sample: i32) -> f32 {
        sample as f32 / (1i64 << 23) as f32
    }

    #[inline]
    pub(crate) fn scale_s32(sample: i32) -> f32 {
        sample as f32 / (1i64 << 31) as f32
    }

    #[inline]
    pub(crate) fn scale_u8(sample: u8) -> f32 {
        (sample as f32 / u8::MAX as f32) * 2.0 - 1.0
    }

    #[inline]
    pub(crate) fn scale_u16(sample: u16) -> f32 {
        (sample as f32 / u16::MAX as f32) * 2.0 - 1.0
    }

    #[inline]
    pub(crate) fn scale_u24(sample: u32) -> f32 {
        let max = (1u32 << 24) - 1;
        (sample as f32 / max as f32) * 2.0 - 1.0
    }

    #[inline]
    pub(crate) fn scale_u32(sample: u32) -> f32 {
        (sample as f32 / u32::MAX as f32) * 2.0 - 1.0
    }
}

#[cfg(test)]
impl SampleSourceTestExt for AudioSampleSource {
    fn is_finished(&self) -> bool {
        self.is_finished
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::audio::sample_source::traits::SampleSource;
    use crate::testutil::write_wav;

    #[test]
    fn from_file_nonexistent_returns_error() {
        let result = AudioSampleSource::from_file("/no/such/file.wav", None, 1024);
        assert!(result.is_err());
    }

    #[test]
    fn from_file_reads_wav_successfully() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("test.wav");
        // Write a mono 44100 Hz WAV with 100 samples
        write_wav(wav_path.clone(), vec![vec![0.5f32; 100]], 44100).unwrap();

        let source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        assert_eq!(source.channel_count(), 1);
        assert_eq!(source.sample_rate(), 44100);
        assert!(source.duration().is_some());
    }

    #[test]
    fn from_file_stereo() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("stereo.wav");
        write_wav(
            wav_path.clone(),
            vec![vec![0.5f32; 100], vec![0.3f32; 100]],
            44100,
        )
        .unwrap();

        let source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        assert_eq!(source.channel_count(), 2);
    }

    #[test]
    fn next_sample_reads_all_samples() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("test.wav");
        let num_samples = 50;
        write_wav(wav_path.clone(), vec![vec![0.25f32; num_samples]], 44100).unwrap();

        let mut source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        let mut count = 0;
        while let Ok(Some(_)) = source.next_sample() {
            count += 1;
            if count > num_samples + 10 {
                break; // safety valve
            }
        }
        assert_eq!(count, num_samples);
    }

    #[test]
    fn from_file_with_start_time() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("seek.wav");
        // Write 1 second of audio at 44100 Hz
        write_wav(wav_path.clone(), vec![vec![0.5f32; 44100]], 44100).unwrap();

        // Start at 0.5 seconds
        let mut source = AudioSampleSource::from_file(
            &wav_path,
            Some(std::time::Duration::from_millis(500)),
            1024,
        )
        .unwrap();

        // Should get fewer than 44100 samples (roughly half)
        let mut count = 0;
        while let Ok(Some(_)) = source.next_sample() {
            count += 1;
            if count > 44100 {
                break;
            }
        }
        assert!(
            count < 44100,
            "seeking to 0.5s should produce fewer samples than full file"
        );
        assert!(
            count > 10000,
            "seeking to 0.5s should still produce many samples, got {}",
            count
        );
    }

    #[test]
    fn accurate_seek_trims_block_residual_flac() {
        // Regression for the section-loop "shifted region" bug: block-based
        // codecs (FLAC here, also MP3/AAC/Ogg) only seek to a packet boundary at
        // or before the target, and the `required_ts - actual_ts` residual must
        // be trimmed so the first emitted sample is exactly at the requested
        // time. The whole suite otherwise seeks WAV, which is sample-addressable
        // and never exercises this.
        //
        // The fixture is mono 44.1k with an impulse at sample 20000 — mid-block
        // for FLAC's 4096-sample blocks (boundary at 16384, so ~3616 samples of
        // residual). Seeking to the impulse's time must return it essentially
        // immediately; without the trim it would appear ~3616 samples late.
        let path = std::path::Path::new("assets/seek-marker-44.1k.flac");
        let marker_sample = 20000.0;
        let start = std::time::Duration::from_secs_f64(marker_sample / 44100.0);
        let mut source = AudioSampleSource::from_file(path, Some(start), 1024).unwrap();

        let mut first_loud = None;
        for i in 0..1024 {
            match source.next_sample() {
                Ok(Some(s)) => {
                    if s.abs() > 0.3 {
                        first_loud = Some(i);
                        break;
                    }
                }
                _ => break,
            }
        }
        let idx = first_loud.expect("impulse not found near the seek target");
        assert!(
            idx <= 8,
            "seek landed {idx} samples before the target; the FLAC block residual \
             was not trimmed (expected the impulse at ~0)"
        );
    }

    #[test]
    fn accurate_seek_is_sample_aligned_lossy() {
        // Regression: block/frame-based codecs (MP3 frames + bit reservoir,
        // Vorbis MDCT overlap-add) need seek pre-roll to warm the decoder and a
        // packet.ts() anchor (not SeekedTo.actual_ts) to land sample-accurately.
        // Vorbis in particular precedes its first output packet with an empty
        // lapping packet, so actual_ts points before the decoded output begins.
        //
        // For each lossy tone fixture, compare seeking to sample T against
        // decoding linearly to T. The same decoder runs both ways, so general
        // lossy compression error cancels and the streams must match closely;
        // without the fix the post-seek head diverges (warmup) or is offset.
        let read_n = |src: &mut AudioSampleSource, n: usize| -> Vec<f32> {
            let mut v = Vec::with_capacity(n);
            while v.len() < n {
                match src.next_sample() {
                    Ok(Some(s)) => v.push(s),
                    _ => break,
                }
            }
            v
        };
        let t = 20000usize;
        let k = 2000usize;
        for name in ["seek-tone-44.1k.mp3", "seek-tone-44.1k.ogg"] {
            let path = std::path::Path::new("assets").join(name);
            let mut lin = AudioSampleSource::from_file(&path, None, 1024).unwrap();
            let rate = lin.sample_rate();
            let linear = read_n(&mut lin, t + k);
            assert!(linear.len() >= t + k, "{name}: fixture too short");
            let reference = &linear[t..t + k];

            let mut sk = AudioSampleSource::from_file(
                &path,
                Some(std::time::Duration::from_secs_f64(t as f64 / rate as f64)),
                1024,
            )
            .unwrap();
            let seeked = read_n(&mut sk, k);
            assert_eq!(seeked.len(), k, "{name}: short read after seek");

            let max_diff = (0..k)
                .map(|i| (seeked[i] - reference[i]).abs())
                .fold(0.0f32, f32::max);
            assert!(
                max_diff < 0.02,
                "{name}: seek not sample-aligned (max_diff={max_diff:.4}); \
                 pre-roll / packet-ts anchoring regressed"
            );
        }
    }

    #[test]
    fn decode_produces_correct_sample_values() {
        // Value-level coverage for the symphonia sample-format conversion path
        // (decode buffer -> interleaved f32) — the riskiest part of a symphonia
        // version migration, otherwise only smoke-tested. A 440Hz sine at
        // amplitude 0.5 is encoded across sample formats: lossless decodes must
        // match the analytic signal sample-for-sample; lossy decodes must
        // reproduce the right amplitude (phase isn't checked for lossy, since the
        // encoder delay shifts it).
        let rate = 44100.0;
        let freq = 440.0;
        let amp = 0.5;
        let expected = |n: usize| amp * (2.0 * std::f64::consts::PI * freq * n as f64 / rate).sin();

        let read_n = |src: &mut AudioSampleSource, n: usize| -> Vec<f32> {
            let mut v = Vec::with_capacity(n);
            while v.len() < n {
                match src.next_sample() {
                    Ok(Some(s)) => v.push(s),
                    _ => break,
                }
            }
            v
        };

        // Lossless: per-sample match. The tolerance covers quantization and the
        // normalization convention while still catching any gross conversion bug
        // (wrong sample-format arm, scale, or sign — all far larger than 0.01).
        for name in ["sine440-16bit.wav", "sine440-24bit.wav", "sine440.flac"] {
            let path = std::path::Path::new("assets").join(name);
            let mut src = AudioSampleSource::from_file(&path, None, 1024).unwrap();
            let got = read_n(&mut src, 4000);
            assert!(
                got.len() >= 2000,
                "{name}: too few samples decoded ({})",
                got.len()
            );
            let max_err = (0..got.len())
                .map(|n| (got[n] as f64 - expected(n)).abs())
                .fold(0.0f64, f64::max);
            assert!(
                max_err < 0.01,
                "{name}: decoded samples diverge from the 440Hz sine \
                 (max_err={max_err:.5}) — sample-format conversion is wrong"
            );
        }

        // Lossy: amplitude/energy check (peak ~amp, RMS ~amp/√2), phase-agnostic.
        for name in ["sine440.mp3", "sine440.ogg", "sine440.aac"] {
            let path = std::path::Path::new("assets").join(name);
            let mut src = AudioSampleSource::from_file(&path, None, 1024).unwrap();
            let got = read_n(&mut src, 8000);
            // Use the steady middle to avoid encoder priming / edge ramps.
            let mid = &got[got.len() / 4..got.len() * 3 / 4];
            assert!(mid.len() > 500, "{name}: too few samples decoded");
            let peak = mid.iter().fold(0.0f32, |m, &s| m.max(s.abs())) as f64;
            let rms =
                (mid.iter().map(|&s| (s as f64).powi(2)).sum::<f64>() / mid.len() as f64).sqrt();
            assert!(
                (peak - amp).abs() < 0.08,
                "{name}: peak {peak:.3} not ~{amp} — lossy decode amplitude wrong"
            );
            assert!(
                (rms - amp / 2.0_f64.sqrt()).abs() < 0.05,
                "{name}: rms {rms:.3} not ~{:.3}",
                amp / 2.0_f64.sqrt()
            );
        }
    }

    #[test]
    fn sample_format_is_int_for_pcm_wav() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("int.wav");
        // Write 16-bit integer WAV
        crate::testutil::write_wav_with_bits(wav_path.clone(), vec![vec![1000i16; 50]], 44100, 16)
            .unwrap();

        let source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        assert_eq!(source.sample_format(), crate::audio::SampleFormat::Int);
        assert_eq!(source.bits_per_sample(), 16);
    }

    #[test]
    fn sample_format_is_float_for_float_wav() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("float.wav");
        write_wav(wav_path.clone(), vec![vec![0.5f32; 50]], 44100).unwrap();

        let source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        assert_eq!(source.sample_format(), crate::audio::SampleFormat::Float);
    }

    #[test]
    fn detect_channels_via_env_var() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("detect.wav");
        write_wav(wav_path.clone(), vec![vec![0.5f32; 200]], 44100).unwrap();

        // Set the env var to force channel detection path
        std::env::set_var("MTRACK_FORCE_DETECT_CHANNELS", "1");
        let source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        std::env::remove_var("MTRACK_FORCE_DETECT_CHANNELS");

        assert_eq!(source.channel_count(), 1);
        // Should still be able to read samples
        let mut src = source;
        let mut count = 0;
        while let Ok(Some(_)) = src.next_sample() {
            count += 1;
            if count > 300 {
                break;
            }
        }
        assert!(count > 0, "should read samples after channel detection");
    }

    #[test]
    fn refill_buffer_small_file() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("small.wav");
        write_wav(wav_path.clone(), vec![vec![0.1f32; 3]], 44100).unwrap();

        let mut source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        let mut samples = Vec::new();
        while let Ok(Some(s)) = source.next_sample() {
            samples.push(s);
            if samples.len() > 100 {
                break;
            }
        }
        assert_eq!(samples.len(), 3);
    }

    #[test]
    fn is_finished_tracking() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("fin.wav");
        write_wav(wav_path.clone(), vec![vec![0.5f32; 5]], 44100).unwrap();

        let mut source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        assert!(!SampleSourceTestExt::is_finished(&source));

        while let Ok(Some(_)) = source.next_sample() {}
        assert!(SampleSourceTestExt::is_finished(&source));
    }

    #[test]
    fn next_sample_after_finished_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("tiny.wav");
        write_wav(wav_path.clone(), vec![vec![0.5f32; 2]], 44100).unwrap();

        let mut source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        // Drain all
        while let Ok(Some(_)) = source.next_sample() {}
        // Subsequent calls should keep returning None
        assert_eq!(source.next_sample().unwrap(), None);
        assert_eq!(source.next_sample().unwrap(), None);
    }

    #[test]
    fn from_file_invalid_format() {
        let dir = tempfile::tempdir().unwrap();
        let bad_path = dir.path().join("bad.wav");
        std::fs::write(&bad_path, b"not a wav file").unwrap();
        let result = AudioSampleSource::from_file(&bad_path, None, 1024);
        assert!(result.is_err());
    }

    #[test]
    fn read_samples_matches_next_sample() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("bulk.wav");
        let num_samples = 200;
        let data: Vec<f32> = (0..num_samples)
            .map(|i| i as f32 / num_samples as f32)
            .collect();
        write_wav(wav_path.clone(), vec![data], 44100).unwrap();

        // Read via next_sample
        let mut source1 = AudioSampleSource::from_file(&wav_path, None, 64).unwrap();
        let mut expected = Vec::new();
        while let Ok(Some(s)) = source1.next_sample() {
            expected.push(s);
        }

        // Read via read_samples
        let mut source2 = AudioSampleSource::from_file(&wav_path, None, 64).unwrap();
        let mut actual = vec![0.0_f32; num_samples + 10];
        let n = source2.read_samples(&mut actual).unwrap();
        actual.truncate(n);

        assert_eq!(expected.len(), actual.len());
        for (i, (a, b)) in expected.iter().zip(actual.iter()).enumerate() {
            assert!(
                (a - b).abs() < f32::EPSILON,
                "sample {} differs: {} vs {}",
                i,
                a,
                b
            );
        }
    }

    #[test]
    fn read_samples_partial_buffer() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("partial.wav");
        write_wav(wav_path.clone(), vec![vec![0.5f32; 100]], 44100).unwrap();

        let mut source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();

        // Read in small chunks
        let mut all = Vec::new();
        let mut buf = [0.0_f32; 7]; // odd size to test partial reads
        loop {
            let n = source.read_samples(&mut buf).unwrap();
            if n == 0 {
                break;
            }
            all.extend_from_slice(&buf[..n]);
        }
        assert_eq!(all.len(), 100);
    }

    #[test]
    fn read_samples_returns_zero_after_eof() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("eof.wav");
        write_wav(wav_path.clone(), vec![vec![0.5f32; 5]], 44100).unwrap();

        let mut source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();

        // Drain everything
        let mut buf = [0.0_f32; 100];
        let n = source.read_samples(&mut buf).unwrap();
        assert_eq!(n, 5);

        // Subsequent calls should return 0
        assert_eq!(source.read_samples(&mut buf).unwrap(), 0);
        assert_eq!(source.read_samples(&mut buf).unwrap(), 0);
    }

    #[test]
    fn read_samples_empty_buffer() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("empty_buf.wav");
        write_wav(wav_path.clone(), vec![vec![0.5f32; 10]], 44100).unwrap();

        let mut source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        let mut buf = [];
        assert_eq!(source.read_samples(&mut buf).unwrap(), 0);
    }

    #[test]
    fn read_samples_tiny_file() {
        let dir = tempfile::tempdir().unwrap();
        let wav_path = dir.path().join("tiny.wav");
        write_wav(wav_path.clone(), vec![vec![0.25f32; 1]], 44100).unwrap();

        let mut source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
        let mut buf = [0.0_f32; 100];
        let n = source.read_samples(&mut buf).unwrap();
        assert_eq!(n, 1);
        assert!((buf[0] - 0.25).abs() < 0.01);
    }

    #[test]
    fn different_sample_rates() {
        for rate in &[22050u32, 44100, 48000, 96000] {
            let dir = tempfile::tempdir().unwrap();
            let wav_path = dir.path().join(format!("rate_{}.wav", rate));
            write_wav(wav_path.clone(), vec![vec![0.5f32; 100]], *rate).unwrap();

            let source = AudioSampleSource::from_file(&wav_path, None, 1024).unwrap();
            assert_eq!(source.sample_rate(), *rate);
        }
    }
}
