use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{ChildStderr, Command, ExitStatus, Stdio};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread::{self, JoinHandle};

use crate::app_config::AudioGenerationFormat;
use crate::file_ops::{default_logs_dir, path_io_error};
use crate::localization::Localizer;
use crate::time_format::format_unix_timestamp_file_utc;
use cosimo::DeckMetadata;
use wxdragon::id::ID_NONE;
use wxdragon::prelude::*;

const AUDIO_REPORT_SUFFIX: &str = "audio-generation";
const PROGRESS_DIALOG_RANGE: i32 = 1000;
pub(crate) const VOICE_ENGINE_ONECORE: &str = "onecore";
pub(crate) const VOICE_ENGINE_SAPI: &str = "sapi";
pub(crate) const VOICE_ENGINE_BOTH: &str = "both";

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AudioGenerationLaunch {
    pub(crate) voice_executable: PathBuf,
    pub(crate) deck_path: PathBuf,
    pub(crate) log_path: PathBuf,
    pub(crate) audio_format: AudioGenerationFormat,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AudioGenerationProgress {
    pub(crate) completed: usize,
    pub(crate) total: usize,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct AudioGenerationSummary {
    pub(crate) generated_cards: usize,
    pub(crate) generated_new_files: usize,
    pub(crate) regenerated_changed_files: usize,
    pub(crate) regenerated_untracked_files: usize,
    pub(crate) overwritten_files: usize,
    pub(crate) preserved_existing_files: usize,
    pub(crate) skipped_unchanged: usize,
    pub(crate) skipped_missing_id: usize,
    pub(crate) skipped_empty_text: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AudioGenerationEvent {
    Progress(AudioGenerationProgress),
    Finished(Result<AudioGenerationSummary, String>),
}

pub(crate) struct AudioGenerationRun {
    receiver: Receiver<AudioGenerationEvent>,
    worker: JoinHandle<()>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct VoiceChoice {
    pub(crate) engine: String,
    pub(crate) label: String,
    pub(crate) key: String,
}

pub(crate) fn audio_generation_metadata_is_complete(metadata: &DeckMetadata) -> bool {
    deck_metadata_value_present(metadata, "prompt_voice")
        && deck_metadata_value_present(metadata, "response_voice")
        && concrete_voice_engine_metadata(metadata.get("prompt_voice_engine")).is_some()
        && concrete_voice_engine_metadata(metadata.get("response_voice_engine")).is_some()
}

pub(crate) fn audio_generation_metadata_allows_generation(metadata: &DeckMetadata) -> bool {
    !metadata.audio_generation_is_disabled()
}

fn deck_metadata_value_present(metadata: &DeckMetadata, key: &str) -> bool {
    metadata
        .get(key)
        .map(str::trim)
        .is_some_and(|value| !value.is_empty())
}

fn concrete_voice_engine_metadata(value: Option<&str>) -> Option<&'static str> {
    match value?.trim().to_ascii_lowercase().as_str() {
        VOICE_ENGINE_ONECORE | "one-core" => Some(VOICE_ENGINE_ONECORE),
        VOICE_ENGINE_SAPI => Some(VOICE_ENGINE_SAPI),
        _ => None,
    }
}

pub(crate) fn audio_generation_launch_for_current_exe(
    deck_path: &Path,
    generated_at_unix_seconds: i64,
    audio_format: AudioGenerationFormat,
) -> io::Result<AudioGenerationLaunch> {
    let root = portable_root_dir()?;
    Ok(audio_generation_launch_for_root(
        &root,
        deck_path,
        generated_at_unix_seconds,
        audio_format,
    ))
}

fn portable_root_dir() -> io::Result<PathBuf> {
    let executable = std::env::current_exe()?;
    executable
        .parent()
        .map(Path::to_path_buf)
        .ok_or_else(|| io::Error::other("current executable has no parent directory"))
}

fn audio_generation_launch_for_root(
    portable_root: &Path,
    deck_path: &Path,
    generated_at_unix_seconds: i64,
    audio_format: AudioGenerationFormat,
) -> AudioGenerationLaunch {
    let timestamp = format_unix_timestamp_file_utc(generated_at_unix_seconds);
    AudioGenerationLaunch {
        voice_executable: voice_executable_for_root(portable_root),
        deck_path: deck_path.to_path_buf(),
        log_path: audio_generation_log_path(&default_logs_dir(), deck_path, &timestamp),
        audio_format,
    }
}

fn voice_executable_for_root(portable_root: &Path) -> PathBuf {
    portable_root.join(if cfg!(windows) { "voice.exe" } else { "voice" })
}

pub(crate) fn available_voice_choices_for_current_exe() -> BTreeMap<String, Vec<VoiceChoice>> {
    let Ok(root) = portable_root_dir() else {
        return BTreeMap::new();
    };
    available_voice_choices_for_executable(&voice_executable_for_root(&root))
}

fn available_voice_choices_for_executable(
    voice_executable: &Path,
) -> BTreeMap<String, Vec<VoiceChoice>> {
    let mut choices = BTreeMap::new();
    if !voice_executable.try_exists().unwrap_or(false) {
        return choices;
    }

    for engine in [VOICE_ENGINE_ONECORE, VOICE_ENGINE_SAPI] {
        if let Ok(engine_choices) = list_voice_choices_for_engine(voice_executable, engine)
            && !engine_choices.is_empty()
        {
            choices.insert(engine.to_string(), engine_choices);
        }
    }
    choices
}

fn list_voice_choices_for_engine(
    voice_executable: &Path,
    engine: &str,
) -> io::Result<Vec<VoiceChoice>> {
    let mut command = Command::new(voice_executable);
    command
        .arg("--list-voices")
        .arg("--engine")
        .arg(engine)
        .arg("--machine")
        .stdout(Stdio::piped())
        .stderr(Stdio::null());

    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        command.creation_flags(CREATE_NO_WINDOW);
    }

    let output = command.output()?;
    if !output.status.success() {
        return Ok(Vec::new());
    }
    let text = String::from_utf8_lossy(&output.stdout);
    Ok(parse_voice_choice_tsv(&text)
        .into_iter()
        .filter(|choice| choice.0 == engine)
        .map(|(_, choice)| choice)
        .collect())
}

fn parse_voice_choice_tsv(text: &str) -> Vec<(String, VoiceChoice)> {
    text.lines().filter_map(parse_voice_choice_line).collect()
}

fn parse_voice_choice_line(line: &str) -> Option<(String, VoiceChoice)> {
    let fields: Vec<_> = line.split('\t').collect();
    let [engine, label, key] = fields.as_slice() else {
        return None;
    };
    Some((
        unescape_voice_choice_field(engine),
        VoiceChoice {
            engine: unescape_voice_choice_field(engine),
            label: unescape_voice_choice_field(label),
            key: unescape_voice_choice_field(key),
        },
    ))
}

fn unescape_voice_choice_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 audio_generation_log_path(logs_dir: &Path, deck_path: &Path, timestamp: &str) -> PathBuf {
    let stem = format!(
        "{}-{AUDIO_REPORT_SUFFIX}",
        sanitized_deck_stem(deck_path).as_str()
    );
    unique_log_path(logs_dir, &stem, timestamp)
}

pub(crate) fn spawn_audio_generation(
    launch: &AudioGenerationLaunch,
) -> io::Result<AudioGenerationRun> {
    if !launch.voice_executable.try_exists().map_err(|error| {
        path_io_error(
            "could not check voice helper",
            &launch.voice_executable,
            error,
        )
    })? {
        return Err(io::Error::new(
            io::ErrorKind::NotFound,
            format!(
                "voice helper not found at {}",
                launch.voice_executable.display()
            ),
        ));
    }

    let deck_argument = fs::canonicalize(&launch.deck_path)
        .map_err(|error| path_io_error("could not locate deck file", &launch.deck_path, error))?;
    if let Some(parent) = launch.log_path.parent() {
        fs::create_dir_all(parent)
            .map_err(|error| path_io_error("could not create log directory", parent, error))?;
    }
    let log = File::create(&launch.log_path).map_err(|error| {
        path_io_error(
            "could not create audio generation log",
            &launch.log_path,
            error,
        )
    })?;
    let mut command = Command::new(&launch.voice_executable);
    command
        .arg(deck_argument)
        .arg("--format")
        .arg(launch.audio_format.code())
        .arg("--progress")
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        command.creation_flags(CREATE_NO_WINDOW);
    }

    let mut child = command.spawn().map_err(|error| {
        path_io_error(
            "could not start voice helper",
            &launch.voice_executable,
            error,
        )
    })?;
    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| io::Error::other("voice helper stdout was not captured"))?;
    let stderr = child
        .stderr
        .take()
        .ok_or_else(|| io::Error::other("voice helper stderr was not captured"))?;
    let (sender, receiver) = mpsc::channel();
    let worker = thread::spawn(move || {
        let result = collect_audio_generation_output(child, stdout, stderr, log, sender.clone());
        let _ = sender.send(AudioGenerationEvent::Finished(
            result.map_err(|error| error.to_string()),
        ));
    });

    Ok(AudioGenerationRun { receiver, worker })
}

pub(crate) fn show_audio_generation_progress_dialog<W: WxWidget>(
    parent: &W,
    localizer: Localizer,
    run: AudioGenerationRun,
) -> Result<AudioGenerationSummary, String> {
    let dialog = Dialog::builder(parent, localizer.audio_generation_progress_caption())
        .with_style(DialogStyle::Caption)
        .build();
    dialog.set_escape_id(ID_NONE);

    let status = StaticText::builder(&dialog)
        .with_label(localizer.audio_generation_progress_preparing())
        .build();
    let gauge = Gauge::builder(&dialog)
        .with_size(Size::new(420, -1))
        .with_style(GaugeStyle::Smooth | GaugeStyle::ShowProgress)
        .with_range(PROGRESS_DIALOG_RANGE)
        .build();
    gauge.set_name(localizer.audio_generation_progress_caption());

    let sizer = BoxSizer::builder(Orientation::Vertical).build();
    sizer.add(&status, 0, SizerFlag::Expand | SizerFlag::All, 12);
    sizer.add(
        &gauge,
        0,
        SizerFlag::Expand | SizerFlag::Left | SizerFlag::Right | SizerFlag::Bottom,
        12,
    );
    dialog.set_sizer_and_fit(sizer, true);
    dialog.centre();

    let (result_sender, result_receiver) = mpsc::channel();
    forward_audio_generation_progress_to_dialog(
        localizer.audio_generation_unknown_failure().to_string(),
        run,
        dialog,
        gauge,
        result_sender,
    );

    let _ = dialog.show_modal();
    dialog.destroy();
    result_receiver
        .try_recv()
        .unwrap_or_else(|_| Err(localizer.audio_generation_unknown_failure()))
}

fn forward_audio_generation_progress_to_dialog(
    unknown_failure: String,
    run: AudioGenerationRun,
    dialog: Dialog,
    gauge: Gauge,
    result_sender: Sender<Result<AudioGenerationSummary, String>>,
) {
    thread::spawn(move || {
        let mut last_progress = AudioGenerationProgress {
            completed: 0,
            total: 0,
        };
        let mut final_result = Err(unknown_failure);

        while let Ok(event) = run.receiver.recv() {
            match event {
                AudioGenerationEvent::Progress(progress) => {
                    last_progress = progress;
                    let value = scaled_progress_value(progress);
                    wxdragon::call_after(Box::new(move || {
                        gauge.set_value(value);
                    }));
                }
                AudioGenerationEvent::Finished(result) => {
                    final_result = result;
                    break;
                }
            }
        }

        if last_progress.total > 0 {
            wxdragon::call_after(Box::new(move || {
                gauge.set_value(PROGRESS_DIALOG_RANGE);
            }));
        }

        let _ = run.worker.join();
        let _ = result_sender.send(final_result);
        wxdragon::call_after(Box::new(move || {
            dialog.end_modal(ID_OK);
        }));
    });
}

fn collect_audio_generation_output(
    mut child: std::process::Child,
    stdout: std::process::ChildStdout,
    stderr: ChildStderr,
    mut report: File,
    sender: Sender<AudioGenerationEvent>,
) -> io::Result<AudioGenerationSummary> {
    let mut stderr_report = report.try_clone()?;
    let stderr_worker =
        thread::spawn(move || collect_audio_generation_stderr(stderr, &mut stderr_report));
    let reader = BufReader::new(stdout);
    let mut summary = None;
    for line in reader.lines() {
        let line = line?;
        writeln!(report, "{line}")?;
        if let Some(progress) = parse_audio_generation_progress(&line) {
            let _ = sender.send(AudioGenerationEvent::Progress(progress));
        }
        if let Some(parsed_summary) = parse_audio_generation_summary(&line) {
            summary = Some(parsed_summary);
        }
    }
    report.flush()?;

    let status = child.wait()?;
    let stderr_detail = stderr_worker
        .join()
        .map_err(|_| io::Error::other("voice helper stderr reader panicked"))??;
    if status.success() {
        Ok(summary.unwrap_or_default())
    } else {
        Err(io::Error::other(voice_helper_failure_message(
            status,
            stderr_detail.as_deref(),
        )))
    }
}

fn collect_audio_generation_stderr(
    stderr: ChildStderr,
    report: &mut File,
) -> io::Result<Option<String>> {
    let reader = BufReader::new(stderr);
    let mut last_detail = None;
    for line in reader.lines() {
        let line = line?;
        writeln!(report, "{line}")?;
        if let Some(detail) = useful_voice_helper_error_line(&line) {
            last_detail = Some(detail.to_string());
        }
    }
    report.flush()?;
    Ok(last_detail)
}

fn useful_voice_helper_error_line(line: &str) -> Option<&str> {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.strip_prefix("error: ").unwrap_or(trimmed))
    }
}

fn voice_helper_failure_message(status: ExitStatus, stderr_detail: Option<&str>) -> String {
    voice_helper_failure_message_text(&status.to_string(), stderr_detail)
}

fn voice_helper_failure_message_text(status: &str, stderr_detail: Option<&str>) -> String {
    match stderr_detail {
        Some(detail) if !detail.trim().is_empty() => {
            format!("voice helper exited with {status}: {}", detail.trim())
        }
        _ => format!("voice helper exited with {status}"),
    }
}

fn parse_audio_generation_progress(line: &str) -> Option<AudioGenerationProgress> {
    if let Some(total) = line.strip_prefix("cosimo-progress-total\t") {
        return total
            .parse::<usize>()
            .ok()
            .map(|total| AudioGenerationProgress {
                completed: 0,
                total,
            });
    }

    let fields: Vec<_> = line.split('\t').collect();
    if fields.len() == 3 && fields[0] == "cosimo-progress-step" {
        let completed = fields[1].parse::<usize>().ok()?;
        let total = fields[2].parse::<usize>().ok()?;
        return Some(AudioGenerationProgress {
            completed: completed.min(total),
            total,
        });
    }

    None
}

fn parse_audio_generation_summary(line: &str) -> Option<AudioGenerationSummary> {
    let fields: Vec<_> = line.split('\t').collect();
    if fields.len() != 10 || fields[0] != "cosimo-progress-summary" {
        return None;
    }

    Some(AudioGenerationSummary {
        generated_cards: fields[1].parse::<usize>().ok()?,
        generated_new_files: fields[2].parse::<usize>().ok()?,
        regenerated_changed_files: fields[3].parse::<usize>().ok()?,
        regenerated_untracked_files: fields[4].parse::<usize>().ok()?,
        overwritten_files: fields[5].parse::<usize>().ok()?,
        preserved_existing_files: fields[6].parse::<usize>().ok()?,
        skipped_unchanged: fields[7].parse::<usize>().ok()?,
        skipped_missing_id: fields[8].parse::<usize>().ok()?,
        skipped_empty_text: fields[9].parse::<usize>().ok()?,
    })
}

fn scaled_progress_value(progress: AudioGenerationProgress) -> i32 {
    if progress.total == 0 {
        return 0;
    }
    ((progress.completed.min(progress.total) * PROGRESS_DIALOG_RANGE as usize) / progress.total)
        as i32
}

fn unique_log_path(logs_dir: &Path, deck_stem: &str, timestamp: &str) -> PathBuf {
    let first = logs_dir.join(format!("{deck_stem}-{timestamp}.txt"));
    if !first.exists() {
        return first;
    }

    for suffix in 2..10_000 {
        let candidate = logs_dir.join(format!("{deck_stem}-{timestamp}-{suffix}.txt"));
        if !candidate.exists() {
            return candidate;
        }
    }

    logs_dir.join(format!("{deck_stem}-{timestamp}-10000.txt"))
}

fn sanitized_deck_stem(deck_path: &Path) -> String {
    let stem = deck_path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("deck")
        .trim();
    let sanitized: String = stem
        .chars()
        .map(|character| {
            if character.is_control()
                || matches!(
                    character,
                    '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
                )
            {
                '_'
            } else {
                character
            }
        })
        .collect();
    if sanitized.is_empty() {
        "deck".to_string()
    } else {
        sanitized
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::process;
    use std::time::{SystemTime, UNIX_EPOCH};

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

    #[test]
    fn launch_uses_voice_binary_beside_current_executable() {
        let launch = audio_generation_launch_for_root(
            Path::new("/portable"),
            Path::new("/portable/decks/Indonesian.cosimo-deck"),
            0,
            AudioGenerationFormat::Opus,
        );

        assert_eq!(
            launch.voice_executable,
            Path::new("/portable").join(if cfg!(windows) { "voice.exe" } else { "voice" })
        );
        assert_eq!(
            launch.deck_path,
            Path::new("/portable/decks/Indonesian.cosimo-deck")
        );
        assert!(
            launch
                .log_path
                .ends_with("Indonesian-audio-generation-1970-01-01-00-00.txt")
        );
        assert_eq!(launch.audio_format, AudioGenerationFormat::Opus);
    }

    #[test]
    fn log_paths_are_sanitized_for_file_systems() {
        let path = audio_generation_log_path(
            Path::new("logs"),
            Path::new("bad:name?.cosimo-deck"),
            "2026-05-09-10-30",
        );

        assert_eq!(
            path,
            Path::new("logs").join("bad_name_-audio-generation-2026-05-09-10-30.txt")
        );
    }

    #[test]
    fn spawn_reports_audio_generation_log_directory_creation_path() {
        let temp_dir = unique_temp_dir("log-dir-error");
        let voice_executable = temp_dir.join(if cfg!(windows) { "voice.exe" } else { "voice" });
        let deck_path = temp_dir.join("deck.cosimo-deck");
        let not_a_directory = temp_dir.join("not-a-directory");
        fs::write(&voice_executable, "not actually executed").unwrap();
        fs::write(&deck_path, "deck_version=2\n:::\n").unwrap();
        fs::write(&not_a_directory, "file").unwrap();
        let launch = AudioGenerationLaunch {
            voice_executable,
            deck_path,
            log_path: not_a_directory.join("audio-generation.txt"),
            audio_format: AudioGenerationFormat::Opus,
        };

        let error = match spawn_audio_generation(&launch) {
            Ok(_) => panic!("audio generation should fail before launching helper"),
            Err(error) => error,
        };

        assert!(error.to_string().contains("could not create log directory"));
        assert!(
            error
                .to_string()
                .contains(&not_a_directory.display().to_string())
        );
    }

    #[test]
    fn progress_lines_parse_machine_readable_helper_output() {
        assert_eq!(
            parse_audio_generation_progress("cosimo-progress-total\t12"),
            Some(AudioGenerationProgress {
                completed: 0,
                total: 12
            })
        );
        assert_eq!(
            parse_audio_generation_progress("cosimo-progress-step\t5\t12"),
            Some(AudioGenerationProgress {
                completed: 5,
                total: 12
            })
        );
        assert_eq!(
            parse_audio_generation_progress("skipping unchanged deck/1.prompt.wav"),
            None
        );
    }

    #[test]
    fn summary_lines_parse_machine_readable_helper_output() {
        assert_eq!(
            parse_audio_generation_summary("cosimo-progress-summary\t3\t4\t5\t6\t7\t8\t9\t10\t11"),
            Some(AudioGenerationSummary {
                generated_cards: 3,
                generated_new_files: 4,
                regenerated_changed_files: 5,
                regenerated_untracked_files: 6,
                overwritten_files: 7,
                preserved_existing_files: 8,
                skipped_unchanged: 9,
                skipped_missing_id: 10,
                skipped_empty_text: 11,
            })
        );
        assert_eq!(
            parse_audio_generation_summary("generated 3; skipped unchanged 7"),
            None
        );
    }

    #[test]
    fn voice_helper_failure_reports_useful_stderr_detail() {
        assert_eq!(
            useful_voice_helper_error_line("error: no installed OneCore voice matched \"Andika\""),
            Some("no installed OneCore voice matched \"Andika\"")
        );
        assert_eq!(useful_voice_helper_error_line(""), None);
        assert_eq!(
            voice_helper_failure_message_text(
                "exit status: 1",
                Some("no installed SAPI voice matched \"David\""),
            ),
            "voice helper exited with exit status: 1: no installed SAPI voice matched \"David\""
        );
        assert_eq!(
            voice_helper_failure_message_text("exit status: 1", None),
            "voice helper exited with exit status: 1"
        );
    }

    #[test]
    fn voice_choice_lines_parse_machine_readable_helper_output() {
        assert_eq!(
            parse_voice_choice_tsv(
                "onecore\tMicrosoft\\tHelena\tvoice\\\\id\nbad\nsapi\tDavid\tDavid\n"
            ),
            vec![
                (
                    VOICE_ENGINE_ONECORE.to_string(),
                    VoiceChoice {
                        engine: VOICE_ENGINE_ONECORE.to_string(),
                        label: "Microsoft\tHelena".to_string(),
                        key: "voice\\id".to_string()
                    }
                ),
                (
                    VOICE_ENGINE_SAPI.to_string(),
                    VoiceChoice {
                        engine: VOICE_ENGINE_SAPI.to_string(),
                        label: "David".to_string(),
                        key: "David".to_string()
                    }
                )
            ]
        );
    }

    #[test]
    fn audio_generation_metadata_requires_both_voice_sides_and_concrete_engines() {
        let mut metadata = DeckMetadata::empty();
        assert!(!audio_generation_metadata_is_complete(&metadata));
        assert!(audio_generation_metadata_allows_generation(&metadata));

        metadata.set("prompt_voice", "Andika");
        metadata.set("response_voice", "David");
        metadata.set("prompt_voice_engine", VOICE_ENGINE_ONECORE);
        metadata.set("response_voice_engine", VOICE_ENGINE_BOTH);
        assert!(!audio_generation_metadata_is_complete(&metadata));

        metadata.set("response_voice_engine", VOICE_ENGINE_SAPI);
        assert!(audio_generation_metadata_is_complete(&metadata));

        metadata.set_audio_generation_disabled(true);
        assert!(audio_generation_metadata_is_complete(&metadata));
        assert!(!audio_generation_metadata_allows_generation(&metadata));
    }

    #[test]
    fn progress_value_scales_to_dialog_range() {
        assert_eq!(
            scaled_progress_value(AudioGenerationProgress {
                completed: 5,
                total: 10
            }),
            500
        );
        assert_eq!(
            scaled_progress_value(AudioGenerationProgress {
                completed: 15,
                total: 10
            }),
            PROGRESS_DIALOG_RANGE
        );
    }
}
