use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use cosimo::Datum;

use crate::review_audio::sidecar_dir_for_deck_path;

const AUDIO_MANIFEST_FILE_NAME: &str = "audio-manifest.tsv";
const MANUAL_AUDIO_MANIFEST_HASH_PREFIX: &str = "manual:";
const AUDIO_EXTENSIONS_BY_PREFERENCE: &[AudioExtension] = &[
    AudioExtension::Wav,
    AudioExtension::Mp3,
    AudioExtension::Opus,
];

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum AudioExtension {
    Opus,
    Mp3,
    Wav,
}

impl AudioExtension {
    fn parse(value: &str) -> Option<Self> {
        match value.to_ascii_lowercase().as_str() {
            "opus" => Some(Self::Opus),
            "mp3" => Some(Self::Mp3),
            "wav" => Some(Self::Wav),
            _ => None,
        }
    }

    fn preference_index(self) -> usize {
        AUDIO_EXTENSIONS_BY_PREFERENCE
            .iter()
            .position(|extension| *extension == self)
            .expect("known extension must be present in preference order")
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum AudioSide {
    Prompt,
    Response,
}

impl AudioSide {
    fn parse(value: &str) -> Option<Self> {
        match value {
            "prompt" => Some(Self::Prompt),
            "response" => Some(Self::Response),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AudioOwnership {
    Generated,
    Manual,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InoperativeAudioReason {
    RemovedCard,
    ShadowedByPreferredFormat,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InoperativeAudioFile {
    pub(crate) path: PathBuf,
    pub(crate) file_name: String,
    pub(crate) reason: InoperativeAudioReason,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InoperativeAudioPlan {
    pub(crate) sidecar_dir: PathBuf,
    pub(crate) files: Vec<InoperativeAudioFile>,
}

impl InoperativeAudioPlan {
    pub(crate) fn is_empty(&self) -> bool {
        self.files.is_empty()
    }

    pub(crate) fn removed_card_count(&self) -> usize {
        self.files
            .iter()
            .filter(|file| file.reason == InoperativeAudioReason::RemovedCard)
            .count()
    }

    pub(crate) fn shadowed_format_count(&self) -> usize {
        self.files
            .iter()
            .filter(|file| file.reason == InoperativeAudioReason::ShadowedByPreferredFormat)
            .count()
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct InoperativeAudioCleanupSummary {
    pub(crate) removed_files: usize,
    pub(crate) removed_card_files: usize,
    pub(crate) shadowed_format_files: usize,
    pub(crate) manifest_entries_removed: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct AudioFileCandidate {
    card_id: u64,
    side: AudioSide,
    extension: AudioExtension,
    ownership: AudioOwnership,
    file_name: String,
    path: PathBuf,
}

pub(crate) fn plan_inoperative_audio_cleanup(
    deck_path: &Path,
    data: &[Datum],
) -> io::Result<InoperativeAudioPlan> {
    let sidecar_dir = sidecar_dir_for_deck_path(deck_path)
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "deck path has no stem"))?;
    let active_card_ids: HashSet<u64> = data.iter().filter_map(Datum::card_id).collect();
    let manifest = AudioManifest::read(&sidecar_dir.join(AUDIO_MANIFEST_FILE_NAME))?;
    let candidates = audio_file_candidates(&sidecar_dir, &manifest)?;
    let candidates_by_side = audio_candidates_by_side(&candidates);

    let mut files = Vec::new();
    for candidate in candidates {
        if candidate.ownership != AudioOwnership::Generated {
            continue;
        }
        let reason = if !active_card_ids.contains(&candidate.card_id) {
            Some(InoperativeAudioReason::RemovedCard)
        } else if manual_or_better_generated_format_exists(&candidate, &candidates_by_side) {
            Some(InoperativeAudioReason::ShadowedByPreferredFormat)
        } else {
            None
        };

        if let Some(reason) = reason {
            files.push(InoperativeAudioFile {
                path: candidate.path,
                file_name: candidate.file_name,
                reason,
            });
        }
    }
    files.sort_by(|left, right| left.file_name.cmp(&right.file_name));

    Ok(InoperativeAudioPlan { sidecar_dir, files })
}

fn audio_file_candidates(
    sidecar_dir: &Path,
    manifest: &AudioManifest,
) -> io::Result<Vec<AudioFileCandidate>> {
    let entries = match fs::read_dir(sidecar_dir) {
        Ok(entries) => entries,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(error) => return Err(error),
    };
    let mut candidates = Vec::new();
    for entry in entries {
        let entry = entry?;
        if !entry.file_type()?.is_file() {
            continue;
        }
        let file_name = entry.file_name();
        let Some(file_name) = file_name.to_str() else {
            continue;
        };
        if let Some((card_id, side, extension)) = parse_audio_file_name(file_name) {
            candidates.push(AudioFileCandidate {
                card_id,
                side,
                extension,
                ownership: manifest.ownership(file_name),
                file_name: file_name.to_string(),
                path: entry.path(),
            });
        }
    }
    Ok(candidates)
}

fn parse_audio_file_name(file_name: &str) -> Option<(u64, AudioSide, AudioExtension)> {
    let mut parts = file_name.split('.');
    let card_id = parts.next()?.parse::<u64>().ok().filter(|id| *id > 0)?;
    let side = AudioSide::parse(parts.next()?)?;
    let extension = AudioExtension::parse(parts.next()?)?;
    parts.next().is_none().then_some((card_id, side, extension))
}

fn audio_candidates_by_side(
    candidates: &[AudioFileCandidate],
) -> BTreeMap<(u64, AudioSide), Vec<(AudioExtension, AudioOwnership)>> {
    let mut by_side: BTreeMap<(u64, AudioSide), Vec<(AudioExtension, AudioOwnership)>> =
        BTreeMap::new();
    for candidate in candidates {
        by_side
            .entry((candidate.card_id, candidate.side))
            .or_default()
            .push((candidate.extension, candidate.ownership));
    }
    by_side
}

fn manual_or_better_generated_format_exists(
    candidate: &AudioFileCandidate,
    candidates_by_side: &BTreeMap<(u64, AudioSide), Vec<(AudioExtension, AudioOwnership)>>,
) -> bool {
    let candidate_preference = candidate.extension.preference_index();
    candidates_by_side
        .get(&(candidate.card_id, candidate.side))
        .is_some_and(|extensions| {
            extensions
                .iter()
                .any(|(extension, ownership)| match ownership {
                    AudioOwnership::Manual => true,
                    AudioOwnership::Generated => {
                        extension.preference_index() < candidate_preference
                    }
                })
        })
}

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

impl AudioManifest {
    fn read(path: &Path) -> io::Result<Self> {
        let text = match fs::read_to_string(path) {
            Ok(text) => text,
            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
            Err(error) => return Err(error),
        };
        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(),
                AudioManifestEntry {
                    text_hash: (*text_hash).to_string(),
                },
            );
        }
        Ok(manifest)
    }

    fn ownership(&self, file_name: &str) -> AudioOwnership {
        self.entries
            .get(file_name)
            .map(AudioManifestEntry::ownership)
            .unwrap_or(AudioOwnership::Manual)
    }
}

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

impl AudioManifestEntry {
    fn ownership(&self) -> AudioOwnership {
        if self
            .text_hash
            .starts_with(MANUAL_AUDIO_MANIFEST_HASH_PREFIX)
        {
            AudioOwnership::Manual
        } else {
            AudioOwnership::Generated
        }
    }
}

pub(crate) fn remove_inoperative_audio_files(
    plan: &InoperativeAudioPlan,
) -> io::Result<InoperativeAudioCleanupSummary> {
    let mut removed_file_names = HashSet::new();
    let mut summary = InoperativeAudioCleanupSummary {
        removed_files: plan.files.len(),
        removed_card_files: plan.removed_card_count(),
        shadowed_format_files: plan.shadowed_format_count(),
        manifest_entries_removed: 0,
    };

    for file in &plan.files {
        fs::remove_file(&file.path).map_err(|source| {
            io::Error::new(
                source.kind(),
                format!("could not delete {}: {source}", file.path.display()),
            )
        })?;
        removed_file_names.insert(file.file_name.clone());
    }

    summary.manifest_entries_removed =
        remove_audio_manifest_entries(&plan.sidecar_dir, &removed_file_names)?;
    Ok(summary)
}

fn remove_audio_manifest_entries(
    sidecar_dir: &Path,
    removed_file_names: &HashSet<String>,
) -> io::Result<usize> {
    if removed_file_names.is_empty() {
        return Ok(0);
    }
    let manifest_path = sidecar_dir.join(AUDIO_MANIFEST_FILE_NAME);
    let text = match fs::read_to_string(&manifest_path) {
        Ok(text) => text,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
        Err(error) => return Err(error),
    };

    let mut removed_count = 0;
    let mut retained = String::new();
    for line in text.lines() {
        let file_name = line.split('\t').next().unwrap_or("");
        if removed_file_names.contains(file_name) {
            removed_count += 1;
        } else {
            retained.push_str(line);
            retained.push('\n');
        }
    }
    if removed_count > 0 {
        fs::write(&manifest_path, retained)?;
    }
    Ok(removed_count)
}

#[cfg(test)]
mod tests {
    use super::*;
    use cosimo::Datum;

    fn datum_with_id(card_id: u64) -> Datum {
        Datum::new(format!("prompt {card_id}"), format!("response {card_id}"))
            .unwrap()
            .with_card_id(card_id)
    }

    fn unique_temp_dir(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "cosimo-audio-cleanup-{name}-{}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn cleanup_plan_finds_removed_card_audio_and_shadowed_wav_files() {
        let root = unique_temp_dir("plan");
        let deck_path = root.join("Indonesian.cosimo-deck");
        let sidecar_dir = root.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("1.prompt.opus"), b"preferred").unwrap();
        fs::write(sidecar_dir.join("1.prompt.wav"), b"shadowed").unwrap();
        fs::write(sidecar_dir.join("1.response.wav"), b"playable").unwrap();
        fs::write(sidecar_dir.join("2.prompt.wav"), b"removed").unwrap();
        fs::write(sidecar_dir.join("notes.wav"), b"ignored").unwrap();
        fs::write(
            sidecar_dir.join("audio-manifest.tsv"),
            "version\t2\t\t\n1.prompt.opus\thash\tonecore\tvoice\n1.prompt.wav\thash\tonecore\tvoice\n1.response.wav\thash\tonecore\tvoice\n2.prompt.wav\thash\tonecore\tvoice\n",
        )
        .unwrap();

        let plan = plan_inoperative_audio_cleanup(&deck_path, &[datum_with_id(1)]).unwrap();
        let file_names: Vec<_> = plan
            .files
            .iter()
            .map(|file| (file.file_name.as_str(), file.reason))
            .collect();

        assert_eq!(
            file_names,
            vec![
                (
                    "1.prompt.opus",
                    InoperativeAudioReason::ShadowedByPreferredFormat
                ),
                ("2.prompt.wav", InoperativeAudioReason::RemovedCard),
            ]
        );

        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn cleanup_deletes_planned_files_and_prunes_manifest_entries() {
        let root = unique_temp_dir("delete");
        let deck_path = root.join("Indonesian.cosimo-deck");
        let sidecar_dir = root.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("1.prompt.opus"), b"preferred").unwrap();
        fs::write(sidecar_dir.join("1.prompt.wav"), b"shadowed").unwrap();
        fs::write(sidecar_dir.join("2.response.wav"), b"removed").unwrap();
        fs::write(
            sidecar_dir.join("audio-manifest.tsv"),
            "version\t2\t\t\n1.prompt.opus\thash\tonecore\tvoice\n1.prompt.wav\thash\tonecore\tvoice\n2.response.wav\thash\tonecore\tvoice\n",
        )
        .unwrap();

        let plan = plan_inoperative_audio_cleanup(&deck_path, &[datum_with_id(1)]).unwrap();
        let summary = remove_inoperative_audio_files(&plan).unwrap();

        assert_eq!(
            summary,
            InoperativeAudioCleanupSummary {
                removed_files: 2,
                removed_card_files: 1,
                shadowed_format_files: 1,
                manifest_entries_removed: 2,
            }
        );
        assert!(!sidecar_dir.join("1.prompt.opus").exists());
        assert!(sidecar_dir.join("1.prompt.wav").exists());
        assert!(!sidecar_dir.join("2.response.wav").exists());
        let manifest = fs::read_to_string(sidecar_dir.join("audio-manifest.tsv")).unwrap();
        assert!(!manifest.contains("1.prompt.opus"));
        assert!(manifest.contains("1.prompt.wav"));
        assert!(!manifest.contains("2.response.wav"));

        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn cleanup_preserves_manual_audio_even_when_removed_or_shadowing_generated_audio() {
        let root = unique_temp_dir("manual");
        let deck_path = root.join("Indonesian.cosimo-deck");
        let sidecar_dir = root.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("1.prompt.wav"), b"generated").unwrap();
        fs::write(sidecar_dir.join("1.prompt.mp3"), b"manual").unwrap();
        fs::write(sidecar_dir.join("2.response.wav"), b"manual removed card").unwrap();
        fs::write(
            sidecar_dir.join("audio-manifest.tsv"),
            "version\t2\t\t\n1.prompt.wav\thash\tonecore\tvoice\n1.prompt.mp3\tmanual:anki\tonecore\t\n2.response.wav\tmanual:recording\tonecore\t\n",
        )
        .unwrap();

        let plan = plan_inoperative_audio_cleanup(&deck_path, &[datum_with_id(1)]).unwrap();
        let file_names: Vec<_> = plan
            .files
            .iter()
            .map(|file| (file.file_name.as_str(), file.reason))
            .collect();

        assert_eq!(
            file_names,
            vec![(
                "1.prompt.wav",
                InoperativeAudioReason::ShadowedByPreferredFormat
            )]
        );

        let _ = fs::remove_dir_all(root);
    }
}
