use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use wxdragon::prelude::{Sound, SoundFlags};

use crate::app_config::ReviewAudioPolicy;
use crate::app_state::AppState;
use crate::review_session::ReviewPhase;
use crate::review_ui::ReviewUi;
use cosimo::DatumKey;

const WAV_EXTENSION: &str = "wav";

#[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
    }
}

#[derive(Debug, Clone)]
struct PlayingReviewAudio {
    context: ReviewAudioContext,
    started_at: Instant,
    duration: Option<Duration>,
}

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

impl ReviewAudioPlayer {
    pub(crate) fn stop(&mut self) {
        if self.playing.take().is_some() {
            Sound::stop();
        }
    }

    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.context == audio_file.context)
        {
            self.stop();
            return true;
        }

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

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

        if self.playing.as_ref().is_some_and(|playing| {
            current_audio
                .as_ref()
                .is_none_or(|audio_file| audio_file.context != playing.context)
        }) {
            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_audio {
                    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);
                    }
                    self.last_automatic_context = Some(audio_file.context);
                } else {
                    self.last_automatic_context = None;
                }
            }
        }
    }

    pub(crate) fn toggle_audio_file(&mut self, audio_file: ReviewAudioFile) -> Result<(), PathBuf> {
        self.clear_finished();
        if self
            .playing
            .as_ref()
            .is_some_and(|playing| playing.context == audio_file.context)
        {
            self.stop();
            return Ok(());
        }

        self.play_audio_file(audio_file)
    }

    pub(crate) fn play_audio_file(&mut self, audio_file: ReviewAudioFile) -> Result<(), PathBuf> {
        self.stop();
        if Sound::play_file(&audio_file.path().to_string_lossy(), SoundFlags::Async) {
            self.playing = Some(PlayingReviewAudio {
                duration: wav_duration(audio_file.path()),
                started_at: Instant::now(),
                context: audio_file.context,
            });
            Ok(())
        } else {
            Err(audio_file.path().to_path_buf())
        }
    }

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

    fn clear_finished(&mut self) {
        if self.playing.as_ref().is_some_and(|playing| {
            playing
                .duration
                .is_some_and(|duration| playing.started_at.elapsed() >= duration)
        }) {
            self.playing = None;
        }
    }
}

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

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

fn wav_duration(path: &Path) -> Option<Duration> {
    let bytes = fs::read(path).ok()?;
    if bytes.len() < 44 || bytes.get(0..4)? != b"RIFF" || bytes.get(8..12)? != b"WAVE" {
        return None;
    }

    let mut byte_rate = None;
    let mut data_bytes = None;
    let mut offset = 12;
    while offset + 8 <= bytes.len() {
        let chunk_id = bytes.get(offset..offset + 4)?;
        let chunk_size = u32::from_le_bytes(bytes.get(offset + 4..offset + 8)?.try_into().ok()?);
        let chunk_data_start = offset + 8;
        let chunk_data_end = chunk_data_start.saturating_add(chunk_size as usize);
        if chunk_data_end > bytes.len() {
            return None;
        }

        if chunk_id == b"fmt " && chunk_size >= 16 {
            byte_rate = Some(u32::from_le_bytes(
                bytes.get(offset + 16..offset + 20)?.try_into().ok()?,
            ));
        } else if chunk_id == b"data" {
            data_bytes = Some(chunk_size);
        }

        offset = chunk_data_end + (chunk_size as usize % 2);
    }

    let byte_rate = byte_rate?;
    if byte_rate == 0 {
        return None;
    }
    Some(Duration::from_secs_f64(
        data_bytes? as f64 / byte_rate as f64,
    ))
}

#[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),
            last_ready_due_notification_count: None,
            store: SqliteReviewStore::open_in_memory().unwrap(),
            last_status: String::new(),
            review_audio_player: RefCell::new(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"))
        );
    }

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