use std::collections::{BTreeMap, VecDeque};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{
    Arc,
    atomic::{AtomicBool, Ordering},
};
use std::thread;
#[cfg(windows)]
use std::time::Duration;

#[cfg(windows)]
use rodio::buffer::SamplesBuffer;
#[cfg(windows)]
use std::num::NonZero;
#[cfg(windows)]
use std::sync::{
    Mutex, OnceLock,
    atomic::{AtomicU64, Ordering as AtomicOrdering},
};
#[cfg(windows)]
use symphonia::core::audio::AudioSpec;
#[cfg(windows)]
use symphonia::core::codecs::audio::AudioDecoderOptions;
#[cfg(windows)]
use symphonia::core::codecs::registry::CodecRegistry;
#[cfg(windows)]
use symphonia::core::errors::Error as SymphoniaError;
#[cfg(windows)]
use symphonia::core::formats::probe::Hint;
#[cfg(windows)]
use symphonia::core::formats::{FormatOptions, TrackType};
#[cfg(windows)]
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions};
#[cfg(windows)]
use symphonia::core::meta::MetadataOptions;
#[cfg(windows)]
use symphonia_adapter_libopus::OpusDecoder;
#[cfg(windows)]
use windows_sys::Win32::Media::Multimedia::mciSendStringW;
#[cfg(not(windows))]
use wxdragon::prelude::{Sound, SoundFlags};

use crate::app_config::ReviewAudioPolicy;
use crate::app_state::AppState;
use crate::notifications::{SoundSegment, pcm_wav_bytes};
use crate::review_session::ReviewPhase;
use crate::review_ui::ReviewUi;
use cosimo::DatumKey;

const WAV_EXTENSION: &str = "wav";
const MP3_EXTENSION: &str = "mp3";
#[cfg(windows)]
const OPUS_EXTENSION: &str = "opus";
const AUDIO_MANIFEST_FILE_NAME: &str = "audio-manifest.tsv";
const MANUAL_AUDIO_MANIFEST_HASH_PREFIX: &str = "manual:";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReviewAudioSide {
    Prompt,
    Response,
}

impl ReviewAudioSide {
    fn file_stem_suffix(self) -> &'static str {
        match self {
            Self::Prompt => "prompt",
            Self::Response => "response",
        }
    }

    fn swapped(self) -> Self {
        match self {
            Self::Prompt => Self::Response,
            Self::Response => Self::Prompt,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ReviewAudioContext {
    card_id: u64,
    review_side: ReviewAudioSide,
    file_side: ReviewAudioSide,
    is_reverse: bool,
    path: PathBuf,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReviewAudioFile {
    context: ReviewAudioContext,
}

impl ReviewAudioFile {
    pub(crate) fn side(&self) -> ReviewAudioSide {
        self.context.review_side
    }

    pub(crate) fn path(&self) -> &Path {
        &self.context.path
    }

    #[cfg(test)]
    pub(crate) fn for_test(card_id: u64, side: ReviewAudioSide, path: PathBuf) -> Self {
        Self {
            context: ReviewAudioContext {
                card_id,
                review_side: side,
                file_side: side,
                is_reverse: false,
                path,
            },
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReviewAudioCue {
    Prompt,
    Response,
    Summary,
    QuizCorrect,
    QuizIncorrect,
}

#[derive(Debug, Clone)]
enum QueuedReviewAudio {
    File(ReviewAudioFile),
    Cue(PathBuf),
}

impl QueuedReviewAudio {
    fn path(&self) -> &Path {
        match self {
            Self::File(audio_file) => audio_file.path(),
            Self::Cue(path) => path,
        }
    }

    fn context(&self) -> Option<&ReviewAudioContext> {
        match self {
            Self::File(audio_file) => Some(&audio_file.context),
            Self::Cue(_) => None,
        }
    }
}

#[derive(Debug, Clone)]
struct PlayingReviewAudio {
    contexts: Vec<ReviewAudioContext>,
    cancel: Arc<AtomicBool>,
    finished: Arc<AtomicBool>,
}

#[derive(Default)]
pub(crate) struct ReviewAudioPlayer {
    playing: Option<PlayingReviewAudio>,
    queued: VecDeque<QueuedReviewAudio>,
    last_automatic_context: Option<ReviewAudioContext>,
}

impl ReviewAudioPlayer {
    pub(crate) fn stop(&mut self) {
        self.queued.clear();
        if let Some(playing) = self.playing.take() {
            playing.cancel.store(true, Ordering::Release);
            stop_audio_playback();
        }
    }

    pub(crate) fn toggle_current_audio(&mut self, ui: &ReviewUi, state: &AppState) -> bool {
        self.clear_finished();
        let Some(audio_file) = current_review_audio_file(state) else {
            return false;
        };

        if self
            .playing
            .as_ref()
            .is_some_and(|playing| playing.has_context(&audio_file.context))
        {
            self.stop();
            return true;
        }

        self.play_for_review_ui(audio_file, ui, state, false);
        true
    }

    pub(crate) fn toggle_selected_ready_audio(&mut self, ui: &ReviewUi, state: &AppState) -> bool {
        self.clear_finished();
        let Ok(Some(audio_files)) = selected_ready_audio_files(state) else {
            return false;
        };

        if self.playing.as_ref().is_some_and(|playing| {
            audio_files
                .iter()
                .any(|file| playing.has_context(&file.context))
        }) {
            self.stop();
            return true;
        }

        self.play_sequence_for_review_ui(audio_files, ui, state);
        true
    }

    pub(crate) fn sync_after_render(&mut self, ui: &ReviewUi, state: &AppState) {
        self.clear_finished();

        if self.playing_context_should_stop_after_render(state) {
            self.stop();
        }

        match state.settings.review_audio_policy {
            ReviewAudioPolicy::Off => {
                self.last_automatic_context = None;
                self.stop();
            }
            ReviewAudioPolicy::Manual => {}
            ReviewAudioPolicy::Automatic => {
                if let Some(audio_file) = current_review_audio_file(state) {
                    if self
                        .last_automatic_context
                        .as_ref()
                        .is_none_or(|context| context != &audio_file.context)
                    {
                        self.play_for_review_ui(
                            audio_file.clone(),
                            ui,
                            state,
                            state.settings.review_audio_cues,
                        );
                    }
                    self.last_automatic_context = Some(audio_file.context);
                } else {
                    self.last_automatic_context = None;
                }
            }
        }
    }

    pub(crate) fn toggle_audio_files(
        &mut self,
        audio_files: Vec<ReviewAudioFile>,
    ) -> Result<(), PathBuf> {
        self.clear_finished();
        if audio_files.is_empty() {
            return Ok(());
        }
        if self.playing.as_ref().is_some_and(|playing| {
            audio_files
                .iter()
                .any(|audio_file| playing.has_context(&audio_file.context))
        }) {
            self.stop();
            return Ok(());
        }

        self.play_audio_files(audio_files)
    }

    pub(crate) fn play_audio_file(&mut self, audio_file: ReviewAudioFile) -> Result<(), PathBuf> {
        self.stop();
        self.start_audio_file(audio_file)
    }

    pub(crate) fn play_audio_files(
        &mut self,
        audio_files: Vec<ReviewAudioFile>,
    ) -> Result<(), PathBuf> {
        if audio_files.is_empty() {
            return Ok(());
        }
        self.stop();
        self.queued = audio_files
            .into_iter()
            .map(QueuedReviewAudio::File)
            .collect();
        self.start_queued_audio_files()
    }

    pub(crate) fn play_quiz_review_audio(
        &mut self,
        audio_files: Vec<ReviewAudioFile>,
        result_cue: Option<bool>,
    ) -> Result<(), PathBuf> {
        if audio_files.is_empty() && result_cue.is_none() {
            return Ok(());
        }
        self.stop();
        if let Some(is_correct) = result_cue
            && let Ok(cue_path) = ensure_review_audio_cue(if is_correct {
                ReviewAudioCue::QuizCorrect
            } else {
                ReviewAudioCue::QuizIncorrect
            })
        {
            self.queued.push_back(QueuedReviewAudio::Cue(cue_path));
        }
        self.queued
            .extend(audio_files.into_iter().map(QueuedReviewAudio::File));
        self.start_queued_audio_files()
    }

    pub(crate) fn play_summary_cue(&mut self) {
        self.stop();
        let Ok(path) = ensure_review_audio_cue(ReviewAudioCue::Summary) else {
            return;
        };
        self.queued.push_back(QueuedReviewAudio::Cue(path));
        let _ = self.start_queued_audio_files();
    }

    fn play_for_review_ui(
        &mut self,
        audio_file: ReviewAudioFile,
        ui: &ReviewUi,
        state: &AppState,
        include_cue: bool,
    ) {
        if let Err(path) = if include_cue {
            self.play_audio_file_with_cue(audio_file)
        } else {
            self.play_audio_file(audio_file)
        } {
            ui.status.set_label(
                &state
                    .localizer
                    .could_not_play_review_audio(&path.display().to_string()),
            );
        }
    }

    fn play_audio_file_with_cue(&mut self, audio_file: ReviewAudioFile) -> Result<(), PathBuf> {
        self.stop();
        let cue = match audio_file.side() {
            ReviewAudioSide::Prompt => ReviewAudioCue::Prompt,
            ReviewAudioSide::Response => ReviewAudioCue::Response,
        };
        let cue_path = ensure_review_audio_cue(cue).map_err(|_| audio_file.path().to_path_buf())?;
        self.queued.push_back(QueuedReviewAudio::Cue(cue_path));
        self.queued.push_back(QueuedReviewAudio::File(audio_file));
        self.start_queued_audio_files()
    }

    fn play_sequence_for_review_ui(
        &mut self,
        audio_files: Vec<ReviewAudioFile>,
        ui: &ReviewUi,
        state: &AppState,
    ) {
        if audio_files.is_empty() {
            return;
        }
        if let Err(path) = self.play_audio_files(audio_files) {
            ui.status.set_label(
                &state
                    .localizer
                    .could_not_play_review_audio(&path.display().to_string()),
            );
        }
    }

    fn start_audio_file(&mut self, audio_file: ReviewAudioFile) -> Result<(), PathBuf> {
        self.queued.push_back(QueuedReviewAudio::File(audio_file));
        self.start_queued_audio_files()
    }

    fn start_queued_audio_files(&mut self) -> Result<(), PathBuf> {
        let audio_items: Vec<QueuedReviewAudio> = self.queued.drain(..).collect();
        if audio_items.is_empty() {
            return Ok(());
        }

        if let Some(missing_path) = audio_items
            .iter()
            .map(QueuedReviewAudio::path)
            .find(|path| !path.is_file())
        {
            return Err(missing_path.to_path_buf());
        }

        let cancel = Arc::new(AtomicBool::new(false));
        let finished = Arc::new(AtomicBool::new(false));
        let thread_cancel = Arc::clone(&cancel);
        let thread_finished = Arc::clone(&finished);
        let paths: Vec<PathBuf> = audio_items
            .iter()
            .map(|audio_item| audio_item.path().to_path_buf())
            .collect();
        thread::spawn(move || {
            for path in paths {
                if thread_cancel.load(Ordering::Acquire) {
                    break;
                }
                let _ = play_audio_file_sync(&path, &thread_cancel);
                if thread_cancel.load(Ordering::Acquire) {
                    break;
                }
            }
            thread_finished.store(true, Ordering::Release);
        });

        self.playing = Some(PlayingReviewAudio {
            contexts: audio_items
                .into_iter()
                .filter_map(|audio_item| audio_item.context().cloned())
                .collect(),
            cancel,
            finished,
        });
        Ok(())
    }

    fn clear_finished(&mut self) {
        if self
            .playing
            .as_ref()
            .is_some_and(|playing| playing.finished.load(Ordering::Acquire))
        {
            self.playing = None;
        }
    }

    fn playing_context_should_stop_after_render(&self, state: &AppState) -> bool {
        if matches!(state.session.phase(), ReviewPhase::ReadyToStart) {
            return false;
        }

        let current_audio = current_audio_files_for_phase(state);
        self.playing.as_ref().is_some_and(|playing| {
            current_audio.as_ref().is_none_or(|audio_files| {
                !audio_files
                    .iter()
                    .any(|audio_file| playing.has_context(&audio_file.context))
            })
        })
    }
}

impl PlayingReviewAudio {
    fn has_context(&self, context: &ReviewAudioContext) -> bool {
        self.contexts.iter().any(|active| active == context)
    }
}

fn ensure_review_audio_cue(cue: ReviewAudioCue) -> std::io::Result<PathBuf> {
    let path = std::env::temp_dir().join(match cue {
        ReviewAudioCue::Prompt => "cosimo-review-prompt-cue-v2.wav",
        ReviewAudioCue::Response => "cosimo-review-response-cue-v2.wav",
        ReviewAudioCue::Summary => "cosimo-review-summary-cue-v2.wav",
        ReviewAudioCue::QuizCorrect => "cosimo-quiz-correct-cue-v1.wav",
        ReviewAudioCue::QuizIncorrect => "cosimo-quiz-incorrect-cue-v1.wav",
    });
    if path.exists() {
        return Ok(path);
    }
    fs::write(&path, review_audio_cue_bytes(cue))?;
    Ok(path)
}

fn review_audio_cue_bytes(cue: ReviewAudioCue) -> Vec<u8> {
    let segments = match cue {
        ReviewAudioCue::Prompt => vec![SoundSegment::tone(523.25, 200)],
        ReviewAudioCue::Response => vec![
            SoundSegment::tone(659.25, 100),
            SoundSegment::silence(30),
            SoundSegment::tone(659.25, 100),
        ],
        ReviewAudioCue::Summary => vec![
            SoundSegment::tone(523.25, 100),
            SoundSegment::silence(30),
            SoundSegment::tone(659.25, 100),
            SoundSegment::silence(30),
            SoundSegment::tone(784.88, 100),
        ],
        ReviewAudioCue::QuizCorrect => vec![
            SoundSegment::tone(523.25, 100),
            SoundSegment::silence(30),
            SoundSegment::tone(784.88, 100),
        ],
        ReviewAudioCue::QuizIncorrect => vec![
            SoundSegment::tone(392.00, 100),
            SoundSegment::silence(30),
            SoundSegment::tone(261.63, 100),
        ],
    };
    pcm_wav_bytes(&segments)
}

#[cfg(windows)]
fn play_audio_file_sync(path: &Path, cancel: &AtomicBool) -> bool {
    if is_rodio_decoded_path(path) {
        play_decoded_file_with_rodio(path, cancel).is_ok()
    } else {
        play_audio_file_with_mci(path, cancel).is_ok()
    }
}

#[cfg(not(windows))]
fn play_audio_file_sync(path: &Path, _cancel: &AtomicBool) -> bool {
    Sound::play_file(&path.to_string_lossy(), SoundFlags::Sync)
}

#[cfg(windows)]
fn stop_audio_playback() {
    if let Some(alias) = active_mci_alias() {
        let _ = mci_command(&format!("stop {alias}"), None);
    }
    if let Some(player) = active_rodio_player() {
        player.stop();
    }
}

#[cfg(windows)]
type SharedRodioPlayer = Arc<rodio::Player>;

#[cfg(windows)]
fn active_rodio_player_cell() -> &'static Mutex<Option<SharedRodioPlayer>> {
    static ACTIVE_PLAYER: OnceLock<Mutex<Option<SharedRodioPlayer>>> = OnceLock::new();
    ACTIVE_PLAYER.get_or_init(|| Mutex::new(None))
}

#[cfg(windows)]
fn active_rodio_player() -> Option<SharedRodioPlayer> {
    active_rodio_player_cell()
        .lock()
        .ok()
        .and_then(|player| player.clone())
}

#[cfg(windows)]
fn set_active_rodio_player(player: &SharedRodioPlayer) {
    if let Ok(mut active_player) = active_rodio_player_cell().lock() {
        *active_player = Some(Arc::clone(player));
    }
}

#[cfg(windows)]
fn clear_active_rodio_player(player: &SharedRodioPlayer) {
    if let Ok(mut active_player) = active_rodio_player_cell().lock()
        && active_player
            .as_ref()
            .is_some_and(|active| Arc::ptr_eq(active, player))
    {
        *active_player = None;
    }
}

#[cfg(windows)]
fn is_rodio_decoded_path(path: &Path) -> bool {
    path.extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| {
            extension.eq_ignore_ascii_case(OPUS_EXTENSION)
                || extension.eq_ignore_ascii_case(MP3_EXTENSION)
        })
}

#[cfg(windows)]
fn play_decoded_file_with_rodio(path: &Path, cancel: &AtomicBool) -> Result<(), ()> {
    let decoded = decode_audio_file(path).map_err(|_| ())?;
    let mut sink = rodio::DeviceSinkBuilder::open_default_sink().map_err(|_| ())?;
    sink.log_on_drop(false);
    let player = Arc::new(rodio::Player::connect_new(sink.mixer()));
    set_active_rodio_player(&player);

    if cancel.load(Ordering::Acquire) {
        clear_active_rodio_player(&player);
        return Ok(());
    }

    player.append(decoded.into_source());
    while !cancel.load(Ordering::Acquire) && !player.empty() {
        thread::sleep(Duration::from_millis(25));
    }

    if cancel.load(Ordering::Acquire) {
        player.stop();
    }
    player.sleep_until_end();
    clear_active_rodio_player(&player);
    Ok(())
}

#[cfg(windows)]
#[derive(Debug, Clone)]
struct DecodedOpusAudio {
    channels: u16,
    sample_rate: u32,
    samples: Vec<f32>,
}

#[cfg(windows)]
impl DecodedOpusAudio {
    fn into_source(self) -> SamplesBuffer {
        SamplesBuffer::new(
            NonZero::new(self.channels).expect("decoded audio has at least one channel"),
            NonZero::new(self.sample_rate).expect("decoded audio has a non-zero sample rate"),
            self.samples,
        )
    }
}

#[cfg(windows)]
fn decode_audio_file(path: &Path) -> Result<DecodedOpusAudio, String> {
    let file = fs::File::open(path)
        .map_err(|error| format!("could not open {}: {error}", path.display()))?;
    let stream = MediaSourceStream::new(Box::new(file), MediaSourceStreamOptions::default());

    let mut hint = Hint::new();
    if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) {
        hint.with_extension(extension);
    }

    let mut format = symphonia::default::get_probe()
        .probe(
            &hint,
            stream,
            FormatOptions::default(),
            MetadataOptions::default(),
        )
        .map_err(|error| format!("could not probe {}: {error}", path.display()))?;
    let track = format
        .default_track(TrackType::Audio)
        .ok_or_else(|| format!("{} has no supported audio track", path.display()))?;
    let track_id = track.id;
    let codec_params = track
        .codec_params
        .as_ref()
        .and_then(|params| params.audio())
        .ok_or_else(|| format!("{} has no audio codec parameters", path.display()))?
        .clone();

    let mut codec_registry = CodecRegistry::new();
    symphonia::default::register_enabled_codecs(&mut codec_registry);
    codec_registry.register_audio_decoder::<OpusDecoder>();
    let mut decoder = codec_registry
        .make_audio_decoder(&codec_params, &AudioDecoderOptions::default())
        .map_err(|error| {
            format!(
                "could not create audio decoder for {}: {error}",
                path.display()
            )
        })?;

    let mut samples = Vec::new();
    let mut observed_spec: Option<AudioSpec> = None;

    loop {
        let packet = match format.next_packet() {
            Ok(Some(packet)) => packet,
            Ok(None) => break,
            Err(SymphoniaError::ResetRequired) => {
                return Err(format!("{} requires a decoder reset", path.display()));
            }
            Err(error) => return Err(format!("could not read {}: {error}", path.display())),
        };

        if packet.track_id != track_id {
            continue;
        }

        let decoded = match decoder.decode(&packet) {
            Ok(decoded) => decoded,
            Err(SymphoniaError::DecodeError(_)) => continue,
            Err(error) => return Err(format!("could not decode {}: {error}", path.display())),
        };

        let spec = decoded.spec().clone();
        if let Some(previous_spec) = &observed_spec {
            if previous_spec != &spec {
                return Err(format!(
                    "{} changes audio format mid-stream, which Cosimo does not handle",
                    path.display()
                ));
            }
        } else {
            observed_spec = Some(spec);
        }

        let mut decoded_samples = vec![0.0; decoded.samples_interleaved()];
        decoded.copy_to_slice_interleaved(decoded_samples.as_mut_slice());
        samples.extend(decoded_samples);
    }

    let spec = observed_spec.ok_or_else(|| format!("{} decoded no audio", path.display()))?;
    Ok(DecodedOpusAudio {
        channels: spec.channels().count() as u16,
        sample_rate: spec.rate(),
        samples,
    })
}

#[cfg(windows)]
static NEXT_MCI_ALIAS_ID: AtomicU64 = AtomicU64::new(1);

#[cfg(windows)]
fn active_mci_alias_cell() -> &'static Mutex<Option<String>> {
    static ACTIVE_ALIAS: OnceLock<Mutex<Option<String>>> = OnceLock::new();
    ACTIVE_ALIAS.get_or_init(|| Mutex::new(None))
}

#[cfg(windows)]
fn active_mci_alias() -> Option<String> {
    active_mci_alias_cell()
        .lock()
        .ok()
        .and_then(|alias| alias.clone())
}

#[cfg(windows)]
fn set_active_mci_alias(alias: &str) {
    if let Ok(mut active_alias) = active_mci_alias_cell().lock() {
        *active_alias = Some(alias.to_string());
    }
}

#[cfg(windows)]
fn clear_active_mci_alias(alias: &str) {
    if let Ok(mut active_alias) = active_mci_alias_cell().lock() {
        if active_alias.as_deref() == Some(alias) {
            *active_alias = None;
        }
    }
}

#[cfg(windows)]
fn play_audio_file_with_mci(path: &Path, cancel: &AtomicBool) -> Result<(), ()> {
    let alias = format!(
        "cosimo_review_audio_{}",
        NEXT_MCI_ALIAS_ID.fetch_add(1, AtomicOrdering::Relaxed)
    );
    let path_text = path.to_string_lossy().replace('"', "");
    mci_command(
        &format!("open \"{path_text}\" type waveaudio alias {alias}"),
        None,
    )?;
    let _guard = MciAliasGuard::new(alias.clone());
    set_active_mci_alias(&alias);

    if cancel.load(Ordering::Acquire) {
        let _ = mci_command(&format!("stop {alias}"), None);
        return Ok(());
    }

    mci_command(&format!("play {alias}"), None)?;
    loop {
        if cancel.load(Ordering::Acquire) {
            let _ = mci_command(&format!("stop {alias}"), None);
            return Ok(());
        }

        let mode = mci_status_mode(&alias)?;
        if mode != "playing" {
            return Ok(());
        }
        thread::sleep(Duration::from_millis(25));
    }
}

#[cfg(windows)]
struct MciAliasGuard {
    alias: String,
}

#[cfg(windows)]
impl MciAliasGuard {
    fn new(alias: String) -> Self {
        Self { alias }
    }
}

#[cfg(windows)]
impl Drop for MciAliasGuard {
    fn drop(&mut self) {
        clear_active_mci_alias(&self.alias);
        let _ = mci_command(&format!("close {}", self.alias), None);
    }
}

#[cfg(windows)]
fn mci_status_mode(alias: &str) -> Result<String, ()> {
    let mut buffer = [0_u16; 32];
    mci_command(&format!("status {alias} mode"), Some(&mut buffer))?;
    let len = buffer
        .iter()
        .position(|unit| *unit == 0)
        .unwrap_or(buffer.len());
    Ok(String::from_utf16_lossy(&buffer[..len])
        .trim()
        .to_ascii_lowercase())
}

#[cfg(windows)]
fn mci_command(command: &str, output: Option<&mut [u16]>) -> Result<(), ()> {
    let mut command: Vec<u16> = command.encode_utf16().collect();
    command.push(0);
    let (output_ptr, output_len) = match output {
        Some(buffer) => (buffer.as_mut_ptr(), buffer.len() as u32),
        None => (std::ptr::null_mut(), 0),
    };
    // SAFETY: command is a NUL-terminated UTF-16 command buffer. output_ptr is
    // either null with length 0 or points to a writable UTF-16 buffer that
    // lives for the call. mciSendStringW does not retain either pointer.
    let result = unsafe {
        mciSendStringW(
            command.as_ptr(),
            output_ptr,
            output_len,
            std::ptr::null_mut(),
        )
    };
    if result == 0 { Ok(()) } else { Err(()) }
}

#[cfg(not(windows))]
fn stop_audio_playback() {
    Sound::stop();
}

fn current_audio_files_for_phase(state: &AppState) -> Option<Vec<ReviewAudioFile>> {
    match state.session.phase() {
        ReviewPhase::ReadyToStart => selected_ready_audio_files(state).ok().flatten(),
        ReviewPhase::Prompt | ReviewPhase::Response { .. } => {
            current_review_audio_file(state).map(|file| vec![file])
        }
    }
}

pub(crate) fn current_review_audio_file(state: &AppState) -> Option<ReviewAudioFile> {
    if state.settings.review_audio_policy == ReviewAudioPolicy::Off {
        return None;
    }

    let review_side = match state.session.phase() {
        ReviewPhase::Prompt => ReviewAudioSide::Prompt,
        ReviewPhase::Response { .. } => ReviewAudioSide::Response,
        ReviewPhase::ReadyToStart => return None,
    };
    let item = state.current_study_item()?;
    audio_file_for_datum_key(&state.deck_path, item.datum_key(), review_side)
}

pub(crate) fn selected_ready_audio_files(
    state: &AppState,
) -> Result<Option<Vec<ReviewAudioFile>>, String> {
    if state.settings.review_audio_policy == ReviewAudioPolicy::Off || !state.session.is_ready() {
        return Ok(None);
    }
    let Some(item) = state.selected_ready_study_item()? else {
        return Ok(None);
    };
    let audio_files: Vec<ReviewAudioFile> = [ReviewAudioSide::Prompt, ReviewAudioSide::Response]
        .into_iter()
        .filter_map(|side| audio_file_for_datum_key(&state.deck_path, item.datum_key(), side))
        .collect();
    Ok((!audio_files.is_empty()).then_some(audio_files))
}

pub(crate) fn selected_ready_audio_available(state: &AppState) -> bool {
    selected_ready_audio_files(state).is_ok_and(|audio_files| audio_files.is_some())
}

pub(crate) fn audio_file_for_datum_key(
    deck_path: &Path,
    datum_key: &DatumKey,
    review_side: ReviewAudioSide,
) -> Option<ReviewAudioFile> {
    let (source_key, is_reverse) = match datum_key.parent_key_for_reverse() {
        Some(parent_key) => (parent_key, true),
        None => (datum_key.clone(), false),
    };
    let card_id = source_key.card_id()?;
    let file_side = if is_reverse {
        review_side.swapped()
    } else {
        review_side
    };
    let path = available_audio_path_for_card_side(deck_path, card_id, file_side)?;
    path.is_file().then_some(ReviewAudioFile {
        context: ReviewAudioContext {
            card_id,
            review_side,
            file_side,
            is_reverse,
            path,
        },
    })
}

pub(crate) fn sidecar_dir_for_deck_path(deck_path: &Path) -> Option<PathBuf> {
    let file_stem = deck_path.file_stem()?;
    Some(match deck_path.parent() {
        Some(parent) => parent.join(file_stem),
        None => PathBuf::from(file_stem),
    })
}

#[cfg(test)]
pub(crate) fn audio_path_for_card_side(
    deck_path: &Path,
    card_id: u64,
    side: ReviewAudioSide,
) -> Option<PathBuf> {
    audio_path_for_card_side_with_extension(deck_path, card_id, side, WAV_EXTENSION)
}

fn available_audio_path_for_card_side(
    deck_path: &Path,
    card_id: u64,
    side: ReviewAudioSide,
) -> Option<PathBuf> {
    let sidecar_dir = sidecar_dir_for_deck_path(deck_path)?;
    let manifest = ReviewAudioManifest::read(&sidecar_dir.join(AUDIO_MANIFEST_FILE_NAME));
    let candidates = candidate_audio_paths_for_card_side(deck_path, card_id, side)?;

    candidates
        .iter()
        .find(|path| path.is_file() && manifest.is_manual_or_untracked(path))
        .cloned()
        .or_else(|| {
            candidates
                .into_iter()
                .find(|path| path.is_file() && manifest.is_generated(path))
        })
}

fn candidate_audio_paths_for_card_side(
    deck_path: &Path,
    card_id: u64,
    side: ReviewAudioSide,
) -> Option<Vec<PathBuf>> {
    Some(
        preferred_audio_extensions()
            .iter()
            .filter_map(|extension| {
                audio_path_for_card_side_with_extension(deck_path, card_id, side, extension)
            })
            .collect(),
    )
}

#[cfg(windows)]
fn preferred_audio_extensions() -> &'static [&'static str] {
    &[WAV_EXTENSION, MP3_EXTENSION, OPUS_EXTENSION]
}

#[cfg(not(windows))]
fn preferred_audio_extensions() -> &'static [&'static str] {
    &[WAV_EXTENSION, MP3_EXTENSION]
}

fn audio_path_for_card_side_with_extension(
    deck_path: &Path,
    card_id: u64,
    side: ReviewAudioSide,
    extension: &str,
) -> Option<PathBuf> {
    Some(sidecar_dir_for_deck_path(deck_path)?.join(format!(
        "{card_id}.{}.{}",
        side.file_stem_suffix(),
        extension
    )))
}

#[derive(Debug, Clone, Default)]
struct ReviewAudioManifest {
    entries: BTreeMap<String, ReviewAudioManifestEntry>,
}

impl ReviewAudioManifest {
    fn read(path: &Path) -> Self {
        let Ok(text) = fs::read_to_string(path) else {
            return Self::default();
        };
        let mut manifest = Self::default();
        for line in text.lines() {
            let fields: Vec<_> = line.split('\t').collect();
            let [file_name, text_hash, ..] = fields.as_slice() else {
                continue;
            };
            if *file_name == "version" || file_name.is_empty() {
                continue;
            }
            manifest.entries.insert(
                (*file_name).to_string(),
                ReviewAudioManifestEntry {
                    text_hash: (*text_hash).to_string(),
                },
            );
        }
        manifest
    }

    fn is_manual_or_untracked(&self, path: &Path) -> bool {
        let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
            return true;
        };
        self.entries
            .get(file_name)
            .map(ReviewAudioManifestEntry::is_manual)
            .unwrap_or(true)
    }

    fn is_generated(&self, path: &Path) -> bool {
        let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
            return false;
        };
        self.entries
            .get(file_name)
            .is_some_and(|entry| !entry.is_manual())
    }
}

#[derive(Debug, Clone)]
struct ReviewAudioManifestEntry {
    text_hash: String,
}

impl ReviewAudioManifestEntry {
    fn is_manual(&self) -> bool {
        self.text_hash
            .starts_with(MANUAL_AUDIO_MANIFEST_HASH_PREFIX)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app_config::{AppSettings, ReviewAudioPolicy};
    use crate::app_state::AppState;
    use crate::ready_list::{ReadyCardFilter, ReadyCardSort, ReadyCardSortDirection};
    use crate::review_session::ReviewSession;
    use cosimo::{Datum, DatumKey, SqliteReviewStore};
    use std::cell::{Cell, RefCell};

    fn datum_with_id(card_id: u64, prompt: &str, response: &str) -> Datum {
        Datum::new(prompt, response).unwrap().with_card_id(card_id)
    }

    fn test_state(deck_path: PathBuf, data: Vec<Datum>, session: ReviewSession) -> AppState {
        let review_db_path = deck_path.with_extension("cosimo-review.sqlite3");
        AppState {
            localizer: Default::default(),
            settings: AppSettings {
                review_audio_policy: ReviewAudioPolicy::Manual,
                ..AppSettings::default()
            },
            deck_path,
            review_db_path: review_db_path.clone(),
            backup_dir: PathBuf::new(),
            deck_metadata: Default::default(),
            data,
            session,
            selected_ready_key: None,
            selected_ready_index: None,
            ready_card_filter: ReadyCardFilter::All,
            ready_card_text_filter: String::new(),
            ready_card_sort: ReadyCardSort::DeckOrder,
            ready_card_sort_direction: ReadyCardSortDirection::Ascending,
            ready_visible_indices: RefCell::new(Vec::new()),
            ready_list_sync_in_progress: Cell::new(false),
            ready_due_notifications_suspended: false,
            last_ready_due_notification_count: None,
            ready_summary_warnings: Vec::new(),
            pending_warning_dialog: None,
            store: SqliteReviewStore::open_in_memory().unwrap(),
            last_status: String::new(),
            review_audio_player: RefCell::new(ReviewAudioPlayer::default()),
        }
    }

    fn player_playing(audio_file: &ReviewAudioFile) -> ReviewAudioPlayer {
        ReviewAudioPlayer {
            playing: Some(PlayingReviewAudio {
                contexts: vec![audio_file.context.clone()],
                cancel: Arc::new(AtomicBool::new(false)),
                finished: Arc::new(AtomicBool::new(false)),
            }),
            ..ReviewAudioPlayer::default()
        }
    }

    #[test]
    fn sidecar_directory_uses_deck_stem_beside_deck() {
        assert_eq!(
            sidecar_dir_for_deck_path(Path::new("/tmp/Indonesian.cosimo-deck")),
            Some(PathBuf::from("/tmp/Indonesian"))
        );
    }

    #[test]
    fn audio_paths_use_card_id_and_side() {
        assert_eq!(
            audio_path_for_card_side(
                Path::new("/tmp/Indonesian.cosimo-deck"),
                12,
                ReviewAudioSide::Prompt,
            ),
            Some(PathBuf::from("/tmp/Indonesian/12.prompt.wav"))
        );
    }

    #[cfg(windows)]
    #[test]
    fn windows_audio_lookup_prefers_manual_wav_to_manual_opus() {
        let deck_path = std::env::temp_dir().join(format!(
            "cosimo-opus-preference-test-{}.cosimo-deck",
            std::process::id()
        ));
        let sidecar = sidecar_dir_for_deck_path(&deck_path).unwrap();
        fs::create_dir_all(&sidecar).unwrap();
        let wav_audio = sidecar.join("12.prompt.wav");
        let opus_audio = sidecar.join("12.prompt.opus");
        fs::write(&wav_audio, b"RIFF....WAVE").unwrap();
        fs::write(&opus_audio, b"OggS").unwrap();

        let audio_file = audio_file_for_datum_key(
            &deck_path,
            &DatumKey::for_card_id(12),
            ReviewAudioSide::Prompt,
        )
        .unwrap();

        assert_eq!(audio_file.path(), wav_audio);

        let _ = fs::remove_file(opus_audio);
        let _ = fs::remove_file(audio_file.path());
        let _ = fs::remove_dir(sidecar);
    }

    #[test]
    fn audio_lookup_prefers_manual_mp3_to_generated_wav() {
        let deck_path = std::env::temp_dir().join(format!(
            "cosimo-manual-audio-preference-test-{}.cosimo-deck",
            std::process::id()
        ));
        let sidecar = sidecar_dir_for_deck_path(&deck_path).unwrap();
        fs::create_dir_all(&sidecar).unwrap();
        let wav_audio = sidecar.join("12.prompt.wav");
        let mp3_audio = sidecar.join("12.prompt.mp3");
        fs::write(&wav_audio, b"RIFF....WAVE").unwrap();
        fs::write(&mp3_audio, b"ID3").unwrap();
        fs::write(
            sidecar.join("audio-manifest.tsv"),
            "version\t2\t\t\n12.prompt.wav\tfnv1a64:generated\tonecore\tvoice\n12.prompt.mp3\tmanual:anki\tonecore\t\n",
        )
        .unwrap();

        let audio_file = audio_file_for_datum_key(
            &deck_path,
            &DatumKey::for_card_id(12),
            ReviewAudioSide::Prompt,
        )
        .unwrap();

        assert_eq!(audio_file.path(), mp3_audio);

        let _ = fs::remove_file(wav_audio);
        let _ = fs::remove_file(audio_file.path());
        let _ = fs::remove_file(sidecar.join("audio-manifest.tsv"));
        let _ = fs::remove_dir(sidecar);
    }

    #[test]
    fn audio_lookup_falls_back_to_wav() {
        let deck_path = std::env::temp_dir().join(format!(
            "cosimo-wav-fallback-test-{}.cosimo-deck",
            std::process::id()
        ));
        let sidecar = sidecar_dir_for_deck_path(&deck_path).unwrap();
        fs::create_dir_all(&sidecar).unwrap();
        let wav_audio = sidecar.join("12.prompt.wav");
        fs::write(&wav_audio, b"RIFF....WAVE").unwrap();

        let audio_file = audio_file_for_datum_key(
            &deck_path,
            &DatumKey::for_card_id(12),
            ReviewAudioSide::Prompt,
        )
        .unwrap();

        assert_eq!(audio_file.path(), wav_audio);

        let _ = fs::remove_file(audio_file.path());
        let _ = fs::remove_dir(sidecar);
    }

    #[test]
    fn review_audio_cues_are_valid_pcm_wav_files() {
        for cue in [
            ReviewAudioCue::Prompt,
            ReviewAudioCue::Response,
            ReviewAudioCue::Summary,
            ReviewAudioCue::QuizCorrect,
            ReviewAudioCue::QuizIncorrect,
        ] {
            let bytes = review_audio_cue_bytes(cue);
            assert!(bytes.starts_with(b"RIFF"));
            assert_eq!(&bytes[8..12], b"WAVE");
            assert_eq!(&bytes[12..16], b"fmt ");
            assert_eq!(&bytes[36..40], b"data");
            assert!(bytes.len() > 44);
        }
    }

    #[test]
    fn reverse_cards_reuse_parent_audio_with_sides_swapped() {
        let deck_path = std::env::temp_dir().join(format!(
            "cosimo-audio-test-{}.cosimo-deck",
            std::process::id()
        ));
        let sidecar = sidecar_dir_for_deck_path(&deck_path).unwrap();
        fs::create_dir_all(&sidecar).unwrap();
        let response_audio = sidecar.join("1.response.wav");
        fs::write(&response_audio, b"RIFF....WAVE").unwrap();

        let data = vec![datum_with_id(1, "one", "uno")];
        let session = ReviewSession::start(vec![1], 2, false, 3, 100).unwrap();
        let state = test_state(deck_path, data, session);
        state
            .store
            .set_datum_reversible(&DatumKey::for_card_id(1), true)
            .unwrap();

        let audio_file = current_review_audio_file(&state).unwrap();
        assert_eq!(audio_file.side(), ReviewAudioSide::Prompt);
        assert_eq!(audio_file.path(), response_audio);

        let _ = fs::remove_file(audio_file.path());
        let _ = fs::remove_dir(sidecar);
    }

    #[test]
    fn datum_key_audio_lookup_supports_exam_questions() {
        let deck_path = std::env::temp_dir().join(format!(
            "cosimo-exam-audio-test-{}.cosimo-deck",
            std::process::id()
        ));
        let sidecar = sidecar_dir_for_deck_path(&deck_path).unwrap();
        fs::create_dir_all(&sidecar).unwrap();
        let prompt_audio = sidecar.join("4.prompt.wav");
        let response_audio = sidecar.join("4.response.wav");
        fs::write(&prompt_audio, b"RIFF....WAVE").unwrap();
        fs::write(&response_audio, b"RIFF....WAVE").unwrap();

        let standard_prompt = audio_file_for_datum_key(
            &deck_path,
            &DatumKey::for_card_id(4),
            ReviewAudioSide::Prompt,
        )
        .unwrap();
        let reverse_prompt = audio_file_for_datum_key(
            &deck_path,
            &DatumKey::for_reverse_of(&DatumKey::for_card_id(4)),
            ReviewAudioSide::Prompt,
        )
        .unwrap();

        assert_eq!(standard_prompt.path(), prompt_audio);
        assert_eq!(reverse_prompt.path(), response_audio);

        let _ = fs::remove_file(standard_prompt.path());
        let _ = fs::remove_file(reverse_prompt.path());
        let _ = fs::remove_dir(sidecar);
    }

    #[test]
    fn selected_ready_audio_files_include_prompt_then_response() {
        let deck_path = std::env::temp_dir().join(format!(
            "cosimo-ready-audio-test-{}.cosimo-deck",
            std::process::id()
        ));
        let sidecar = sidecar_dir_for_deck_path(&deck_path).unwrap();
        fs::create_dir_all(&sidecar).unwrap();
        let prompt_audio = sidecar.join("7.prompt.wav");
        let response_audio = sidecar.join("7.response.wav");
        fs::write(&prompt_audio, b"RIFF....WAVE").unwrap();
        fs::write(&response_audio, b"RIFF....WAVE").unwrap();

        let mut state = test_state(
            deck_path,
            vec![datum_with_id(7, "one", "uno")],
            ReviewSession::Ready,
        );
        state.selected_ready_key = Some(DatumKey::for_card_id(7));

        let audio_files = selected_ready_audio_files(&state).unwrap().unwrap();

        assert_eq!(audio_files.len(), 2);
        assert_eq!(audio_files[0].side(), ReviewAudioSide::Prompt);
        assert_eq!(audio_files[0].path(), prompt_audio);
        assert_eq!(audio_files[1].side(), ReviewAudioSide::Response);
        assert_eq!(audio_files[1].path(), response_audio);

        let _ = fs::remove_file(audio_files[0].path());
        let _ = fs::remove_file(audio_files[1].path());
        let _ = fs::remove_dir(sidecar);
    }

    #[test]
    fn selected_ready_reverse_audio_files_use_swapped_parent_sides() {
        let deck_path = std::env::temp_dir().join(format!(
            "cosimo-ready-reverse-audio-test-{}.cosimo-deck",
            std::process::id()
        ));
        let sidecar = sidecar_dir_for_deck_path(&deck_path).unwrap();
        fs::create_dir_all(&sidecar).unwrap();
        let prompt_audio = sidecar.join("9.prompt.wav");
        let response_audio = sidecar.join("9.response.wav");
        fs::write(&prompt_audio, b"RIFF....WAVE").unwrap();
        fs::write(&response_audio, b"RIFF....WAVE").unwrap();

        let mut state = test_state(
            deck_path,
            vec![datum_with_id(9, "one", "uno")],
            ReviewSession::Ready,
        );
        state
            .store
            .set_datum_reversible(&DatumKey::for_card_id(9), true)
            .unwrap();
        state.selected_ready_key = Some(DatumKey::for_reverse_of(&DatumKey::for_card_id(9)));

        let audio_files = selected_ready_audio_files(&state).unwrap().unwrap();

        assert_eq!(audio_files.len(), 2);
        assert_eq!(audio_files[0].side(), ReviewAudioSide::Prompt);
        assert_eq!(audio_files[0].path(), response_audio);
        assert_eq!(audio_files[1].side(), ReviewAudioSide::Response);
        assert_eq!(audio_files[1].path(), prompt_audio);

        let _ = fs::remove_file(audio_files[0].path());
        let _ = fs::remove_file(audio_files[1].path());
        let _ = fs::remove_dir(sidecar);
    }

    #[test]
    fn ready_preview_audio_survives_ready_selection_changes() {
        let deck_path = std::env::temp_dir().join(format!(
            "cosimo-ready-audio-selection-test-{}.cosimo-deck",
            std::process::id()
        ));
        let sidecar = sidecar_dir_for_deck_path(&deck_path).unwrap();
        fs::create_dir_all(&sidecar).unwrap();
        let first_prompt_audio = sidecar.join("1.prompt.wav");
        let second_prompt_audio = sidecar.join("2.prompt.wav");
        fs::write(&first_prompt_audio, b"RIFF....WAVE").unwrap();
        fs::write(&second_prompt_audio, b"RIFF....WAVE").unwrap();

        let mut state = test_state(
            deck_path.clone(),
            vec![
                datum_with_id(1, "one", "uno"),
                datum_with_id(2, "two", "dos"),
            ],
            ReviewSession::Ready,
        );
        let playing_audio = audio_file_for_datum_key(
            &deck_path,
            &DatumKey::for_card_id(1),
            ReviewAudioSide::Prompt,
        )
        .unwrap();
        state.selected_ready_key = Some(DatumKey::for_card_id(2));

        assert!(
            !player_playing(&playing_audio).playing_context_should_stop_after_render(&state),
            "ready-list preview audio should not stop merely because selection changed"
        );

        let _ = fs::remove_file(first_prompt_audio);
        let _ = fs::remove_file(second_prompt_audio);
        let _ = fs::remove_dir(sidecar);
    }

    #[test]
    fn review_audio_stops_when_review_context_changes() {
        let deck_path = std::env::temp_dir().join(format!(
            "cosimo-review-audio-context-test-{}.cosimo-deck",
            std::process::id()
        ));
        let sidecar = sidecar_dir_for_deck_path(&deck_path).unwrap();
        fs::create_dir_all(&sidecar).unwrap();
        let first_prompt_audio = sidecar.join("1.prompt.wav");
        let second_prompt_audio = sidecar.join("2.prompt.wav");
        fs::write(&first_prompt_audio, b"RIFF....WAVE").unwrap();
        fs::write(&second_prompt_audio, b"RIFF....WAVE").unwrap();

        let state = test_state(
            deck_path.clone(),
            vec![
                datum_with_id(1, "one", "uno"),
                datum_with_id(2, "two", "dos"),
            ],
            ReviewSession::start(vec![1], 1, false, 3, 100).unwrap(),
        );
        let stale_audio = audio_file_for_datum_key(
            &deck_path,
            &DatumKey::for_card_id(1),
            ReviewAudioSide::Prompt,
        )
        .unwrap();

        assert!(
            player_playing(&stale_audio).playing_context_should_stop_after_render(&state),
            "ordinary review audio should stop when the current prompt/response changes"
        );

        let _ = fs::remove_file(first_prompt_audio);
        let _ = fs::remove_file(second_prompt_audio);
        let _ = fs::remove_dir(sidecar);
    }

    #[test]
    fn audio_is_ignored_when_policy_is_off() {
        let mut state = test_state(
            PathBuf::from("/tmp/deck.cosimo-deck"),
            vec![datum_with_id(1, "one", "uno")],
            ReviewSession::start(vec![0], 1, false, 3, 100).unwrap(),
        );
        state.settings.review_audio_policy = ReviewAudioPolicy::Off;

        assert!(current_review_audio_file(&state).is_none());
    }
}
