#![deny(clippy::undocumented_unsafe_blocks)]

use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fmt;
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};

use cosimo::{Datum, DeckMetadata, parse_deck_file};

const PROGRAM_NAME: &str = "voice";
const DEFAULT_DECK_DIR: &str = "decks";
const DECK_EXTENSION: &str = "cosimo-deck";
const OPUS_EXTENSION: &str = "opus";
const MP3_EXTENSION: &str = "mp3";
const WAV_EXTENSION: &str = "wav";
const MANIFEST_FILE_NAME: &str = "audio-manifest.tsv";
const MANUAL_AUDIO_MANIFEST_HASH_PREFIX: &str = "manual:";
const PROMPT_VOICE_METADATA_KEY: &str = "prompt_voice";
const RESPONSE_VOICE_METADATA_KEY: &str = "response_voice";
const PROMPT_VOICE_ENGINE_METADATA_KEY: &str = "prompt_voice_engine";
const RESPONSE_VOICE_ENGINE_METADATA_KEY: &str = "response_voice_engine";

fn main() {
    if let Err(error) = run(env::args().skip(1)) {
        eprintln!("error: {error}");
        std::process::exit(1);
    }
}

fn run(args: impl IntoIterator<Item = String>) -> Result<(), ToolError> {
    let options = Options::parse(args)?;

    if options.help {
        print_help();
        return Ok(());
    }

    if options.list_voices {
        return list_voices(
            options
                .voices
                .default_engine
                .unwrap_or(VoiceEngine::OneCore),
            options.machine,
        );
    }

    let deck_path = options
        .deck_path
        .as_deref()
        .ok_or(ToolError::MissingDeckPath)?;
    let deck_text =
        fs::read_to_string(deck_path).map_err(|source| ToolError::ReadDeck { source })?;
    let deck_file = parse_deck_file(&deck_text).map_err(|source| ToolError::ParseDeck {
        source: source.to_string(),
    })?;
    if deck_file.metadata().audio_generation_is_disabled() {
        return Err(ToolError::AudioGenerationDisabled);
    }
    let sidecar_dir = sidecar_dir_for_deck_path(deck_path).ok_or(ToolError::InvalidDeckPath)?;
    let manifest_path = manifest_path_for_deck_path(deck_path).ok_or(ToolError::InvalidDeckPath)?;
    let manifest_exists = manifest_path.exists();
    let manifest = AudioManifest::read(&manifest_path)?;
    let voices = options
        .voices
        .with_deck_metadata_defaults(deck_file.metadata())?;
    let plan = generation_plan(
        deck_path,
        deck_file.data(),
        options.side,
        &voices,
        &manifest,
        options.output_format,
        options.overwrite,
        manifest_exists,
    );

    if !options.dry_run {
        fs::create_dir_all(&sidecar_dir).map_err(|source| ToolError::CreateSidecar {
            path: sidecar_dir.clone(),
            source,
        })?;
    }

    #[cfg(windows)]
    let onecore_synthesizer = if !options.dry_run && plan.uses_engine(VoiceEngine::OneCore) {
        Some(onecore::OneCoreSynthesizer::new(&voices)?)
    } else {
        None
    };
    #[cfg(windows)]
    let sapi_synthesizer = if !options.dry_run && plan.uses_engine(VoiceEngine::Sapi) {
        Some(sapi::SapiSynthesizer::new(&voices)?)
    } else {
        None
    };

    #[cfg_attr(not(windows), allow(unused_mut))]
    let mut updated_manifest = manifest;
    let mut summary = GenerationSummary::default();
    let mut completed_items = 0;

    emit_progress_total(options.progress, plan.items.len());

    for item in &plan.items {
        match item {
            GenerationItem::Generate {
                card_id,
                side,
                engine,
                text,
                output,
                manifest_key,
                signature,
                reason,
            } => {
                if options.dry_run {
                    println!("would generate {}", output.display());
                } else {
                    #[cfg(windows)]
                    {
                        let wav_bytes = match engine {
                            VoiceEngine::OneCore => onecore_synthesizer
                                .as_ref()
                                .unwrap()
                                .synthesize(*side, text)?,
                            VoiceEngine::Sapi => {
                                let temp_wav = temporary_wav_path(output);
                                let synthesize_result = (|| {
                                    sapi_synthesizer
                                        .as_ref()
                                        .unwrap()
                                        .speak_to_wav(*side, text, &temp_wav)?;
                                    fs::read(&temp_wav).map_err(|source| {
                                        ToolError::ReadTemporaryAudio {
                                            path: temp_wav.clone(),
                                            source,
                                        }
                                    })
                                })();
                                let remove_result = fs::remove_file(&temp_wav);
                                match synthesize_result {
                                    Ok(bytes) => {
                                        if let Err(error) = remove_result
                                            && error.kind() != ErrorKind::NotFound
                                        {
                                            eprintln!(
                                                "warning: could not remove temporary WAV file {}: {error}",
                                                temp_wav.display()
                                            );
                                        }
                                        bytes
                                    }
                                    Err(error) => {
                                        let _ = remove_result;
                                        return Err(error);
                                    }
                                }
                            }
                        };
                        match options.output_format {
                            AudioOutputFormat::Opus => {
                                audio_encoding::write_opus_from_wav_bytes(output, &wav_bytes)
                                    .map_err(|source| ToolError::EncodeAudio {
                                        path: output.clone(),
                                        source,
                                    })?;
                            }
                            AudioOutputFormat::Wav => {
                                fs::write(output, wav_bytes).map_err(|source| {
                                    ToolError::WriteAudio {
                                        path: output.clone(),
                                        source,
                                    }
                                })?;
                            }
                        }
                        updated_manifest.set(manifest_key.clone(), signature.clone());
                    }

                    #[cfg(not(windows))]
                    {
                        let _ = (
                            card_id,
                            side,
                            engine,
                            text,
                            output,
                            manifest_key,
                            signature,
                            reason,
                        );
                        return Err(ToolError::WindowsOnly);
                    }
                }
                summary.record_generated(*card_id, *reason);
            }
            GenerationItem::SkipManagedUnchanged { output } => {
                println!("skipping unchanged {}", output.display());
                summary.skipped_unchanged += 1;
            }
            GenerationItem::PreserveUntrackedExisting { output } => {
                println!("preserving existing untracked audio {}", output.display());
                summary.preserved_existing_files += 1;
            }
            GenerationItem::SkipMissingCardId { index } => {
                eprintln!(
                    "skipping card {} because it has no stable card ID; open and save the deck in Cosimo first",
                    index + 1
                );
                summary.skipped_missing_id += 1;
            }
            GenerationItem::SkipEmptyText { card_id, side } => {
                eprintln!("skipping card {card_id} {side} because its text is empty");
                summary.skipped_empty_text += 1;
            }
        }
        completed_items += 1;
        emit_progress_step(options.progress, completed_items, plan.items.len());
    }

    if !options.dry_run && summary.generated_files() > 0 {
        updated_manifest.write(&manifest_path)?;
    }

    println!(
        "cards with generated audio {generated_cards}; generated new files {generated_new_files}; regenerated changed files {regenerated_changed_files}; regenerated untracked files {regenerated_untracked_files}; force-regenerated by overwrite {overwritten_files}; preserved existing files {preserved_existing_files}; skipped unchanged {skipped_unchanged}; skipped missing IDs {skipped_missing_id}; skipped empty text {skipped_empty_text}",
        generated_cards = summary.generated_cards(),
        generated_new_files = summary.generated_new_files,
        regenerated_changed_files = summary.regenerated_changed_files,
        regenerated_untracked_files = summary.regenerated_untracked_files,
        overwritten_files = summary.overwritten_files,
        preserved_existing_files = summary.preserved_existing_files,
        skipped_unchanged = summary.skipped_unchanged,
        skipped_missing_id = summary.skipped_missing_id,
        skipped_empty_text = summary.skipped_empty_text,
    );
    emit_progress_summary(options.progress, &summary);
    Ok(())
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GenerationReason {
    MissingFile,
    ManagedRefresh,
}

#[derive(Debug, Default)]
struct GenerationSummary {
    generated_card_ids: BTreeSet<u64>,
    generated_new_files: usize,
    regenerated_changed_files: usize,
    regenerated_untracked_files: usize,
    overwritten_files: usize,
    preserved_existing_files: usize,
    skipped_unchanged: usize,
    skipped_missing_id: usize,
    skipped_empty_text: usize,
}

impl GenerationSummary {
    fn record_generated(&mut self, card_id: u64, reason: GenerationReason) {
        self.generated_card_ids.insert(card_id);
        match reason {
            GenerationReason::MissingFile => self.generated_new_files += 1,
            GenerationReason::ManagedRefresh => self.regenerated_changed_files += 1,
        }
    }

    fn generated_cards(&self) -> usize {
        self.generated_card_ids.len()
    }

    fn generated_files(&self) -> usize {
        self.generated_new_files
            + self.regenerated_changed_files
            + self.regenerated_untracked_files
            + self.overwritten_files
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum AudioOutputFormat {
    #[default]
    Opus,
    Wav,
}

impl AudioOutputFormat {
    fn extension(self) -> &'static str {
        match self {
            Self::Opus => OPUS_EXTENSION,
            Self::Wav => WAV_EXTENSION,
        }
    }

    fn parse(value: &str) -> Result<Self, ToolError> {
        match value.trim().to_ascii_lowercase().as_str() {
            "opus" => Ok(Self::Opus),
            "wav" | "wave" => Ok(Self::Wav),
            _ => Err(ToolError::InvalidOutputFormat(value.to_string())),
        }
    }
}

impl fmt::Display for AudioOutputFormat {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.extension())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct Options {
    deck_path: Option<PathBuf>,
    side: AudioSides,
    voices: VoiceSelection,
    output_format: AudioOutputFormat,
    overwrite: bool,
    dry_run: bool,
    list_voices: bool,
    machine: bool,
    progress: bool,
    help: bool,
}

impl Default for Options {
    fn default() -> Self {
        Self {
            deck_path: None,
            side: AudioSides::Both,
            voices: VoiceSelection::default(),
            output_format: AudioOutputFormat::Opus,
            overwrite: false,
            dry_run: false,
            list_voices: false,
            machine: false,
            progress: false,
            help: false,
        }
    }
}

impl Options {
    fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, ToolError> {
        Self::parse_with_portable_root(args, &portable_root_dir())
    }

    fn parse_with_portable_root(
        args: impl IntoIterator<Item = String>,
        portable_root: &Path,
    ) -> Result<Self, ToolError> {
        let mut options = Self::default();
        let mut args = args.into_iter();

        while let Some(arg) = args.next() {
            match arg.as_str() {
                "-h" | "--help" => options.help = true,
                "--list-voices" => options.list_voices = true,
                "--machine" | "--tsv" => options.machine = true,
                "--overwrite" => options.overwrite = true,
                "--dry-run" => options.dry_run = true,
                "--progress" => options.progress = true,
                "--format" | "--audio-format" => {
                    let value = args.next().ok_or_else(|| ToolError::MissingOptionValue {
                        option: arg.clone(),
                    })?;
                    options.output_format = AudioOutputFormat::parse(&value)?;
                }
                "--side" => {
                    let value = args.next().ok_or_else(|| ToolError::MissingOptionValue {
                        option: "--side".to_string(),
                    })?;
                    options.side = value.parse()?;
                }
                "--voice" => {
                    options.voices.default =
                        Some(args.next().ok_or_else(|| ToolError::MissingOptionValue {
                            option: "--voice".to_string(),
                        })?);
                }
                "--engine" | "--voice-engine" => {
                    let value = args.next().ok_or_else(|| ToolError::MissingOptionValue {
                        option: arg.clone(),
                    })?;
                    options.voices.default_engine = Some(value.parse()?);
                }
                "--prompt-voice" => {
                    options.voices.prompt =
                        Some(args.next().ok_or_else(|| ToolError::MissingOptionValue {
                            option: "--prompt-voice".to_string(),
                        })?);
                }
                "--prompt-engine" | "--prompt-voice-engine" => {
                    let value = args.next().ok_or_else(|| ToolError::MissingOptionValue {
                        option: arg.clone(),
                    })?;
                    options.voices.prompt_engine = Some(value.parse()?);
                }
                "--response-voice" => {
                    options.voices.response =
                        Some(args.next().ok_or_else(|| ToolError::MissingOptionValue {
                            option: "--response-voice".to_string(),
                        })?);
                }
                "--response-engine" | "--response-voice-engine" => {
                    let value = args.next().ok_or_else(|| ToolError::MissingOptionValue {
                        option: arg.clone(),
                    })?;
                    options.voices.response_engine = Some(value.parse()?);
                }
                _ if arg.starts_with("--side=") => {
                    options.side = arg["--side=".len()..].parse()?;
                }
                _ if arg.starts_with("--format=") => {
                    options.output_format = AudioOutputFormat::parse(&arg["--format=".len()..])?;
                }
                _ if arg.starts_with("--audio-format=") => {
                    options.output_format =
                        AudioOutputFormat::parse(&arg["--audio-format=".len()..])?;
                }
                _ if arg.starts_with("--voice=") => {
                    options.voices.default = Some(arg["--voice=".len()..].to_string());
                }
                _ if arg.starts_with("--engine=") => {
                    options.voices.default_engine = Some(arg["--engine=".len()..].parse()?);
                }
                _ if arg.starts_with("--voice-engine=") => {
                    options.voices.default_engine = Some(arg["--voice-engine=".len()..].parse()?);
                }
                _ if arg.starts_with("--prompt-voice=") => {
                    options.voices.prompt = Some(arg["--prompt-voice=".len()..].to_string());
                }
                _ if arg.starts_with("--prompt-engine=") => {
                    options.voices.prompt_engine = Some(arg["--prompt-engine=".len()..].parse()?);
                }
                _ if arg.starts_with("--prompt-voice-engine=") => {
                    options.voices.prompt_engine =
                        Some(arg["--prompt-voice-engine=".len()..].parse()?);
                }
                _ if arg.starts_with("--response-voice=") => {
                    options.voices.response = Some(arg["--response-voice=".len()..].to_string());
                }
                _ if arg.starts_with("--response-engine=") => {
                    options.voices.response_engine =
                        Some(arg["--response-engine=".len()..].parse()?);
                }
                _ if arg.starts_with("--response-voice-engine=") => {
                    options.voices.response_engine =
                        Some(arg["--response-voice-engine=".len()..].parse()?);
                }
                _ if arg.starts_with('-') => return Err(ToolError::UnknownOption(arg)),
                _ => {
                    if options
                        .deck_path
                        .replace(resolve_deck_argument(&arg, portable_root))
                        .is_some()
                    {
                        return Err(ToolError::TooManyDeckPaths);
                    }
                }
            }
        }

        if !options.help && !options.list_voices && options.deck_path.is_none() {
            return Err(ToolError::MissingDeckPath);
        }

        Ok(options)
    }
}

fn emit_progress_total(enabled: bool, total: usize) {
    if enabled {
        println!("cosimo-progress-total\t{total}");
        let _ = std::io::stdout().flush();
    }
}

fn emit_progress_step(enabled: bool, completed: usize, total: usize) {
    if enabled {
        println!("cosimo-progress-step\t{completed}\t{total}");
        let _ = std::io::stdout().flush();
    }
}

fn emit_progress_summary(enabled: bool, summary: &GenerationSummary) {
    if enabled {
        println!(
            "cosimo-progress-summary\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
            summary.generated_cards(),
            summary.generated_new_files,
            summary.regenerated_changed_files,
            summary.regenerated_untracked_files,
            summary.overwritten_files,
            summary.preserved_existing_files,
            summary.skipped_unchanged,
            summary.skipped_missing_id,
            summary.skipped_empty_text,
        );
        let _ = std::io::stdout().flush();
    }
}

fn portable_root_dir() -> PathBuf {
    env::current_exe()
        .ok()
        .and_then(|path| path.parent().map(Path::to_path_buf))
        .unwrap_or_else(|| PathBuf::from("."))
}

fn resolve_deck_argument(arg: &str, portable_root: &Path) -> PathBuf {
    let path = Path::new(arg);
    if path
        .parent()
        .is_none_or(|parent| parent.as_os_str().is_empty())
        && path.extension().is_none()
    {
        portable_root
            .join(DEFAULT_DECK_DIR)
            .join(format!("{arg}.{DECK_EXTENSION}"))
    } else {
        path.to_path_buf()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VoiceEngine {
    OneCore,
    Sapi,
}

impl VoiceEngine {
    fn key(self) -> &'static str {
        match self {
            Self::OneCore => "onecore",
            Self::Sapi => "sapi",
        }
    }
}

impl fmt::Display for VoiceEngine {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.key())
    }
}

impl std::str::FromStr for VoiceEngine {
    type Err = ToolError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value.trim().to_ascii_lowercase().as_str() {
            "onecore" | "one-core" => Ok(Self::OneCore),
            "sapi" => Ok(Self::Sapi),
            _ => Err(ToolError::InvalidVoiceEngine(value.to_string())),
        }
    }
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct VoiceSelection {
    default: Option<String>,
    prompt: Option<String>,
    response: Option<String>,
    default_engine: Option<VoiceEngine>,
    prompt_engine: Option<VoiceEngine>,
    response_engine: Option<VoiceEngine>,
}

impl VoiceSelection {
    fn filter_for_side(&self, side: AudioSide) -> Option<&str> {
        match side {
            AudioSide::Prompt => self.prompt.as_ref().or(self.default.as_ref()),
            AudioSide::Response => self.response.as_ref().or(self.default.as_ref()),
        }
        .map(String::as_str)
    }

    fn engine_for_side(&self, side: AudioSide) -> VoiceEngine {
        match side {
            AudioSide::Prompt => self.prompt_engine.or(self.default_engine),
            AudioSide::Response => self.response_engine.or(self.default_engine),
        }
        .unwrap_or(VoiceEngine::OneCore)
    }

    #[cfg_attr(not(windows), allow(dead_code))]
    fn filter_for_engine_side(&self, side: AudioSide, engine: VoiceEngine) -> Option<&str> {
        if self.engine_for_side(side) == engine {
            self.filter_for_side(side)
        } else {
            None
        }
    }

    fn signature_for_side(&self, side: AudioSide, text: &str) -> AudioManifestEntry {
        AudioManifestEntry::new(text, self.engine_for_side(side), self.filter_for_side(side))
    }

    fn with_deck_metadata_defaults(&self, metadata: &DeckMetadata) -> Result<Self, ToolError> {
        Ok(Self {
            default: None,
            prompt: self
                .prompt
                .clone()
                .or_else(|| self.default.clone())
                .or_else(|| metadata_voice(metadata, PROMPT_VOICE_METADATA_KEY)),
            response: self
                .response
                .clone()
                .or_else(|| self.default.clone())
                .or_else(|| metadata_voice(metadata, RESPONSE_VOICE_METADATA_KEY)),
            default_engine: None,
            prompt_engine: self
                .prompt_engine
                .or(self.default_engine)
                .or(metadata_voice_engine(
                    metadata,
                    PROMPT_VOICE_ENGINE_METADATA_KEY,
                )?),
            response_engine: self.response_engine.or(self.default_engine).or(
                metadata_voice_engine(metadata, RESPONSE_VOICE_ENGINE_METADATA_KEY)?,
            ),
        })
    }
}

fn metadata_voice(metadata: &DeckMetadata, key: &str) -> Option<String> {
    metadata
        .get(key)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

fn metadata_voice_engine(
    metadata: &DeckMetadata,
    key: &str,
) -> Result<Option<VoiceEngine>, ToolError> {
    metadata
        .get(key)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::parse)
        .transpose()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AudioSides {
    Prompt,
    Response,
    Both,
}

impl AudioSides {
    fn contains(self, side: AudioSide) -> bool {
        matches!(
            (self, side),
            (Self::Prompt, AudioSide::Prompt)
                | (Self::Response, AudioSide::Response)
                | (Self::Both, _)
        )
    }
}

impl std::str::FromStr for AudioSides {
    type Err = ToolError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match value {
            "prompt" => Ok(Self::Prompt),
            "response" => Ok(Self::Response),
            "both" => Ok(Self::Both),
            _ => Err(ToolError::InvalidSide(value.to_string())),
        }
    }
}

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

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

impl fmt::Display for AudioSide {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.suffix())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct GenerationPlan {
    items: Vec<GenerationItem>,
}

impl GenerationPlan {
    #[cfg(windows)]
    fn uses_engine(&self, engine: VoiceEngine) -> bool {
        self.items.iter().any(|item| {
            matches!(
                item,
                GenerationItem::Generate {
                    engine: item_engine,
                    ..
                } if *item_engine == engine
            )
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum GenerationItem {
    Generate {
        card_id: u64,
        side: AudioSide,
        engine: VoiceEngine,
        text: String,
        output: PathBuf,
        manifest_key: String,
        signature: AudioManifestEntry,
        reason: GenerationReason,
    },
    PreserveUntrackedExisting {
        output: PathBuf,
    },
    SkipManagedUnchanged {
        output: PathBuf,
    },
    SkipMissingCardId {
        index: usize,
    },
    SkipEmptyText {
        card_id: u64,
        side: AudioSide,
    },
}

#[allow(clippy::too_many_arguments)]
fn generation_plan(
    deck_path: &Path,
    data: &[Datum],
    sides: AudioSides,
    voices: &VoiceSelection,
    manifest: &AudioManifest,
    output_format: AudioOutputFormat,
    _overwrite: bool,
    _manifest_exists: bool,
) -> GenerationPlan {
    let mut items = Vec::new();
    for (index, datum) in data.iter().enumerate() {
        let Some(card_id) = datum.card_id() else {
            items.push(GenerationItem::SkipMissingCardId { index });
            continue;
        };

        for side in [AudioSide::Prompt, AudioSide::Response] {
            if !sides.contains(side) {
                continue;
            }

            let text = match side {
                AudioSide::Prompt => datum.prompt(),
                AudioSide::Response => datum.response(),
            };
            if text.trim().is_empty() {
                items.push(GenerationItem::SkipEmptyText { card_id, side });
                continue;
            }

            let file_name = audio_file_name_for_card_side(card_id, side, output_format);
            let Some(output) = audio_path_for_card_side(deck_path, card_id, side, output_format)
            else {
                items.push(GenerationItem::SkipMissingCardId { index });
                continue;
            };
            let engine = voices.engine_for_side(side);
            let signature = voices.signature_for_side(side, text);

            match audio_generation_disposition(
                deck_path,
                card_id,
                side,
                output_format,
                manifest,
                &signature,
            ) {
                AudioGenerationDisposition::SkipManagedUnchanged { output } => {
                    items.push(GenerationItem::SkipManagedUnchanged { output });
                }
                AudioGenerationDisposition::PreserveUntrackedExisting { output } => {
                    items.push(GenerationItem::PreserveUntrackedExisting { output });
                }
                AudioGenerationDisposition::Generate { reason } => {
                    items.push(GenerationItem::Generate {
                        card_id,
                        side,
                        engine,
                        text: text.to_string(),
                        output,
                        manifest_key: file_name,
                        signature,
                        reason,
                    })
                }
            }
        }
    }

    GenerationPlan { items }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum AudioGenerationDisposition {
    Generate { reason: GenerationReason },
    PreserveUntrackedExisting { output: PathBuf },
    SkipManagedUnchanged { output: PathBuf },
}

fn audio_generation_disposition(
    deck_path: &Path,
    card_id: u64,
    side: AudioSide,
    output_format: AudioOutputFormat,
    manifest: &AudioManifest,
    signature: &AudioManifestEntry,
) -> AudioGenerationDisposition {
    if let Some(existing) = manual_audio_for_card_side(deck_path, card_id, side, manifest) {
        return AudioGenerationDisposition::PreserveUntrackedExisting {
            output: existing.path,
        };
    }

    if let Some(existing) = audio_for_card_side_format(deck_path, card_id, side, output_format) {
        if manifest.matches(&existing.file_name, signature) {
            return AudioGenerationDisposition::SkipManagedUnchanged {
                output: existing.path,
            };
        }
        if manifest.contains(&existing.file_name) {
            return AudioGenerationDisposition::Generate {
                reason: GenerationReason::ManagedRefresh,
            };
        }
        return AudioGenerationDisposition::PreserveUntrackedExisting {
            output: existing.path,
        };
    }

    if let Some(_existing) = existing_audio_for_card_side(deck_path, card_id, side)
        .into_iter()
        .find(|existing| manifest.contains(&existing.file_name))
    {
        return AudioGenerationDisposition::Generate {
            reason: GenerationReason::ManagedRefresh,
        };
    }

    AudioGenerationDisposition::Generate {
        reason: GenerationReason::MissingFile,
    }
}

fn manual_audio_for_card_side(
    deck_path: &Path,
    card_id: u64,
    side: AudioSide,
    manifest: &AudioManifest,
) -> Option<ExistingAudio> {
    existing_audio_for_card_side(deck_path, card_id, side)
        .into_iter()
        .find(|existing| manifest.is_manual_or_untracked(&existing.file_name))
}

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

fn audio_path_for_card_side(
    deck_path: &Path,
    card_id: u64,
    side: AudioSide,
    format: AudioOutputFormat,
) -> Option<PathBuf> {
    Some(
        sidecar_dir_for_deck_path(deck_path)?
            .join(audio_file_name_for_card_side(card_id, side, format)),
    )
}

fn audio_file_name_for_card_side(
    card_id: u64,
    side: AudioSide,
    format: AudioOutputFormat,
) -> String {
    audio_file_name_for_card_side_with_extension(card_id, side, format.extension())
}

fn audio_file_name_for_card_side_with_extension(
    card_id: u64,
    side: AudioSide,
    extension: &str,
) -> String {
    format!("{card_id}.{}.{extension}", side.suffix())
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ExistingAudio {
    path: PathBuf,
    file_name: String,
}

fn audio_for_card_side_format(
    deck_path: &Path,
    card_id: u64,
    side: AudioSide,
    format: AudioOutputFormat,
) -> Option<ExistingAudio> {
    let sidecar_dir = sidecar_dir_for_deck_path(deck_path)?;
    let file_name = audio_file_name_for_card_side(card_id, side, format);
    let path = sidecar_dir.join(&file_name);
    if path.exists() {
        Some(ExistingAudio { path, file_name })
    } else {
        None
    }
}

fn existing_audio_for_card_side(
    deck_path: &Path,
    card_id: u64,
    side: AudioSide,
) -> Vec<ExistingAudio> {
    let Some(sidecar_dir) = sidecar_dir_for_deck_path(deck_path) else {
        return Vec::new();
    };
    [WAV_EXTENSION, MP3_EXTENSION, OPUS_EXTENSION]
        .into_iter()
        .filter_map(|extension| {
            let file_name = audio_file_name_for_card_side_with_extension(card_id, side, extension);
            let path = sidecar_dir.join(&file_name);
            if path.exists() {
                Some(ExistingAudio { path, file_name })
            } else {
                None
            }
        })
        .collect()
}

#[cfg(windows)]
fn temporary_wav_path(output: &Path) -> PathBuf {
    let file_name = output
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("audio.opus");
    output.with_file_name(format!("{file_name}.tmp.wav"))
}

fn manifest_path_for_deck_path(deck_path: &Path) -> Option<PathBuf> {
    Some(sidecar_dir_for_deck_path(deck_path)?.join(MANIFEST_FILE_NAME))
}

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

impl AudioManifest {
    fn read(path: &Path) -> Result<Self, ToolError> {
        let text = match fs::read_to_string(path) {
            Ok(text) => text,
            Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Self::default()),
            Err(source) => {
                return Err(ToolError::ReadManifest {
                    path: path.to_path_buf(),
                    source,
                });
            }
        };

        Ok(Self::parse(&text))
    }

    fn parse(text: &str) -> Self {
        let mut manifest = Self::default();
        for line in text.lines() {
            let fields: Vec<_> = line.split('\t').collect();
            let (file_name, text_hash, engine, voice) = match fields.as_slice() {
                [file_name, text_hash, voice] => {
                    (*file_name, *text_hash, VoiceEngine::OneCore, *voice)
                }
                [file_name, text_hash, engine, voice] => {
                    let Ok(engine) = engine.parse() else {
                        continue;
                    };
                    (*file_name, *text_hash, engine, *voice)
                }
                _ => continue,
            };
            if file_name == "version" {
                continue;
            }
            manifest.entries.insert(
                file_name.to_string(),
                AudioManifestEntry {
                    text_hash: text_hash.to_string(),
                    engine,
                    voice: unescape_manifest_field(voice),
                },
            );
        }
        manifest
    }

    fn write(&self, path: &Path) -> Result<(), ToolError> {
        let mut text = String::from("version\t2\t\t\n");
        for (file_name, entry) in &self.entries {
            text.push_str(file_name);
            text.push('\t');
            text.push_str(&entry.text_hash);
            text.push('\t');
            text.push_str(entry.engine.key());
            text.push('\t');
            text.push_str(&escape_manifest_field(&entry.voice));
            text.push('\n');
        }
        fs::write(path, text).map_err(|source| ToolError::WriteManifest {
            path: path.to_path_buf(),
            source,
        })
    }

    fn matches(&self, file_name: &str, entry: &AudioManifestEntry) -> bool {
        self.entries.get(file_name) == Some(entry)
    }

    fn contains(&self, file_name: &str) -> bool {
        self.entries.contains_key(file_name)
    }

    fn is_manual_or_untracked(&self, file_name: &str) -> bool {
        self.entries
            .get(file_name)
            .map(AudioManifestEntry::is_manual)
            .unwrap_or(true)
    }

    #[cfg_attr(not(any(windows, test)), allow(dead_code))]
    fn set(&mut self, file_name: String, entry: AudioManifestEntry) {
        self.entries.insert(file_name, entry);
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct AudioManifestEntry {
    text_hash: String,
    engine: VoiceEngine,
    voice: String,
}

impl AudioManifestEntry {
    fn new(text: &str, engine: VoiceEngine, voice: Option<&str>) -> Self {
        Self {
            text_hash: stable_hash_hex(text.as_bytes()),
            engine,
            voice: voice.unwrap_or("").to_string(),
        }
    }

    #[cfg(test)]
    fn onecore(text: &str, voice: Option<&str>) -> Self {
        Self::new(text, VoiceEngine::OneCore, voice)
    }

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

    #[cfg(test)]
    fn manual_anki() -> Self {
        Self {
            text_hash: "manual:anki".to_string(),
            engine: VoiceEngine::OneCore,
            voice: String::new(),
        }
    }
}

fn stable_hash_hex(bytes: &[u8]) -> String {
    const FNV_1A_64_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
    const FNV_1A_64_PRIME: u64 = 0x00000100000001b3;
    let mut hash = FNV_1A_64_OFFSET_BASIS;
    for byte in bytes {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(FNV_1A_64_PRIME);
    }
    format!("fnv1a64:{hash:016x}")
}

fn escape_manifest_field(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('\t', "\\t")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
}

fn unescape_manifest_field(value: &str) -> String {
    let mut output = String::new();
    let mut chars = value.chars();
    while let Some(ch) = chars.next() {
        if ch == '\\' {
            match chars.next() {
                Some('\\') => output.push('\\'),
                Some('t') => output.push('\t'),
                Some('n') => output.push('\n'),
                Some('r') => output.push('\r'),
                Some(other) => {
                    output.push('\\');
                    output.push(other);
                }
                None => output.push('\\'),
            }
        } else {
            output.push(ch);
        }
    }
    output
}

fn list_voices(engine: VoiceEngine, machine: bool) -> Result<(), ToolError> {
    #[cfg(windows)]
    {
        let voices = match engine {
            VoiceEngine::OneCore => onecore::voice_names()?,
            VoiceEngine::Sapi => sapi::SapiSynthesizer::voice_names()?,
        };
        for voice in voices {
            if machine {
                println!(
                    "{}\t{}\t{}",
                    engine.key(),
                    escape_machine_field(&voice.label),
                    escape_machine_field(&voice.key)
                );
            } else {
                println!("{}", voice.label);
            }
        }
        Ok(())
    }

    #[cfg(not(windows))]
    {
        let _ = (engine, machine);
        Err(ToolError::WindowsOnly)
    }
}

#[cfg(windows)]
struct VoiceName {
    label: String,
    key: String,
}

#[cfg(windows)]
impl VoiceName {
    fn new(label: String, key: String) -> Self {
        Self { label, key }
    }
}

#[cfg(windows)]
fn escape_machine_field(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('\t', "\\t")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
}

fn print_help() {
    println!(
        "\
{PROGRAM_NAME}: generate Cosimo review audio files with Windows speech voices

Usage:
  {PROGRAM_NAME} [options] <deck-name>
  {PROGRAM_NAME} --list-voices

Options:
  --side prompt|response|both  Generate one side or both sides (default: both)
  --format opus|wav            Select generated audio format (default: opus)
  --voice NAME                 Use a voice whose name contains NAME for both sides
  --prompt-voice NAME          Use a voice whose name contains NAME for prompts
  --response-voice NAME        Use a voice whose name contains NAME for responses
  --engine onecore|sapi        Use a speech engine for both sides (default: onecore)
  --prompt-engine ENGINE       Use a speech engine for prompts
  --response-engine ENGINE     Use a speech engine for responses
  --overwrite                  Reserved; current generation preserves existing audio
  --dry-run                    Show what would be generated
  --list-voices                List installed voices for the selected engine
  --machine                    With --list-voices, emit engine, label, key as TSV
  --progress                   Emit machine-readable progress for Cosimo
  -h, --help                   Show this help

Files are written beside the deck in Cosimo's sidecar audio layout, for example
Indonesian.cosimo-deck -> Indonesian/1.prompt.opus and Indonesian/1.response.opus.
The sidecar directory also contains audio-manifest.tsv. Manifest-tracked audio is
Cosimo-managed and may be refreshed or converted to the selected format. Existing
untracked .opus and .wav files are treated as user-owned recordings and are left
untouched, even if this means no generated file is created for that card side.

If the deck argument is a bare name without an extension, Cosimo reads it from
the decks directory beside the voice executable. For example, if voice.exe is in
C:\\cosimo, `voice Indonesian` reads `C:\\cosimo\\decks\\Indonesian.cosimo-deck`.
Deck metadata keys prompt_voice, response_voice, prompt_voice_engine, and
response_voice_engine are used as defaults when the corresponding command-line
option is not provided. Decks with audio_generation=disabled are treated as
manual-audio decks and this tool refuses to generate audio for them.
"
    );
}

#[derive(Debug)]
enum ToolError {
    MissingDeckPath,
    TooManyDeckPaths,
    UnknownOption(String),
    MissingOptionValue {
        option: String,
    },
    InvalidSide(String),
    InvalidVoiceEngine(String),
    InvalidOutputFormat(String),
    InvalidDeckPath,
    ReadDeck {
        source: std::io::Error,
    },
    ParseDeck {
        source: String,
    },
    AudioGenerationDisabled,
    CreateSidecar {
        path: PathBuf,
        source: std::io::Error,
    },
    ReadManifest {
        path: PathBuf,
        source: std::io::Error,
    },
    WriteManifest {
        path: PathBuf,
        source: std::io::Error,
    },
    #[cfg(windows)]
    WriteAudio {
        path: PathBuf,
        source: std::io::Error,
    },
    #[cfg(windows)]
    ReadTemporaryAudio {
        path: PathBuf,
        source: std::io::Error,
    },
    #[cfg(windows)]
    EncodeAudio {
        path: PathBuf,
        source: audio_encoding::AudioEncodingError,
    },
    #[cfg(not(windows))]
    WindowsOnly,
    #[cfg(windows)]
    OneCore(onecore::OneCoreError),
    #[cfg(windows)]
    Sapi(sapi::SapiError),
}

impl fmt::Display for ToolError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingDeckPath => f.write_str("missing deck path"),
            Self::TooManyDeckPaths => f.write_str("only one deck path may be supplied"),
            Self::UnknownOption(option) => write!(f, "unknown option {option}"),
            Self::MissingOptionValue { option } => write!(f, "missing value for {option}"),
            Self::InvalidSide(value) => {
                write!(
                    f,
                    "invalid side {value}; expected prompt, response, or both"
                )
            }
            Self::InvalidVoiceEngine(value) => {
                write!(f, "invalid voice engine {value}; expected onecore or sapi")
            }
            Self::InvalidOutputFormat(value) => {
                write!(f, "invalid audio format {value}; expected opus or wav")
            }
            Self::InvalidDeckPath => f.write_str("deck path has no file stem"),
            Self::ReadDeck { source } => write!(f, "could not read deck: {source}"),
            Self::ParseDeck { source } => write!(f, "could not parse deck: {source}"),
            Self::AudioGenerationDisabled => f.write_str(
                "this deck is marked as a manual audio deck; audio generation is disabled",
            ),
            Self::CreateSidecar { path, source } => {
                write!(
                    f,
                    "could not create audio directory {}: {source}",
                    path.display()
                )
            }
            Self::ReadManifest { path, source } => {
                write!(
                    f,
                    "could not read audio manifest {}: {source}",
                    path.display()
                )
            }
            Self::WriteManifest { path, source } => {
                write!(
                    f,
                    "could not write audio manifest {}: {source}",
                    path.display()
                )
            }
            #[cfg(windows)]
            Self::WriteAudio { path, source } => {
                write!(f, "could not write audio file {}: {source}", path.display())
            }
            #[cfg(windows)]
            Self::ReadTemporaryAudio { path, source } => {
                write!(
                    f,
                    "could not read temporary audio file {}: {source}",
                    path.display()
                )
            }
            #[cfg(windows)]
            Self::EncodeAudio { path, source } => {
                write!(
                    f,
                    "could not encode audio file {}: {source}",
                    path.display()
                )
            }
            #[cfg(not(windows))]
            Self::WindowsOnly => {
                f.write_str("OneCore audio generation is available on Windows only")
            }
            #[cfg(windows)]
            Self::OneCore(source) => write!(f, "{source}"),
            #[cfg(windows)]
            Self::Sapi(source) => write!(f, "{source}"),
        }
    }
}

impl std::error::Error for ToolError {}

#[cfg(windows)]
impl From<onecore::OneCoreError> for ToolError {
    fn from(error: onecore::OneCoreError) -> Self {
        Self::OneCore(error)
    }
}

#[cfg(windows)]
impl From<sapi::SapiError> for ToolError {
    fn from(error: sapi::SapiError) -> Self {
        Self::Sapi(error)
    }
}

#[cfg(windows)]
mod audio_encoding {
    use std::ffi::CStr;
    use std::fmt;
    use std::fs;
    use std::io;
    use std::path::Path;

    const OPUS_SAMPLE_RATE: usize = 48_000;
    const OPUS_FRAME_SIZE: usize = 960;
    const OPUS_MAX_PACKET_BYTES: usize = 4_000;
    const OGG_AUDIO_PACKETS_PER_PAGE: usize = 50;
    const OGG_MAX_SEGMENTS_PER_PAGE: usize = 255;
    const OGG_SERIAL: u32 = 0x436f_7369;
    const OGG_BOS: u8 = 0x02;
    const OGG_EOS: u8 = 0x04;
    const WAVE_FORMAT_PCM: u16 = 1;
    const WAVE_FORMAT_EXTENSIBLE: u16 = 0xfffe;
    const WAVE_EXTENSIBLE_PCM_GUID: [u8; 16] = [
        0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b,
        0x71,
    ];

    pub(crate) fn write_opus_from_wav_bytes(
        path: &Path,
        wav_bytes: &[u8],
    ) -> Result<(), AudioEncodingError> {
        let pcm = PcmAudio::from_wav_bytes(wav_bytes)?.resampled_to_opus_rate();
        let opus = encode_pcm_to_ogg_opus(&pcm)?;
        fs::write(path, opus).map_err(AudioEncodingError::WriteOutput)
    }

    #[derive(Debug)]
    pub(crate) enum AudioEncodingError {
        InvalidWav(&'static str),
        UnsupportedWav(String),
        Opus(String),
        WriteOutput(io::Error),
    }

    impl fmt::Display for AudioEncodingError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                Self::InvalidWav(message) => write!(f, "invalid WAV data: {message}"),
                Self::UnsupportedWav(message) => write!(f, "unsupported WAV data: {message}"),
                Self::Opus(message) => write!(f, "Opus encoder failed: {message}"),
                Self::WriteOutput(source) => write!(f, "{source}"),
            }
        }
    }

    impl std::error::Error for AudioEncodingError {}

    #[derive(Debug, Clone, PartialEq, Eq)]
    struct PcmAudio {
        channels: usize,
        sample_rate: usize,
        samples: Vec<i16>,
    }

    impl PcmAudio {
        fn from_wav_bytes(bytes: &[u8]) -> Result<Self, AudioEncodingError> {
            if bytes.len() < 12 {
                return Err(AudioEncodingError::InvalidWav("file is too short"));
            }
            if &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
                return Err(AudioEncodingError::InvalidWav("missing RIFF/WAVE header"));
            }

            let mut format = None;
            let mut data = None;
            let mut offset = 12;
            while offset + 8 <= bytes.len() {
                let chunk_id = &bytes[offset..offset + 4];
                let chunk_size = u32::from_le_bytes(
                    bytes[offset + 4..offset + 8]
                        .try_into()
                        .expect("slice length checked"),
                ) as usize;
                offset += 8;
                if offset + chunk_size > bytes.len() {
                    return Err(AudioEncodingError::InvalidWav("chunk extends past EOF"));
                }
                let chunk = &bytes[offset..offset + chunk_size];
                match chunk_id {
                    b"fmt " => format = Some(WavFormat::parse(chunk)?),
                    b"data" => data = Some(chunk),
                    _ => {}
                }
                offset += chunk_size + (chunk_size % 2);
            }

            let format = format.ok_or(AudioEncodingError::InvalidWav("missing fmt chunk"))?;
            let data = data.ok_or(AudioEncodingError::InvalidWav("missing data chunk"))?;
            if data.len() % 2 != 0 {
                return Err(AudioEncodingError::InvalidWav("16-bit data has odd length"));
            }
            let samples = data
                .chunks_exact(2)
                .map(|bytes| i16::from_le_bytes([bytes[0], bytes[1]]))
                .collect::<Vec<_>>();
            if samples.len() % format.channels != 0 {
                return Err(AudioEncodingError::InvalidWav(
                    "sample data is not aligned to channels",
                ));
            }

            Ok(Self {
                channels: format.channels,
                sample_rate: format.sample_rate,
                samples,
            })
        }

        fn frame_count(&self) -> usize {
            self.samples.len() / self.channels
        }

        fn resampled_to_opus_rate(&self) -> Self {
            if self.sample_rate == OPUS_SAMPLE_RATE {
                return self.clone();
            }

            let source_frames = self.frame_count();
            if source_frames == 0 {
                return Self {
                    channels: self.channels,
                    sample_rate: OPUS_SAMPLE_RATE,
                    samples: Vec::new(),
                };
            }

            let output_frames =
                ((source_frames as u64 * OPUS_SAMPLE_RATE as u64) + self.sample_rate as u64 - 1)
                    / self.sample_rate as u64;
            let mut samples = Vec::with_capacity(output_frames as usize * self.channels);
            for output_frame in 0..output_frames as usize {
                let source_position =
                    output_frame as f64 * self.sample_rate as f64 / OPUS_SAMPLE_RATE as f64;
                let source_index = source_position.floor() as usize;
                let next_index = (source_index + 1).min(source_frames - 1);
                let fraction = source_position - source_index as f64;
                for channel in 0..self.channels {
                    let first = self.samples[source_index * self.channels + channel] as f64;
                    let second = self.samples[next_index * self.channels + channel] as f64;
                    samples.push((first + (second - first) * fraction).round() as i16);
                }
            }

            Self {
                channels: self.channels,
                sample_rate: OPUS_SAMPLE_RATE,
                samples,
            }
        }
    }

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    struct WavFormat {
        channels: usize,
        sample_rate: usize,
    }

    impl WavFormat {
        fn parse(bytes: &[u8]) -> Result<Self, AudioEncodingError> {
            if bytes.len() < 16 {
                return Err(AudioEncodingError::InvalidWav("fmt chunk is too short"));
            }
            let format_tag = u16::from_le_bytes([bytes[0], bytes[1]]);
            let channels = u16::from_le_bytes([bytes[2], bytes[3]]) as usize;
            let sample_rate = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
            let bits_per_sample = u16::from_le_bytes([bytes[14], bytes[15]]);

            if !(1..=2).contains(&channels) {
                return Err(AudioEncodingError::UnsupportedWav(format!(
                    "expected mono or stereo audio, got {channels} channels"
                )));
            }
            if sample_rate == 0 {
                return Err(AudioEncodingError::InvalidWav("sample rate is zero"));
            }
            if bits_per_sample != 16 {
                return Err(AudioEncodingError::UnsupportedWav(format!(
                    "expected 16-bit PCM, got {bits_per_sample}-bit audio"
                )));
            }
            if !matches!(format_tag, WAVE_FORMAT_PCM | WAVE_FORMAT_EXTENSIBLE) {
                return Err(AudioEncodingError::UnsupportedWav(format!(
                    "expected PCM audio, got format tag {format_tag}"
                )));
            }
            if format_tag == WAVE_FORMAT_EXTENSIBLE {
                if bytes.len() < 40 || bytes[24..40] != WAVE_EXTENSIBLE_PCM_GUID {
                    return Err(AudioEncodingError::UnsupportedWav(
                        "expected PCM WAVE_FORMAT_EXTENSIBLE subtype".to_string(),
                    ));
                }
            }

            Ok(Self {
                channels,
                sample_rate,
            })
        }
    }

    fn encode_pcm_to_ogg_opus(pcm: &PcmAudio) -> Result<Vec<u8>, AudioEncodingError> {
        if pcm.channels == 0 || pcm.channels > 2 {
            return Err(AudioEncodingError::UnsupportedWav(format!(
                "expected mono or stereo audio, got {} channels",
                pcm.channels
            )));
        }
        if pcm.sample_rate != OPUS_SAMPLE_RATE {
            return Err(AudioEncodingError::InvalidWav(
                "PCM was not resampled before Opus encoding",
            ));
        }
        if pcm.samples.is_empty() {
            return Err(AudioEncodingError::InvalidWav("audio contains no samples"));
        }

        let mut encoder = OpusEncoder::new(pcm.channels)?;
        let pre_skip = encoder.lookahead()? as u16;
        let mut ogg = OggWriter::new(OGG_SERIAL);
        ogg.write_packet(OGG_BOS, 0, &opus_head(pcm.channels, pre_skip));
        ogg.write_packet(0, 0, &opus_tags());

        let mut encoded_samples = 0usize;
        let total_frames = pcm.frame_count();
        for start_frame in (0..total_frames).step_by(OPUS_FRAME_SIZE) {
            let available_frames = (total_frames - start_frame).min(OPUS_FRAME_SIZE);
            let mut frame = vec![0_i16; OPUS_FRAME_SIZE * pcm.channels];
            let source_start = start_frame * pcm.channels;
            let source_end = source_start + available_frames * pcm.channels;
            frame[..available_frames * pcm.channels]
                .copy_from_slice(&pcm.samples[source_start..source_end]);
            let packet = encoder.encode(&frame)?;
            encoded_samples += available_frames;
            let eos = start_frame + available_frames >= total_frames;
            let granule_position = u64::from(pre_skip) + encoded_samples as u64;
            ogg.write_audio_packet(granule_position, &packet, eos);
        }

        Ok(ogg.into_bytes())
    }

    fn opus_head(channels: usize, pre_skip: u16) -> Vec<u8> {
        let mut packet = Vec::with_capacity(19);
        packet.extend_from_slice(b"OpusHead");
        packet.push(1);
        packet.push(channels as u8);
        packet.extend_from_slice(&pre_skip.to_le_bytes());
        packet.extend_from_slice(&(OPUS_SAMPLE_RATE as u32).to_le_bytes());
        packet.extend_from_slice(&0_i16.to_le_bytes());
        packet.push(0);
        packet
    }

    fn opus_tags() -> Vec<u8> {
        let vendor = b"Cosimo voice";
        let mut packet = Vec::with_capacity(16 + vendor.len());
        packet.extend_from_slice(b"OpusTags");
        packet.extend_from_slice(&(vendor.len() as u32).to_le_bytes());
        packet.extend_from_slice(vendor);
        packet.extend_from_slice(&0_u32.to_le_bytes());
        packet
    }

    struct OpusEncoder {
        ptr: *mut opusic_sys::OpusEncoder,
    }

    impl OpusEncoder {
        fn new(channels: usize) -> Result<Self, AudioEncodingError> {
            let mut error = opusic_sys::OPUS_OK;
            // SAFETY: error points to valid writable storage, and libopus
            // returns an owned encoder pointer which is released by Drop.
            let ptr = unsafe {
                opusic_sys::opus_encoder_create(
                    OPUS_SAMPLE_RATE as i32,
                    channels as i32,
                    opusic_sys::OPUS_APPLICATION_VOIP,
                    &mut error,
                )
            };
            if ptr.is_null() || error != opusic_sys::OPUS_OK {
                return Err(AudioEncodingError::Opus(opus_error_text(error)));
            }

            let encoder = Self { ptr };
            encoder.set_int_ctl(opusic_sys::OPUS_SET_BITRATE_REQUEST, 24_000)?;
            encoder.set_int_ctl(
                opusic_sys::OPUS_SET_SIGNAL_REQUEST,
                opusic_sys::OPUS_SIGNAL_VOICE,
            )?;
            encoder.set_int_ctl(opusic_sys::OPUS_SET_COMPLEXITY_REQUEST, 5)?;
            Ok(encoder)
        }

        fn set_int_ctl(&self, request: i32, value: i32) -> Result<(), AudioEncodingError> {
            // SAFETY: self.ptr is a live encoder, and these requests expect a
            // single integer value matching the libopus ctl varargs contract.
            let result = unsafe { opusic_sys::opus_encoder_ctl(self.ptr, request, value) };
            check_opus_result(result)
        }

        fn lookahead(&self) -> Result<i32, AudioEncodingError> {
            let mut lookahead = 0_i32;
            // SAFETY: self.ptr is a live encoder, and OPUS_GET_LOOKAHEAD
            // expects a valid pointer to an integer output slot.
            let result = unsafe {
                opusic_sys::opus_encoder_ctl(
                    self.ptr,
                    opusic_sys::OPUS_GET_LOOKAHEAD_REQUEST,
                    &mut lookahead,
                )
            };
            check_opus_result(result)?;
            Ok(lookahead)
        }

        fn encode(&mut self, frame: &[i16]) -> Result<Vec<u8>, AudioEncodingError> {
            let mut packet = vec![0_u8; OPUS_MAX_PACKET_BYTES];
            // SAFETY: self.ptr is a live encoder. frame contains exactly one
            // interleaved 20 ms frame, and packet is valid writable storage.
            let len = unsafe {
                opusic_sys::opus_encode(
                    self.ptr,
                    frame.as_ptr(),
                    OPUS_FRAME_SIZE as i32,
                    packet.as_mut_ptr(),
                    packet.len() as i32,
                )
            };
            if len < 0 {
                return Err(AudioEncodingError::Opus(opus_error_text(len)));
            }
            packet.truncate(len as usize);
            Ok(packet)
        }
    }

    impl Drop for OpusEncoder {
        fn drop(&mut self) {
            if !self.ptr.is_null() {
                // SAFETY: self.ptr is the encoder pointer returned by
                // opus_encoder_create and has not yet been destroyed.
                unsafe {
                    opusic_sys::opus_encoder_destroy(self.ptr);
                }
            }
        }
    }

    fn check_opus_result(result: i32) -> Result<(), AudioEncodingError> {
        if result == opusic_sys::OPUS_OK {
            Ok(())
        } else {
            Err(AudioEncodingError::Opus(opus_error_text(result)))
        }
    }

    fn opus_error_text(error: i32) -> String {
        // SAFETY: opus_strerror returns a NUL-terminated static string for
        // every libopus error code.
        let ptr = unsafe { opusic_sys::opus_strerror(error) };
        if ptr.is_null() {
            format!("error code {error}")
        } else {
            // SAFETY: ptr is a non-null NUL-terminated C string from libopus.
            unsafe { CStr::from_ptr(ptr) }
                .to_string_lossy()
                .into_owned()
        }
    }

    struct OggWriter {
        serial: u32,
        sequence: u32,
        bytes: Vec<u8>,
        pending_audio: PendingOggPage,
    }

    impl OggWriter {
        fn new(serial: u32) -> Self {
            Self {
                serial,
                sequence: 0,
                bytes: Vec::new(),
                pending_audio: PendingOggPage::default(),
            }
        }

        fn write_packet(&mut self, header_type: u8, granule_position: u64, packet: &[u8]) {
            self.flush_audio_page(0);
            let lacing = lacing_values(packet.len());
            self.write_page(header_type, granule_position, &lacing, packet);
        }

        fn write_audio_packet(&mut self, granule_position: u64, packet: &[u8], eos: bool) {
            let lacing = lacing_values(packet.len());
            assert!(lacing.len() <= OGG_MAX_SEGMENTS_PER_PAGE);
            if !self.pending_audio.can_accept(lacing.len()) {
                self.flush_audio_page(0);
            }
            self.pending_audio.push(lacing, packet, granule_position);
            if eos {
                self.flush_audio_page(OGG_EOS);
            } else if !self.pending_audio.can_accept(1) {
                self.flush_audio_page(0);
            }
        }

        fn flush_audio_page(&mut self, header_type: u8) {
            if self.pending_audio.is_empty() {
                return;
            }
            let pending = std::mem::take(&mut self.pending_audio);
            self.write_page(
                header_type,
                pending.granule_position,
                &pending.lacing,
                &pending.payload,
            );
        }

        fn write_page(
            &mut self,
            header_type: u8,
            granule_position: u64,
            lacing: &[u8],
            payload: &[u8],
        ) {
            assert!(lacing.len() <= OGG_MAX_SEGMENTS_PER_PAGE);
            let mut page = Vec::with_capacity(27 + lacing.len() + payload.len());
            page.extend_from_slice(b"OggS");
            page.push(0);
            page.push(header_type);
            page.extend_from_slice(&granule_position.to_le_bytes());
            page.extend_from_slice(&self.serial.to_le_bytes());
            page.extend_from_slice(&self.sequence.to_le_bytes());
            page.extend_from_slice(&0_u32.to_le_bytes());
            page.push(lacing.len() as u8);
            page.extend_from_slice(&lacing);
            page.extend_from_slice(payload);

            let checksum = ogg_crc(&page);
            page[22..26].copy_from_slice(&checksum.to_le_bytes());
            self.bytes.extend(page);
            self.sequence += 1;
        }

        fn into_bytes(mut self) -> Vec<u8> {
            self.flush_audio_page(0);
            self.bytes
        }
    }

    #[derive(Default)]
    struct PendingOggPage {
        lacing: Vec<u8>,
        payload: Vec<u8>,
        granule_position: u64,
        packet_count: usize,
    }

    impl PendingOggPage {
        fn is_empty(&self) -> bool {
            self.packet_count == 0
        }

        fn can_accept(&self, additional_segments: usize) -> bool {
            self.packet_count < OGG_AUDIO_PACKETS_PER_PAGE
                && self.lacing.len() + additional_segments <= OGG_MAX_SEGMENTS_PER_PAGE
        }

        fn push(&mut self, lacing: Vec<u8>, packet: &[u8], granule_position: u64) {
            self.lacing.extend(lacing);
            self.payload.extend_from_slice(packet);
            self.granule_position = granule_position;
            self.packet_count += 1;
        }
    }

    fn lacing_values(mut len: usize) -> Vec<u8> {
        let mut values = Vec::new();
        while len >= 255 {
            values.push(255);
            len -= 255;
        }
        values.push(len as u8);
        values
    }

    fn ogg_crc(bytes: &[u8]) -> u32 {
        let mut crc = 0_u32;
        for byte in bytes {
            crc ^= u32::from(*byte) << 24;
            for _ in 0..8 {
                if crc & 0x8000_0000 != 0 {
                    crc = (crc << 1) ^ 0x04c1_1db7;
                } else {
                    crc <<= 1;
                }
            }
        }
        crc
    }

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

        #[test]
        fn parses_basic_pcm_wav() {
            let mut wav = Vec::new();
            wav.extend_from_slice(b"RIFF");
            wav.extend_from_slice(&40_u32.to_le_bytes());
            wav.extend_from_slice(b"WAVE");
            wav.extend_from_slice(b"fmt ");
            wav.extend_from_slice(&16_u32.to_le_bytes());
            wav.extend_from_slice(&1_u16.to_le_bytes());
            wav.extend_from_slice(&1_u16.to_le_bytes());
            wav.extend_from_slice(&22_050_u32.to_le_bytes());
            wav.extend_from_slice(&(22_050_u32 * 2).to_le_bytes());
            wav.extend_from_slice(&2_u16.to_le_bytes());
            wav.extend_from_slice(&16_u16.to_le_bytes());
            wav.extend_from_slice(b"data");
            wav.extend_from_slice(&4_u32.to_le_bytes());
            wav.extend_from_slice(&0_i16.to_le_bytes());
            wav.extend_from_slice(&1_i16.to_le_bytes());

            let parsed = PcmAudio::from_wav_bytes(&wav).unwrap();

            assert_eq!(parsed.channels, 1);
            assert_eq!(parsed.sample_rate, 22_050);
            assert_eq!(parsed.samples, vec![0, 1]);
        }

        #[test]
        fn lacing_marks_exact_multiples_of_255() {
            assert_eq!(lacing_values(254), vec![254]);
            assert_eq!(lacing_values(255), vec![255, 0]);
            assert_eq!(lacing_values(510), vec![255, 255, 0]);
        }

        #[test]
        fn ogg_writer_packs_audio_packets_into_pages() {
            let mut ogg = OggWriter::new(7);
            ogg.write_packet(OGG_BOS, 0, b"head");
            ogg.write_packet(0, 0, b"tags");
            for packet_index in 0..120 {
                let packet = [packet_index as u8; 12];
                ogg.write_audio_packet(
                    ((packet_index + 1) * OPUS_FRAME_SIZE) as u64,
                    &packet,
                    packet_index == 119,
                );
            }

            let pages = parse_ogg_pages(&ogg.into_bytes());

            assert_eq!(pages.len(), 5);
            assert_eq!(pages[0].header_type, OGG_BOS);
            assert_eq!(pages[0].sequence, 0);
            assert_eq!(pages[1].sequence, 1);
            assert_eq!(pages[2].sequence, 2);
            assert_eq!(pages[2].segment_count, OGG_AUDIO_PACKETS_PER_PAGE);
            assert_eq!(pages[3].segment_count, OGG_AUDIO_PACKETS_PER_PAGE);
            assert_eq!(pages[4].segment_count, 20);
            assert_eq!(pages[4].header_type, OGG_EOS);
            assert_eq!(pages[4].granule_position, (120 * OPUS_FRAME_SIZE) as u64);
        }

        #[derive(Debug, PartialEq, Eq)]
        struct ParsedOggPage {
            header_type: u8,
            granule_position: u64,
            sequence: u32,
            segment_count: usize,
        }

        fn parse_ogg_pages(bytes: &[u8]) -> Vec<ParsedOggPage> {
            let mut pages = Vec::new();
            let mut offset = 0;
            while offset < bytes.len() {
                assert_eq!(&bytes[offset..offset + 4], b"OggS");
                let segment_count = bytes[offset + 26] as usize;
                let payload_len = bytes[offset + 27..offset + 27 + segment_count]
                    .iter()
                    .map(|segment| usize::from(*segment))
                    .sum::<usize>();
                pages.push(ParsedOggPage {
                    header_type: bytes[offset + 5],
                    granule_position: u64::from_le_bytes(
                        bytes[offset + 6..offset + 14].try_into().unwrap(),
                    ),
                    sequence: u32::from_le_bytes(
                        bytes[offset + 18..offset + 22].try_into().unwrap(),
                    ),
                    segment_count,
                });
                offset += 27 + segment_count + payload_len;
            }
            pages
        }
    }
}

#[cfg(windows)]
mod onecore {
    use super::{AudioSide, VoiceEngine, VoiceName, VoiceSelection};
    use std::fmt;

    use windows::Media::SpeechSynthesis::{SpeechSynthesizer, VoiceInformation};
    use windows::Storage::Streams::{DataReader, InputStreamOptions};
    use windows::core::{Error as WindowsError, HSTRING};

    pub(crate) struct OneCoreSynthesizer {
        synthesizer: SpeechSynthesizer,
        prompt_voice: Option<VoiceInformation>,
        response_voice: Option<VoiceInformation>,
    }

    impl OneCoreSynthesizer {
        pub(crate) fn new(voices: &VoiceSelection) -> Result<Self, OneCoreError> {
            let synthesizer = SpeechSynthesizer::new()?;
            let prompt_voice = voices
                .filter_for_engine_side(AudioSide::Prompt, VoiceEngine::OneCore)
                .map(find_voice)
                .transpose()?;
            let response_voice = voices
                .filter_for_engine_side(AudioSide::Response, VoiceEngine::OneCore)
                .map(find_voice)
                .transpose()?;

            Ok(Self {
                synthesizer,
                prompt_voice,
                response_voice,
            })
        }

        pub(crate) fn synthesize(
            &self,
            side: AudioSide,
            text: &str,
        ) -> Result<Vec<u8>, OneCoreError> {
            if let Some(voice) = self.voice_for_side(side) {
                self.synthesizer.SetVoice(voice)?;
            }

            let text = HSTRING::from(text);
            let stream = self
                .synthesizer
                .SynthesizeTextToStreamAsync(&text)?
                .join()?;
            let size = stream.Size()?;
            if size > u32::MAX as u64 {
                return Err(OneCoreError::AudioTooLarge(size));
            }

            let input = stream.GetInputStreamAt(0)?;
            let reader = DataReader::CreateDataReader(&input)?;
            reader.SetInputStreamOptions(InputStreamOptions::ReadAhead)?;
            let loaded = reader.LoadAsync(size as u32)?.join()?;
            let mut bytes = vec![0; loaded as usize];
            reader.ReadBytes(&mut bytes)?;
            Ok(bytes)
        }

        fn voice_for_side(&self, side: AudioSide) -> Option<&VoiceInformation> {
            match side {
                AudioSide::Prompt => self.prompt_voice.as_ref(),
                AudioSide::Response => self.response_voice.as_ref(),
            }
        }
    }

    pub(crate) fn voice_names() -> Result<Vec<VoiceName>, OneCoreError> {
        let voices = SpeechSynthesizer::AllVoices()?;
        let mut names = Vec::with_capacity(voices.Size()? as usize);
        for index in 0..voices.Size()? {
            names.push(voice_name(&voices.GetAt(index)?)?);
        }
        Ok(names)
    }

    fn find_voice(filter: &str) -> Result<VoiceInformation, OneCoreError> {
        let needle = filter.to_lowercase();
        let voices = SpeechSynthesizer::AllVoices()?;
        for index in 0..voices.Size()? {
            let voice = voices.GetAt(index)?;
            if searchable_voice_text(&voice)?.contains(&needle) {
                return Ok(voice);
            }
        }

        Err(OneCoreError::VoiceNotFound(filter.to_string()))
    }

    fn searchable_voice_text(voice: &VoiceInformation) -> Result<String, OneCoreError> {
        Ok(format!(
            "{}\n{}\n{}\n{}",
            voice.DisplayName()?.to_string_lossy(),
            voice.Description()?.to_string_lossy(),
            voice.Language()?.to_string_lossy(),
            voice.Id()?.to_string_lossy()
        )
        .to_lowercase())
    }

    fn voice_name(voice: &VoiceInformation) -> Result<VoiceName, OneCoreError> {
        Ok(VoiceName::new(
            format!(
                "{} [{}]",
                voice.DisplayName()?.to_string_lossy(),
                voice.Language()?.to_string_lossy()
            ),
            voice.Id()?.to_string_lossy(),
        ))
    }

    #[derive(Debug)]
    pub(crate) enum OneCoreError {
        Windows(WindowsError),
        VoiceNotFound(String),
        AudioTooLarge(u64),
    }

    impl fmt::Display for OneCoreError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                Self::Windows(source) => write!(f, "Windows speech synthesis failed: {source}"),
                Self::VoiceNotFound(filter) => {
                    write!(f, "no installed OneCore voice matched {filter:?}")
                }
                Self::AudioTooLarge(size) => {
                    write!(
                        f,
                        "generated audio stream is too large to write ({size} bytes)"
                    )
                }
            }
        }
    }

    impl std::error::Error for OneCoreError {}

    impl From<WindowsError> for OneCoreError {
        fn from(error: WindowsError) -> Self {
            Self::Windows(error)
        }
    }
}

#[cfg(windows)]
#[path = "voice/sapi.rs"]
mod sapi;

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

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

    fn empty_manifest() -> AudioManifest {
        AudioManifest::default()
    }

    fn voice_selection(prompt: Option<&str>, response: Option<&str>) -> VoiceSelection {
        VoiceSelection {
            default: None,
            prompt: prompt.map(str::to_string),
            response: response.map(str::to_string),
            default_engine: None,
            prompt_engine: None,
            response_engine: None,
        }
    }

    #[test]
    fn generation_summary_counts_files_and_unique_cards() {
        let mut summary = GenerationSummary::default();

        summary.record_generated(12, GenerationReason::MissingFile);
        summary.record_generated(12, GenerationReason::MissingFile);
        summary.record_generated(15, GenerationReason::MissingFile);

        assert_eq!(summary.generated_cards(), 2);
        assert_eq!(summary.generated_files(), 3);
        assert_eq!(summary.generated_new_files, 3);
        assert_eq!(summary.regenerated_changed_files, 0);
        assert_eq!(summary.regenerated_untracked_files, 0);
        assert_eq!(summary.overwritten_files, 0);
    }

    #[test]
    fn parses_default_options() {
        let options = Options::parse(["deck.cosimo-deck".to_string()]).unwrap();
        assert_eq!(options.deck_path, Some(PathBuf::from("deck.cosimo-deck")));
        assert_eq!(options.side, AudioSides::Both);
        assert!(!options.overwrite);
        assert!(!options.progress);
    }

    #[test]
    fn parses_progress_option() {
        let options =
            Options::parse(["--progress".to_string(), "deck.cosimo-deck".to_string()]).unwrap();
        assert!(options.progress);
    }

    #[test]
    fn parses_output_format_option() {
        let options = Options::parse([
            "--format".to_string(),
            "wav".to_string(),
            "deck.cosimo-deck".to_string(),
        ])
        .unwrap();
        assert_eq!(options.output_format, AudioOutputFormat::Wav);

        let options = Options::parse([
            "--audio-format=opus".to_string(),
            "deck.cosimo-deck".to_string(),
        ])
        .unwrap();
        assert_eq!(options.output_format, AudioOutputFormat::Opus);
    }

    #[test]
    fn parses_machine_voice_listing_option() {
        let options = Options::parse([
            "--list-voices".to_string(),
            "--engine=sapi".to_string(),
            "--machine".to_string(),
        ])
        .unwrap();

        assert!(options.list_voices);
        assert!(options.machine);
        assert_eq!(options.voices.default_engine, Some(VoiceEngine::Sapi));
    }

    #[test]
    fn resolves_bare_deck_name_under_portable_root_decks() {
        let options = Options::parse_with_portable_root(
            [
                "Indonesian".to_string(),
                "--voice".to_string(),
                "Andika".to_string(),
            ],
            Path::new("/portable/cosimo"),
        )
        .unwrap();
        assert_eq!(
            options.deck_path,
            Some(PathBuf::from(
                "/portable/cosimo/decks/Indonesian.cosimo-deck"
            ))
        );
        assert_eq!(options.voices.default, Some("Andika".to_string()));
    }

    #[test]
    fn explicit_deck_paths_are_not_rewritten() {
        let options = Options::parse_with_portable_root(
            ["decks/Indonesian.cosimo-deck".to_string()],
            Path::new("/portable/cosimo"),
        )
        .unwrap();
        assert_eq!(
            options.deck_path,
            Some(PathBuf::from("decks/Indonesian.cosimo-deck"))
        );
    }

    #[test]
    fn parses_prompt_and_response_voice_options() {
        let options = Options::parse([
            "--prompt-voice=Helena".to_string(),
            "--response-voice".to_string(),
            "David".to_string(),
            "deck.cosimo-deck".to_string(),
        ])
        .unwrap();
        assert_eq!(options.voices.prompt, Some("Helena".to_string()));
        assert_eq!(options.voices.response, Some("David".to_string()));
        assert_eq!(
            options.voices.filter_for_side(AudioSide::Prompt),
            Some("Helena")
        );
        assert_eq!(
            options.voices.filter_for_side(AudioSide::Response),
            Some("David")
        );
    }

    #[test]
    fn parses_prompt_and_response_engine_options() {
        let options = Options::parse([
            "--engine=onecore".to_string(),
            "--prompt-engine=sapi".to_string(),
            "--response-voice-engine".to_string(),
            "onecore".to_string(),
            "deck.cosimo-deck".to_string(),
        ])
        .unwrap();

        assert_eq!(
            options.voices.engine_for_side(AudioSide::Prompt),
            VoiceEngine::Sapi
        );
        assert_eq!(
            options.voices.engine_for_side(AudioSide::Response),
            VoiceEngine::OneCore
        );
    }

    #[test]
    fn default_voice_is_used_for_unset_sides() {
        let options = Options::parse([
            "--voice=Microsoft".to_string(),
            "--prompt-voice=Helena".to_string(),
            "deck.cosimo-deck".to_string(),
        ])
        .unwrap();
        assert_eq!(
            options.voices.filter_for_side(AudioSide::Prompt),
            Some("Helena")
        );
        assert_eq!(
            options.voices.filter_for_side(AudioSide::Response),
            Some("Microsoft")
        );
    }

    #[test]
    fn deck_metadata_voices_are_used_when_cli_voice_is_missing() {
        let mut metadata = DeckMetadata::v2();
        metadata.set(PROMPT_VOICE_METADATA_KEY, "Andika");
        metadata.set(RESPONSE_VOICE_METADATA_KEY, "David");
        let options = Options::parse([
            "--prompt-voice=Helena".to_string(),
            "deck.cosimo-deck".to_string(),
        ])
        .unwrap();

        let voices = options
            .voices
            .with_deck_metadata_defaults(&metadata)
            .unwrap();

        assert_eq!(voices.filter_for_side(AudioSide::Prompt), Some("Helena"));
        assert_eq!(voices.filter_for_side(AudioSide::Response), Some("David"));
    }

    #[test]
    fn deck_metadata_voice_engines_are_used_when_cli_engine_is_missing() {
        let mut metadata = DeckMetadata::v2();
        metadata.set(PROMPT_VOICE_ENGINE_METADATA_KEY, "sapi");
        metadata.set(RESPONSE_VOICE_ENGINE_METADATA_KEY, "onecore");
        let options = Options::parse([
            "--response-engine=sapi".to_string(),
            "deck.cosimo-deck".to_string(),
        ])
        .unwrap();

        let voices = options
            .voices
            .with_deck_metadata_defaults(&metadata)
            .unwrap();

        assert_eq!(voices.engine_for_side(AudioSide::Prompt), VoiceEngine::Sapi);
        assert_eq!(
            voices.engine_for_side(AudioSide::Response),
            VoiceEngine::Sapi
        );
    }

    #[test]
    fn default_cli_voice_overrides_deck_metadata_voices() {
        let mut metadata = DeckMetadata::v2();
        metadata.set(PROMPT_VOICE_METADATA_KEY, "Andika");
        metadata.set(RESPONSE_VOICE_METADATA_KEY, "David");
        let options = Options::parse([
            "--voice=Microsoft".to_string(),
            "deck.cosimo-deck".to_string(),
        ])
        .unwrap();

        let voices = options
            .voices
            .with_deck_metadata_defaults(&metadata)
            .unwrap();

        assert_eq!(voices.filter_for_side(AudioSide::Prompt), Some("Microsoft"));
        assert_eq!(
            voices.filter_for_side(AudioSide::Response),
            Some("Microsoft")
        );
    }

    #[test]
    fn plans_prompt_and_response_files() {
        let deck_path = Path::new("/tmp/Indonesian.cosimo-deck");
        let voices = voice_selection(Some("Andika"), Some("David"));
        let plan = generation_plan(
            deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Both,
            &voices,
            &empty_manifest(),
            AudioOutputFormat::Opus,
            false,
            true,
        );
        assert_eq!(
            plan.items,
            vec![
                GenerationItem::Generate {
                    card_id: 12,
                    side: AudioSide::Prompt,
                    engine: VoiceEngine::OneCore,
                    text: "satu".to_string(),
                    output: PathBuf::from("/tmp/Indonesian/12.prompt.opus"),
                    manifest_key: "12.prompt.opus".to_string(),
                    signature: AudioManifestEntry::onecore("satu", Some("Andika")),
                    reason: GenerationReason::MissingFile
                },
                GenerationItem::Generate {
                    card_id: 12,
                    side: AudioSide::Response,
                    engine: VoiceEngine::OneCore,
                    text: "one".to_string(),
                    output: PathBuf::from("/tmp/Indonesian/12.response.opus"),
                    manifest_key: "12.response.opus".to_string(),
                    signature: AudioManifestEntry::onecore("one", Some("David")),
                    reason: GenerationReason::MissingFile
                }
            ]
        );
    }

    #[test]
    fn existing_manifest_match_skips_unchanged_file() {
        let mut manifest = AudioManifest::default();
        manifest.set(
            "12.prompt.opus".to_string(),
            AudioManifestEntry::onecore("satu", Some("Andika")),
        );
        let voices = voice_selection(Some("Andika"), None);
        let temp_dir = unique_temp_dir("voice-manifest-skip");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.opus"), b"old audio").unwrap();

        let plan = generation_plan(
            &deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voices,
            &manifest,
            AudioOutputFormat::Opus,
            false,
            true,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::SkipManagedUnchanged {
                output: sidecar_dir.join("12.prompt.opus")
            }]
        );
    }

    #[test]
    fn managed_manifest_mismatch_regenerates_selected_format() {
        let temp_dir = unique_temp_dir("voice-manifest-regenerate");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.opus"), b"old audio").unwrap();
        let mut manifest = AudioManifest::default();
        manifest.set(
            "12.prompt.opus".to_string(),
            AudioManifestEntry::onecore("old text", Some("Andika")),
        );
        let voices = voice_selection(Some("Andika"), None);

        let plan = generation_plan(
            &deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voices,
            &manifest,
            AudioOutputFormat::Opus,
            false,
            true,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::Generate {
                card_id: 12,
                side: AudioSide::Prompt,
                engine: VoiceEngine::OneCore,
                text: "satu".to_string(),
                output: sidecar_dir.join("12.prompt.opus"),
                manifest_key: "12.prompt.opus".to_string(),
                signature: AudioManifestEntry::onecore("satu", Some("Andika")),
                reason: GenerationReason::ManagedRefresh,
            }]
        );
    }

    #[test]
    fn engine_selection_change_regenerates_managed_audio() {
        let temp_dir = unique_temp_dir("voice-manifest-engine-change");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.opus"), b"old audio").unwrap();
        let mut manifest = AudioManifest::default();
        manifest.set(
            "12.prompt.opus".to_string(),
            AudioManifestEntry::onecore("satu", Some("Andika")),
        );
        let mut voices = voice_selection(Some("Andika"), None);
        voices.prompt_engine = Some(VoiceEngine::Sapi);

        let plan = generation_plan(
            &deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voices,
            &manifest,
            AudioOutputFormat::Opus,
            false,
            true,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::Generate {
                card_id: 12,
                side: AudioSide::Prompt,
                engine: VoiceEngine::Sapi,
                text: "satu".to_string(),
                output: sidecar_dir.join("12.prompt.opus"),
                manifest_key: "12.prompt.opus".to_string(),
                signature: AudioManifestEntry::new("satu", VoiceEngine::Sapi, Some("Andika")),
                reason: GenerationReason::ManagedRefresh,
            }]
        );
    }

    #[test]
    fn existing_file_without_manifest_entry_is_preserved_without_overwrite() {
        let temp_dir = unique_temp_dir("voice-manifest-untracked");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.opus"), b"old audio").unwrap();

        let plan = generation_plan(
            &deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voice_selection(Some("Andika"), None),
            &empty_manifest(),
            AudioOutputFormat::Opus,
            false,
            true,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::PreserveUntrackedExisting {
                output: sidecar_dir.join("12.prompt.opus"),
            }]
        );
    }

    #[test]
    fn existing_file_without_manifest_file_is_preserved() {
        let temp_dir = unique_temp_dir("voice-manifest-missing-preserve");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.wav"), b"old audio").unwrap();

        let plan = generation_plan(
            &deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voice_selection(Some("Andika"), None),
            &empty_manifest(),
            AudioOutputFormat::Opus,
            false,
            false,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::PreserveUntrackedExisting {
                output: sidecar_dir.join("12.prompt.wav"),
            }]
        );
    }

    #[test]
    fn existing_wav_prevents_new_opus_generation() {
        let temp_dir = unique_temp_dir("voice-existing-wav");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.wav"), b"user audio").unwrap();

        let plan = generation_plan(
            &deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voice_selection(Some("Andika"), None),
            &empty_manifest(),
            AudioOutputFormat::Opus,
            false,
            true,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::PreserveUntrackedExisting {
                output: sidecar_dir.join("12.prompt.wav"),
            }]
        );
    }

    #[test]
    fn manual_mp3_manifest_entry_prevents_new_opus_generation() {
        let temp_dir = unique_temp_dir("voice-imported-mp3");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.mp3"), b"imported audio").unwrap();
        let mut manifest = AudioManifest::default();
        manifest.set(
            "12.prompt.mp3".to_string(),
            AudioManifestEntry::manual_anki(),
        );

        let plan = generation_plan(
            &deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voice_selection(Some("Andika"), None),
            &manifest,
            AudioOutputFormat::Opus,
            false,
            true,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::PreserveUntrackedExisting {
                output: sidecar_dir.join("12.prompt.mp3"),
            }]
        );

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

    #[test]
    fn manual_wav_is_preferred_over_manual_opus() {
        let temp_dir = unique_temp_dir("voice-manual-format-preference");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.wav"), b"wav audio").unwrap();
        fs::write(sidecar_dir.join("12.prompt.opus"), b"opus audio").unwrap();

        let plan = generation_plan(
            &deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voice_selection(Some("Andika"), None),
            &empty_manifest(),
            AudioOutputFormat::Opus,
            false,
            true,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::PreserveUntrackedExisting {
                output: sidecar_dir.join("12.prompt.wav"),
            }]
        );
    }

    #[test]
    fn selected_wav_format_plans_wav_file_names() {
        let plan = generation_plan(
            Path::new("/tmp/Indonesian.cosimo-deck"),
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voice_selection(Some("Andika"), None),
            &empty_manifest(),
            AudioOutputFormat::Wav,
            false,
            true,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::Generate {
                card_id: 12,
                side: AudioSide::Prompt,
                engine: VoiceEngine::OneCore,
                text: "satu".to_string(),
                output: PathBuf::from("/tmp/Indonesian/12.prompt.wav"),
                manifest_key: "12.prompt.wav".to_string(),
                signature: AudioManifestEntry::onecore("satu", Some("Andika")),
                reason: GenerationReason::MissingFile,
            }]
        );
    }

    #[test]
    fn managed_wav_is_converted_to_opus_when_opus_is_selected() {
        let temp_dir = unique_temp_dir("voice-managed-wav-convert");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.wav"), b"generated wav").unwrap();
        let mut manifest = AudioManifest::default();
        manifest.set(
            "12.prompt.wav".to_string(),
            AudioManifestEntry::onecore("satu", Some("Andika")),
        );

        let plan = generation_plan(
            &deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voice_selection(Some("Andika"), None),
            &manifest,
            AudioOutputFormat::Opus,
            false,
            true,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::Generate {
                card_id: 12,
                side: AudioSide::Prompt,
                engine: VoiceEngine::OneCore,
                text: "satu".to_string(),
                output: sidecar_dir.join("12.prompt.opus"),
                manifest_key: "12.prompt.opus".to_string(),
                signature: AudioManifestEntry::onecore("satu", Some("Andika")),
                reason: GenerationReason::ManagedRefresh,
            }]
        );
    }

    #[test]
    fn managed_opus_is_converted_to_wav_when_wav_is_selected() {
        let temp_dir = unique_temp_dir("voice-managed-opus-convert");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.opus"), b"generated opus").unwrap();
        let mut manifest = AudioManifest::default();
        manifest.set(
            "12.prompt.opus".to_string(),
            AudioManifestEntry::onecore("satu", Some("Andika")),
        );

        let plan = generation_plan(
            &deck_path,
            &[datum_with_id(12, "satu", "one")],
            AudioSides::Prompt,
            &voice_selection(Some("Andika"), None),
            &manifest,
            AudioOutputFormat::Wav,
            false,
            true,
        );

        assert_eq!(
            plan.items,
            vec![GenerationItem::Generate {
                card_id: 12,
                side: AudioSide::Prompt,
                engine: VoiceEngine::OneCore,
                text: "satu".to_string(),
                output: sidecar_dir.join("12.prompt.wav"),
                manifest_key: "12.prompt.wav".to_string(),
                signature: AudioManifestEntry::onecore("satu", Some("Andika")),
                reason: GenerationReason::ManagedRefresh,
            }]
        );
    }

    #[test]
    fn run_preserves_untracked_existing_files_without_manifest_changes() {
        let temp_dir = unique_temp_dir("voice-manifest-preserve-existing");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        let sidecar_dir = temp_dir.join("Indonesian");
        fs::write(&deck_path, "satu\n---\none\n=12=\n").unwrap();
        fs::create_dir_all(&sidecar_dir).unwrap();
        fs::write(sidecar_dir.join("12.prompt.opus"), b"old audio").unwrap();
        fs::write(sidecar_dir.join(MANIFEST_FILE_NAME), "version\t1\t\n").unwrap();

        run([
            "--side".to_string(),
            "prompt".to_string(),
            "--voice".to_string(),
            "Andika".to_string(),
            deck_path.display().to_string(),
        ])
        .unwrap();

        let manifest = AudioManifest::read(&sidecar_dir.join(MANIFEST_FILE_NAME)).unwrap();
        assert!(!manifest.contains("12.prompt.opus"));
    }

    #[test]
    fn run_refuses_manual_audio_decks() {
        let temp_dir = unique_temp_dir("voice-manual-audio");
        let deck_path = temp_dir.join("Indonesian.cosimo-deck");
        fs::write(
            &deck_path,
            "\
deck_version=2
audio_generation=disabled
:::

satu
---
one
=12=
",
        )
        .unwrap();

        let error = run([
            "--dry-run".to_string(),
            "--voice".to_string(),
            "Andika".to_string(),
            deck_path.display().to_string(),
        ])
        .unwrap_err();

        assert_eq!(
            error.to_string(),
            "this deck is marked as a manual audio deck; audio generation is disabled"
        );
    }

    #[test]
    fn manifest_round_trips_voice_fields() {
        let mut manifest = AudioManifest::default();
        manifest.set(
            "12.prompt.wav".to_string(),
            AudioManifestEntry::new("satu", VoiceEngine::Sapi, Some("Voice\twith\\escapes")),
        );

        let text = manifest_text(&manifest);
        let parsed = AudioManifest::parse(&text);

        assert_eq!(parsed, manifest);
    }

    #[test]
    fn legacy_manifest_entries_default_to_onecore_engine() {
        let parsed = AudioManifest::parse("version\t1\t\n12.prompt.wav\tabc\tAndika\n");

        assert!(parsed.matches(
            "12.prompt.wav",
            &AudioManifestEntry {
                text_hash: "abc".to_string(),
                engine: VoiceEngine::OneCore,
                voice: "Andika".to_string()
            }
        ));
    }

    #[test]
    fn skips_cards_without_ids() {
        let datum = Datum::new("satu", "one").unwrap();
        let plan = generation_plan(
            Path::new("/tmp/Indonesian.cosimo-deck"),
            &[datum],
            AudioSides::Prompt,
            &VoiceSelection::default(),
            &empty_manifest(),
            AudioOutputFormat::Opus,
            false,
            true,
        );
        assert_eq!(
            plan.items,
            vec![GenerationItem::SkipMissingCardId { index: 0 }]
        );
    }

    fn unique_temp_dir(prefix: &str) -> PathBuf {
        let unique = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!("{prefix}-{unique}"));
        fs::create_dir_all(&path).unwrap();
        path
    }

    fn manifest_text(manifest: &AudioManifest) -> String {
        let path = unique_temp_dir("voice-manifest-text").join(MANIFEST_FILE_NAME);
        manifest.write(&path).unwrap();
        fs::read_to_string(path).unwrap()
    }
}
