use crate::audio_generation::{
    VOICE_ENGINE_BOTH, VOICE_ENGINE_ONECORE, VOICE_ENGINE_SAPI, VoiceChoice,
    available_voice_choices_for_current_exe,
};
use crate::dialog_support::{
    is_affirmative_dialog_result, is_dialog_accept_key, show_info_dialog_with_parent,
    show_yes_no_dialog_with_parent,
};
use crate::learning_dashboard_model::{
    LearningDashboardTextSection, learning_dashboard_markdown_text,
};
use crate::localization::{DeckPropertiesLabel as PropertyLabel, Localizer};
use crate::review_report::{
    learning_dashboard_report_file, review_report_file, save_review_report,
};
use crate::review_session::ReviewCycleSummary;
use crate::sync_remote_decks_model::RemoteDeckListEntry;
use crate::{
    CardDetailsUi, CardEditorUi, CycleStatsUi, DeckPropertiesEditorUi, LearningDashboardUi,
    SyncRemoteDecksUi,
};
use cosimo::{Datum, DatumValidationError, DeckMetadata, DeckVisibility, SyncUuid};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::Path;
use std::rc::Rc;
use wxdragon::id::{ID_CANCEL, ID_OK};
use wxdragon::prelude::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RemoveCardConfirmation {
    Normal,
    ReversibleParent,
    Reverse,
}

pub(crate) fn show_card_details_dialog(parent: Frame, localizer: Localizer, details: &str) {
    show_text_report_dialog(
        parent,
        localizer.card_details_dialog_title(),
        localizer.card_details_dialog_status(),
        localizer.card_details_close_button(),
        details,
    );
}

pub(crate) fn show_learning_dashboard_dialog(
    parent: Frame,
    localizer: Localizer,
    sections: &[LearningDashboardTextSection],
    deck_path: &Path,
    review_db_path: &Path,
    generated_at_unix_seconds: i64,
) {
    let dashboard = learning_dashboard_markdown_text(sections);
    let report = learning_dashboard_report_file(
        localizer,
        deck_path,
        review_db_path,
        &dashboard,
        generated_at_unix_seconds,
    );
    show_learning_dashboard_sections_dialog(parent, localizer, sections, &dashboard, report);
}

fn show_learning_dashboard_sections_dialog(
    parent: Frame,
    localizer: Localizer,
    sections: &[LearningDashboardTextSection],
    dashboard: &str,
    report: crate::review_report::ReviewReportFile,
) {
    let dialog = LearningDashboardUi::new(Some(&parent), false);
    let close_id = LearningDashboardUi::xrc_id("button_learning_dashboard_close");

    dialog
        .scroll_learning_dashboard_sections
        .set_scroll_rate(0, 20);
    dialog
        .dialog_learning_dashboard
        .set_label(localizer.learning_dashboard_dialog_title());
    dialog
        .label_learning_dashboard_status
        .set_label(localizer.learning_dashboard_dialog_status());
    dialog
        .button_learning_dashboard_save_report
        .set_label(localizer.save_report_button());
    dialog
        .button_learning_dashboard_copy_report
        .set_label(localizer.copy_report_button());
    dialog
        .button_learning_dashboard_close
        .set_label(localizer.card_details_close_button());

    let section_controls = [
        (
            dialog.panel_learning_dashboard_section_1,
            dialog.label_learning_dashboard_section_1,
            dialog.text_learning_dashboard_section_1,
        ),
        (
            dialog.panel_learning_dashboard_section_2,
            dialog.label_learning_dashboard_section_2,
            dialog.text_learning_dashboard_section_2,
        ),
        (
            dialog.panel_learning_dashboard_section_3,
            dialog.label_learning_dashboard_section_3,
            dialog.text_learning_dashboard_section_3,
        ),
        (
            dialog.panel_learning_dashboard_section_4,
            dialog.label_learning_dashboard_section_4,
            dialog.text_learning_dashboard_section_4,
        ),
        (
            dialog.panel_learning_dashboard_section_5,
            dialog.label_learning_dashboard_section_5,
            dialog.text_learning_dashboard_section_5,
        ),
        (
            dialog.panel_learning_dashboard_section_6,
            dialog.label_learning_dashboard_section_6,
            dialog.text_learning_dashboard_section_6,
        ),
        (
            dialog.panel_learning_dashboard_section_7,
            dialog.label_learning_dashboard_section_7,
            dialog.text_learning_dashboard_section_7,
        ),
        (
            dialog.panel_learning_dashboard_section_8,
            dialog.label_learning_dashboard_section_8,
            dialog.text_learning_dashboard_section_8,
        ),
    ];
    for (index, (panel, label, text)) in section_controls.iter().enumerate() {
        match sections.get(index) {
            Some(section) => {
                panel.show(true);
                label.set_label(section.heading());
                text.set_value(section.body());
                text.set_insertion_point(0);
                bind_close_on_text_accept(*text, dialog.dialog_learning_dashboard);
            }
            None => {
                panel.show(false);
                text.set_value("");
            }
        }
    }

    apply_learning_dashboard_visual_style(
        dialog.label_learning_dashboard_status,
        &section_controls,
        dialog.button_learning_dashboard_save_report,
        dialog.button_learning_dashboard_copy_report,
        dialog.button_learning_dashboard_close,
    );

    dialog.button_learning_dashboard_save_report.on_click({
        let status = dialog.label_learning_dashboard_status;
        move |_| match save_review_report(&report) {
            Ok(path) => {
                status.set_label(
                    &localizer.saved_learning_dashboard_report_status(&path.display().to_string()),
                );
            }
            Err(error) => {
                status.set_label(&localizer.could_not_save_learning_dashboard_report_status(
                    &report.path().display().to_string(),
                    &error.to_string(),
                ));
            }
        }
    });
    dialog.button_learning_dashboard_copy_report.on_click({
        let status = dialog.label_learning_dashboard_status;
        let dashboard = dashboard.to_string();
        move |_| {
            if copy_text_to_clipboard(&dashboard) {
                status.set_label(localizer.copied_learning_dashboard_report_status());
            } else {
                status.set_label(localizer.could_not_copy_learning_dashboard_report_status());
            }
        }
    });
    dialog.button_learning_dashboard_close.on_click({
        let dialog = dialog.dialog_learning_dashboard;
        move |_| dialog.end_modal(ID_OK)
    });

    dialog
        .dialog_learning_dashboard
        .set_affirmative_id(close_id);
    dialog.dialog_learning_dashboard.set_escape_id(close_id);
    dialog.button_learning_dashboard_close.set_default();
    if let Some((_, _, first_text)) = section_controls.first() {
        first_text.set_focus();
    }
    dialog.scroll_learning_dashboard_sections.layout();
    dialog.panel_learning_dashboard_root.layout();

    let _ = dialog.dialog_learning_dashboard.show_modal();
    dialog.dialog_learning_dashboard.destroy();
}

pub(crate) fn show_deck_properties_dialog(parent: Frame, localizer: Localizer, details: &str) {
    show_text_report_dialog(
        parent,
        localizer.deck_properties_dialog_title(),
        localizer.deck_properties_dialog_status(),
        localizer.card_details_close_button(),
        details,
    );
}

pub(crate) fn show_sync_status_dialog(parent: Frame, localizer: Localizer, details: &str) {
    show_text_report_dialog(
        parent,
        localizer.sync_status_dialog_title(),
        localizer.sync_status_dialog_status(),
        localizer.card_details_close_button(),
        details,
    );
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncRemoteDecksDialogResult {
    Close,
    Details(usize),
    CopyId(usize),
    FindById,
    Download(usize),
    Delete(usize),
}

pub(crate) fn show_sync_remote_decks_list_dialog(
    parent: Frame,
    localizer: Localizer,
    entries: &[RemoteDeckListEntry],
    selected_index: Option<usize>,
) -> SyncRemoteDecksDialogResult {
    let dialog = SyncRemoteDecksUi::new(Some(&parent), false);
    let details_id = SyncRemoteDecksUi::xrc_id("button_sync_remote_decks_details");
    let copy_id_id = SyncRemoteDecksUi::xrc_id("button_sync_remote_decks_copy_id");
    let find_by_id_id = SyncRemoteDecksUi::xrc_id("button_sync_remote_decks_find_by_id");
    let download_id = SyncRemoteDecksUi::xrc_id("button_sync_remote_decks_download");
    let delete_id = SyncRemoteDecksUi::xrc_id("button_sync_remote_decks_delete");
    let close_id = SyncRemoteDecksUi::xrc_id("button_sync_remote_decks_close");

    dialog
        .dialog_sync_remote_decks
        .set_label(localizer.sync_remote_decks_dialog_title());
    dialog
        .label_sync_remote_decks_status
        .set_label(localizer.sync_remote_decks_dialog_status());
    dialog
        .list_sync_remote_decks
        .set_name(localizer.sync_remote_decks_list_label());
    dialog
        .button_sync_remote_decks_details
        .set_label(localizer.sync_remote_decks_details_button());
    dialog
        .button_sync_remote_decks_copy_id
        .set_label(localizer.sync_remote_decks_copy_id_button());
    dialog
        .button_sync_remote_decks_find_by_id
        .set_label(localizer.sync_remote_decks_find_by_id_button());
    dialog
        .button_sync_remote_decks_download
        .set_label(localizer.sync_remote_decks_download_button());
    dialog
        .button_sync_remote_decks_delete
        .set_label(localizer.sync_remote_decks_delete_button());
    dialog
        .button_sync_remote_decks_close
        .set_label(localizer.card_details_close_button());

    for entry in entries {
        dialog
            .list_sync_remote_decks
            .append(&entry.label(localizer));
    }
    let has_entries = !entries.is_empty();
    dialog.button_sync_remote_decks_details.enable(has_entries);
    dialog.button_sync_remote_decks_copy_id.enable(has_entries);
    dialog.button_sync_remote_decks_download.enable(has_entries);
    let owned_entries: Vec<bool> = entries.iter().map(RemoteDeckListEntry::is_owned).collect();
    if let Some(index) = selected_index
        && index < entries.len()
    {
        dialog
            .list_sync_remote_decks
            .set_selection(index as u32, true);
    } else if has_entries {
        dialog.list_sync_remote_decks.set_selection(0, true);
    }
    let selected_can_delete = dialog
        .list_sync_remote_decks
        .get_selection()
        .and_then(|selection| owned_entries.get(selection as usize))
        .copied()
        .unwrap_or(false);
    dialog
        .button_sync_remote_decks_delete
        .enable(selected_can_delete);
    dialog.list_sync_remote_decks.on_selection_changed({
        let delete_button = dialog.button_sync_remote_decks_delete;
        let owned_entries = Rc::new(owned_entries);
        move |event| {
            let selected_can_delete = event
                .get_selection()
                .and_then(|selection| owned_entries.get(selection as usize))
                .copied()
                .unwrap_or(false);
            delete_button.enable(selected_can_delete);
        }
    });

    let modal_dialog = dialog.dialog_sync_remote_decks;
    dialog
        .button_sync_remote_decks_details
        .on_click(move |_| modal_dialog.end_modal(details_id));

    let modal_dialog = dialog.dialog_sync_remote_decks;
    dialog
        .button_sync_remote_decks_copy_id
        .on_click(move |_| modal_dialog.end_modal(copy_id_id));

    let modal_dialog = dialog.dialog_sync_remote_decks;
    dialog
        .button_sync_remote_decks_find_by_id
        .on_click(move |_| modal_dialog.end_modal(find_by_id_id));

    let modal_dialog = dialog.dialog_sync_remote_decks;
    dialog
        .button_sync_remote_decks_download
        .on_click(move |_| modal_dialog.end_modal(download_id));

    let modal_dialog = dialog.dialog_sync_remote_decks;
    dialog
        .button_sync_remote_decks_delete
        .on_click(move |_| modal_dialog.end_modal(delete_id));

    let modal_dialog = dialog.dialog_sync_remote_decks;
    dialog
        .button_sync_remote_decks_close
        .on_click(move |_| modal_dialog.end_modal(close_id));

    if has_entries {
        dialog
            .dialog_sync_remote_decks
            .set_affirmative_id(download_id);
        dialog.button_sync_remote_decks_download.set_default();
    } else {
        dialog
            .dialog_sync_remote_decks
            .set_affirmative_id(find_by_id_id);
        dialog.button_sync_remote_decks_find_by_id.set_default();
    }
    dialog.dialog_sync_remote_decks.set_escape_id(close_id);
    if has_entries {
        dialog.list_sync_remote_decks.set_focus();
    } else {
        dialog.button_sync_remote_decks_find_by_id.set_focus();
    }
    dialog.panel_sync_remote_decks_root.layout();

    let modal_result = dialog.dialog_sync_remote_decks.show_modal();
    let selection = dialog
        .list_sync_remote_decks
        .get_selection()
        .map(|selection| selection as usize)
        .filter(|index| *index < entries.len())
        .unwrap_or(0);
    dialog.dialog_sync_remote_decks.destroy();

    if modal_result == details_id && has_entries {
        SyncRemoteDecksDialogResult::Details(selection)
    } else if modal_result == copy_id_id && has_entries {
        SyncRemoteDecksDialogResult::CopyId(selection)
    } else if modal_result == find_by_id_id {
        SyncRemoteDecksDialogResult::FindById
    } else if modal_result == download_id && has_entries {
        SyncRemoteDecksDialogResult::Download(selection)
    } else if modal_result == delete_id
        && entries
            .get(selection)
            .is_some_and(RemoteDeckListEntry::is_owned)
    {
        SyncRemoteDecksDialogResult::Delete(selection)
    } else {
        SyncRemoteDecksDialogResult::Close
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DeckPropertiesEditorResult {
    pub(crate) metadata: DeckMetadata,
    pub(crate) replaced_sync_deck_uuid: Option<SyncUuid>,
}

pub(crate) fn show_deck_properties_editor_dialog(
    parent: Frame,
    localizer: Localizer,
    metadata: &DeckMetadata,
) -> Option<DeckPropertiesEditorResult> {
    let dialog = DeckPropertiesEditorUi::new(Some(&parent), false);
    let save_id = DeckPropertiesEditorUi::xrc_id("button_deck_properties_save");
    let cancel_id = DeckPropertiesEditorUi::xrc_id("button_deck_properties_cancel");

    dialog
        .dialog_deck_properties_editor
        .set_label(localizer.deck_properties_editor_title());
    dialog
        .label_deck_properties_editor_status
        .set_label(localizer.deck_properties_editor_status());
    dialog
        .label_deck_properties_title
        .set_label(localizer.deck_properties_label(PropertyLabel::Title));
    dialog
        .label_deck_properties_author
        .set_label(localizer.deck_properties_label(PropertyLabel::Author));
    dialog
        .label_deck_properties_description
        .set_label(localizer.deck_properties_label(PropertyLabel::Description));
    dialog
        .label_deck_properties_url
        .set_label(localizer.deck_properties_label(PropertyLabel::Url));
    dialog
        .label_deck_properties_visibility
        .set_label(localizer.deck_properties_label(PropertyLabel::Visibility));
    dialog
        .label_deck_properties_prompt_voice
        .set_label(localizer.deck_properties_label(PropertyLabel::PromptVoice));
    dialog
        .label_deck_properties_prompt_voice_engine
        .set_label(localizer.deck_properties_label(PropertyLabel::PromptVoiceEngine));
    dialog
        .label_deck_properties_response_voice
        .set_label(localizer.deck_properties_label(PropertyLabel::ResponseVoice));
    dialog
        .label_deck_properties_response_voice_engine
        .set_label(localizer.deck_properties_label(PropertyLabel::ResponseVoiceEngine));
    let manual_audio_label = localizer.deck_properties_manual_audio_checkbox();
    dialog
        .checkbox_deck_properties_manual_audio
        .set_label(manual_audio_label);
    dialog
        .checkbox_deck_properties_manual_audio
        .set_name(manual_audio_label);
    dialog
        .button_deck_properties_make_independent_copy
        .set_label(localizer.deck_properties_make_independent_copy_button());
    dialog
        .button_deck_properties_cancel
        .set_label(localizer.card_editor_cancel_button());
    dialog
        .button_deck_properties_save
        .set_label(localizer.card_editor_save_button());

    dialog
        .text_deck_properties_title
        .set_value(metadata.get("title").unwrap_or(""));
    dialog
        .text_deck_properties_author
        .set_value(metadata.get("author").unwrap_or(""));
    dialog
        .text_deck_properties_description
        .set_value(metadata.get("description").unwrap_or(""));
    dialog
        .text_deck_properties_url
        .set_value(metadata.get("url").unwrap_or(""));
    populate_visibility_choice(
        dialog.choice_deck_properties_visibility,
        localizer,
        metadata.deck_visibility(),
    );
    dialog
        .text_deck_properties_prompt_voice
        .set_value(metadata.get("prompt_voice").unwrap_or(""));
    dialog
        .text_deck_properties_prompt_voice_engine
        .set_value(metadata.get("prompt_voice_engine").unwrap_or(""));
    dialog
        .text_deck_properties_response_voice
        .set_value(metadata.get("response_voice").unwrap_or(""));
    dialog
        .text_deck_properties_response_voice_engine
        .set_value(metadata.get("response_voice_engine").unwrap_or(""));
    dialog
        .checkbox_deck_properties_manual_audio
        .set_value(metadata.audio_generation_is_disabled());
    populate_engine_combo(dialog.text_deck_properties_prompt_voice_engine);
    populate_engine_combo(dialog.text_deck_properties_response_voice_engine);
    let voice_choices = available_voice_choices_for_current_exe();
    set_voice_combo_choices(
        dialog.text_deck_properties_prompt_voice,
        metadata.get("prompt_voice").unwrap_or(""),
        metadata.get("prompt_voice_engine").unwrap_or(""),
        &voice_choices,
    );
    set_voice_combo_choices(
        dialog.text_deck_properties_response_voice,
        metadata.get("response_voice").unwrap_or(""),
        metadata.get("response_voice_engine").unwrap_or(""),
        &voice_choices,
    );
    dialog.text_deck_properties_title.set_insertion_point(0);
    dialog.text_deck_properties_author.set_insertion_point(0);
    dialog
        .text_deck_properties_description
        .set_insertion_point(0);
    dialog.text_deck_properties_url.set_insertion_point(0);
    dialog
        .text_deck_properties_prompt_voice
        .set_insertion_point(0);
    dialog
        .text_deck_properties_prompt_voice_engine
        .set_insertion_point(0);
    dialog
        .text_deck_properties_response_voice
        .set_insertion_point(0);
    dialog
        .text_deck_properties_response_voice_engine
        .set_insertion_point(0);

    let independent_copy_uuid = Rc::new(RefCell::new(None));
    dialog
        .button_deck_properties_make_independent_copy
        .on_click({
            let modal_dialog = dialog.dialog_deck_properties_editor;
            let status = dialog.label_deck_properties_editor_status;
            let visibility = dialog.choice_deck_properties_visibility;
            let independent_copy_uuid = Rc::clone(&independent_copy_uuid);
            move |_| {
                if !show_yes_no_dialog_with_parent(
                    &modal_dialog,
                    localizer.deck_properties_make_independent_copy_caption(),
                    localizer.deck_properties_make_independent_copy_message(),
                ) {
                    return;
                }

                match SyncUuid::new_v4() {
                    Ok(uuid) => {
                        *independent_copy_uuid.borrow_mut() = Some(uuid);
                        visibility
                            .set_selection(visibility_selection_index(DeckVisibility::Private));
                        status.set_label(localizer.deck_properties_make_independent_copy_status());
                    }
                    Err(error) => {
                        status.set_label(
                            &localizer.deck_properties_make_independent_copy_error_status(
                                &error.to_string(),
                            ),
                        );
                    }
                }
            }
        });

    let modal_dialog = dialog.dialog_deck_properties_editor;
    let status = dialog.label_deck_properties_editor_status;
    let title = dialog.text_deck_properties_title;
    let author = dialog.text_deck_properties_author;
    let description = dialog.text_deck_properties_description;
    let url = dialog.text_deck_properties_url;
    let prompt_voice = dialog.text_deck_properties_prompt_voice;
    let prompt_voice_engine = dialog.text_deck_properties_prompt_voice_engine;
    let response_voice = dialog.text_deck_properties_response_voice;
    let response_voice_engine = dialog.text_deck_properties_response_voice_engine;
    let voice_choices_for_validation = voice_choices.clone();
    dialog.button_deck_properties_save.on_click(move |_| {
        for text_control in [title, author, description, url] {
            if !deck_metadata_field_is_single_line(&text_control.get_value()) {
                status.set_label(localizer.deck_properties_editor_single_line_status());
                text_control.set_focus();
                return;
            }
        }
        for combo_control in [
            prompt_voice,
            prompt_voice_engine,
            response_voice,
            response_voice_engine,
        ] {
            if !deck_metadata_field_is_single_line(&combo_control.get_value()) {
                status.set_label(localizer.deck_properties_editor_single_line_status());
                combo_control.set_focus();
                return;
            }
        }
        if voice_selection_requires_engine(
            prompt_voice,
            &voice_choices_for_validation,
            &prompt_voice_engine.get_value(),
        ) {
            status.set_label(localizer.deck_properties_editor_voice_engine_status());
            prompt_voice_engine.set_focus();
            return;
        }
        if voice_selection_requires_engine(
            response_voice,
            &voice_choices_for_validation,
            &response_voice_engine.get_value(),
        ) {
            status.set_label(localizer.deck_properties_editor_voice_engine_status());
            response_voice_engine.set_focus();
            return;
        }
        modal_dialog.end_modal(ID_OK);
    });

    let voice_choices_for_prompt = voice_choices.clone();
    let prompt_voice = dialog.text_deck_properties_prompt_voice;
    let prompt_voice_engine = dialog.text_deck_properties_prompt_voice_engine;
    dialog
        .text_deck_properties_prompt_voice_engine
        .on_selection_changed(move |_| {
            let current_voice = metadata_value_for_voice_combo(
                prompt_voice,
                &voice_choices_for_prompt,
                &prompt_voice_engine.get_value(),
            )
            .voice;
            set_voice_combo_choices(
                prompt_voice,
                &current_voice,
                &prompt_voice_engine.get_value(),
                &voice_choices_for_prompt,
            );
        });

    let voice_choices_for_response = voice_choices.clone();
    let response_voice = dialog.text_deck_properties_response_voice;
    let response_voice_engine = dialog.text_deck_properties_response_voice_engine;
    dialog
        .text_deck_properties_response_voice_engine
        .on_selection_changed(move |_| {
            let current_voice = metadata_value_for_voice_combo(
                response_voice,
                &voice_choices_for_response,
                &response_voice_engine.get_value(),
            )
            .voice;
            set_voice_combo_choices(
                response_voice,
                &current_voice,
                &response_voice_engine.get_value(),
                &voice_choices_for_response,
            );
        });

    let modal_dialog = dialog.dialog_deck_properties_editor;
    dialog
        .button_deck_properties_cancel
        .on_click(move |_| modal_dialog.end_modal(ID_CANCEL));

    dialog
        .dialog_deck_properties_editor
        .set_affirmative_id(save_id);
    dialog
        .dialog_deck_properties_editor
        .set_escape_id(cancel_id);
    dialog.text_deck_properties_title.set_focus();

    let result = dialog.dialog_deck_properties_editor.show_modal();
    let saved_result = if is_affirmative_dialog_result(result, save_id) {
        let mut updated_metadata = metadata.clone();
        let prompt_voice_selection = metadata_value_for_voice_combo(
            dialog.text_deck_properties_prompt_voice,
            &voice_choices,
            &dialog.text_deck_properties_prompt_voice_engine.get_value(),
        );
        let response_voice_selection = metadata_value_for_voice_combo(
            dialog.text_deck_properties_response_voice,
            &voice_choices,
            &dialog
                .text_deck_properties_response_voice_engine
                .get_value(),
        );
        apply_deck_metadata_field(
            &mut updated_metadata,
            "title",
            &dialog.text_deck_properties_title.get_value(),
        );
        apply_deck_metadata_field(
            &mut updated_metadata,
            "author",
            &dialog.text_deck_properties_author.get_value(),
        );
        apply_deck_metadata_field(
            &mut updated_metadata,
            "description",
            &dialog.text_deck_properties_description.get_value(),
        );
        apply_deck_metadata_field(
            &mut updated_metadata,
            "url",
            &dialog.text_deck_properties_url.get_value(),
        );
        updated_metadata.set_deck_visibility(visibility_from_selection(
            dialog.choice_deck_properties_visibility,
        ));
        apply_deck_metadata_field(
            &mut updated_metadata,
            "prompt_voice",
            &prompt_voice_selection.voice,
        );
        apply_deck_metadata_field(
            &mut updated_metadata,
            "prompt_voice_engine",
            prompt_voice_selection.engine.as_deref().unwrap_or(""),
        );
        apply_deck_metadata_field(
            &mut updated_metadata,
            "response_voice",
            &response_voice_selection.voice,
        );
        apply_deck_metadata_field(
            &mut updated_metadata,
            "response_voice_engine",
            response_voice_selection.engine.as_deref().unwrap_or(""),
        );
        updated_metadata.set_audio_generation_disabled(
            dialog.checkbox_deck_properties_manual_audio.is_checked(),
        );
        let replaced_sync_deck_uuid = independent_copy_uuid
            .borrow()
            .as_ref()
            .and_then(|new_uuid| apply_independent_deck_copy(&mut updated_metadata, new_uuid));
        updated_metadata.ensure_v2_header();
        Some(DeckPropertiesEditorResult {
            metadata: updated_metadata,
            replaced_sync_deck_uuid,
        })
    } else {
        None
    };
    dialog.dialog_deck_properties_editor.destroy();
    saved_result
}

fn apply_independent_deck_copy(
    metadata: &mut DeckMetadata,
    new_uuid: &SyncUuid,
) -> Option<SyncUuid> {
    let replaced_sync_deck_uuid = metadata
        .deck_uuid()
        .filter(|previous_uuid| previous_uuid != new_uuid);
    metadata.set_deck_uuid(new_uuid);
    metadata.set_deck_visibility(DeckVisibility::Private);
    replaced_sync_deck_uuid
}

fn deck_metadata_field_is_single_line(value: &str) -> bool {
    !value.contains('\n') && !value.contains('\r')
}

fn populate_visibility_choice(choice: Choice, localizer: Localizer, visibility: DeckVisibility) {
    choice.clear();
    for label in [
        localizer.deck_visibility_private_choice(),
        localizer.deck_visibility_unlisted_choice(),
        localizer.deck_visibility_public_choice(),
    ] {
        choice.append(label);
    }
    choice.set_selection(visibility_selection_index(visibility));
}

fn visibility_selection_index(visibility: DeckVisibility) -> u32 {
    match visibility {
        DeckVisibility::Private => 0,
        DeckVisibility::Unlisted => 1,
        DeckVisibility::Public => 2,
    }
}

fn visibility_from_selection(choice: Choice) -> DeckVisibility {
    match choice.get_selection() {
        Some(1) => DeckVisibility::Unlisted,
        Some(2) => DeckVisibility::Public,
        _ => DeckVisibility::Private,
    }
}

fn apply_deck_metadata_field(metadata: &mut DeckMetadata, key: &str, value: &str) {
    let value = value.trim();
    if value.is_empty() {
        metadata.remove(key);
    } else {
        metadata.set(key, value);
    }
}

fn populate_engine_combo(combo: ComboBox) {
    let current_value = combo.get_value();
    combo.clear();
    combo.append(VOICE_ENGINE_ONECORE);
    combo.append(VOICE_ENGINE_SAPI);
    combo.append(VOICE_ENGINE_BOTH);
    combo.set_value(&current_value);
}

fn set_voice_combo_choices(
    combo: ComboBox,
    current_value: &str,
    engine_value: &str,
    choices_by_engine: &BTreeMap<String, Vec<VoiceChoice>>,
) {
    combo.clear();
    let choices = voice_choices_for_engine(engine_value, choices_by_engine);
    for choice in &choices {
        combo.append(&choice.label);
    }

    if let Some((index, choice)) = matching_voice_choice(current_value, &choices) {
        combo.set_value(&choice.label);
        combo.set_selection(index as u32);
    } else {
        combo.set_value(current_value);
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct VoiceMetadataSelection {
    voice: String,
    engine: Option<String>,
}

fn metadata_value_for_voice_combo(
    combo: ComboBox,
    choices_by_engine: &BTreeMap<String, Vec<VoiceChoice>>,
    engine_value: &str,
) -> VoiceMetadataSelection {
    let value = combo.get_value();
    metadata_value_for_voice_value(
        &value,
        combo.get_selection(),
        choices_by_engine,
        engine_value,
    )
}

fn metadata_value_for_voice_value(
    value: &str,
    selection: Option<u32>,
    choices_by_engine: &BTreeMap<String, Vec<VoiceChoice>>,
    engine_value: &str,
) -> VoiceMetadataSelection {
    let choices = voice_choices_for_engine(engine_value, choices_by_engine);
    if let Some(index) = selection.and_then(|index| usize::try_from(index).ok())
        && let Some(choice) = choices.get(index)
        && value == choice.label
    {
        return VoiceMetadataSelection {
            voice: choice.key.clone(),
            engine: Some(choice.engine.clone()),
        };
    }

    if let Some((_, choice)) = matching_voice_choice(value, &choices) {
        return VoiceMetadataSelection {
            voice: choice.key.clone(),
            engine: Some(choice.engine.clone()),
        };
    }

    VoiceMetadataSelection {
        voice: value.to_string(),
        engine: concrete_voice_engine(engine_value).map(str::to_string),
    }
}

fn voice_selection_requires_engine(
    combo: ComboBox,
    choices_by_engine: &BTreeMap<String, Vec<VoiceChoice>>,
    engine_value: &str,
) -> bool {
    let selection = metadata_value_for_voice_combo(combo, choices_by_engine, engine_value);
    !selection.voice.trim().is_empty() && selection.engine.is_none()
}

fn voice_choices_for_engine(
    engine_value: &str,
    choices_by_engine: &BTreeMap<String, Vec<VoiceChoice>>,
) -> Vec<VoiceChoice> {
    match normalized_voice_list_engine(engine_value) {
        VOICE_ENGINE_BOTH => [VOICE_ENGINE_ONECORE, VOICE_ENGINE_SAPI]
            .into_iter()
            .filter_map(|engine| choices_by_engine.get(engine))
            .flat_map(|choices| choices.iter().cloned())
            .collect(),
        engine => choices_by_engine.get(engine).cloned().unwrap_or_default(),
    }
}

fn matching_voice_choice<'a>(
    value: &str,
    choices: &'a [VoiceChoice],
) -> Option<(usize, &'a VoiceChoice)> {
    choices
        .iter()
        .enumerate()
        .find(|(_, choice)| choice.key == value || choice.label == value)
}

fn normalized_voice_list_engine(value: &str) -> &str {
    match value.trim().to_ascii_lowercase().as_str() {
        VOICE_ENGINE_SAPI => VOICE_ENGINE_SAPI,
        VOICE_ENGINE_BOTH => VOICE_ENGINE_BOTH,
        _ => VOICE_ENGINE_ONECORE,
    }
}

fn concrete_voice_engine(value: &str) -> Option<&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,
    }
}

fn show_text_report_dialog(parent: Frame, title: &str, status: &str, close: &str, text: &str) {
    let dialog = CardDetailsUi::new(Some(&parent), false);
    let close_id = CardDetailsUi::xrc_id("button_card_details_close");

    dialog.dialog_card_details.set_label(title);
    dialog.label_card_details_status.set_label(status);
    dialog.button_card_details_close.set_label(close);
    dialog.text_card_details.set_value(text);
    dialog.text_card_details.set_insertion_point(0);
    apply_text_report_visual_style(
        dialog.label_card_details_status,
        dialog.text_card_details,
        dialog.button_card_details_save_report,
        dialog.button_card_details_close,
    );
    dialog.button_card_details_save_report.show(false);
    dialog.button_card_details_close.on_click({
        let dialog = dialog.dialog_card_details;
        move |_| dialog.end_modal(ID_OK)
    });
    dialog.text_card_details.on_key_down({
        let dialog = dialog.dialog_card_details;
        move |event| {
            if let WindowEventData::Keyboard(key_event) = event {
                if is_dialog_accept_key(key_event.get_unicode_key(), key_event.get_key_code()) {
                    dialog.end_modal(ID_OK);
                    key_event.event.skip(false);
                } else {
                    key_event.event.skip(true);
                }
            }
        }
    });
    dialog.dialog_card_details.set_affirmative_id(close_id);
    dialog.dialog_card_details.set_escape_id(close_id);
    dialog.button_card_details_close.set_default();
    dialog.text_card_details.set_focus();
    dialog.panel_card_details_root.layout();

    let _ = dialog.dialog_card_details.show_modal();
    dialog.dialog_card_details.destroy();
}

pub(crate) fn show_about_dialog(parent: Frame, localizer: Localizer) {
    show_info_dialog_with_parent(
        &parent,
        localizer.about_caption(),
        &localizer.about_message(),
    );
}

pub(crate) fn show_cycle_stats_dialog(
    parent: Frame,
    localizer: Localizer,
    cycle_summary: ReviewCycleSummary,
    deck_path: &Path,
    review_db_path: &Path,
) -> bool {
    let dialog = CycleStatsUi::new(Some(&parent), false);
    let summary = cycle_summary_text_with_warning(localizer, &cycle_summary);
    let continue_id = CycleStatsUi::xrc_id("button_continue");

    dialog
        .dialog_stats
        .set_label(localizer.stats_dialog_title());
    dialog
        .label_stats_status
        .set_label(localizer.stats_dialog_status());
    dialog
        .button_continue
        .set_label(localizer.continue_button());
    dialog
        .button_save_report
        .set_label(localizer.save_report_button());
    dialog.text_stats_summary.set_value(&summary);
    dialog.text_stats_summary.set_insertion_point(0);
    dialog.button_save_report.on_click({
        let status = dialog.label_stats_status;
        let cycle_summary = cycle_summary.clone();
        let deck_path = deck_path.to_path_buf();
        let review_db_path = review_db_path.to_path_buf();
        let summary = summary.clone();
        move |_| {
            let report = review_report_file(
                localizer,
                &cycle_summary,
                &deck_path,
                &review_db_path,
                &summary,
            );
            match save_review_report(&report) {
                Ok(path) => {
                    status.set_label(
                        &localizer.saved_review_report_status(&path.display().to_string()),
                    );
                }
                Err(error) => {
                    status.set_label(&localizer.could_not_save_review_report_status(
                        &report.path().display().to_string(),
                        &error.to_string(),
                    ));
                }
            }
        }
    });
    dialog.button_continue.on_click({
        let dialog = dialog.dialog_stats;
        move |_| dialog.end_modal(ID_OK)
    });
    dialog.text_stats_summary.on_key_down({
        let dialog = dialog.dialog_stats;
        move |event| {
            if let WindowEventData::Keyboard(key_event) = event {
                if is_dialog_accept_key(key_event.get_unicode_key(), key_event.get_key_code()) {
                    dialog.end_modal(ID_OK);
                    key_event.event.skip(false);
                } else {
                    key_event.event.skip(true);
                }
            }
        }
    });
    dialog.dialog_stats.set_affirmative_id(continue_id);
    dialog.dialog_stats.set_escape_id(continue_id);
    dialog.button_continue.set_default();
    dialog.text_stats_summary.set_focus();
    apply_text_report_visual_style(
        dialog.label_stats_status,
        dialog.text_stats_summary,
        dialog.button_save_report,
        dialog.button_continue,
    );
    dialog.panel_stats_root.layout();

    let result = dialog.dialog_stats.show_modal();
    dialog.dialog_stats.destroy();
    is_affirmative_dialog_result(result, continue_id)
}

pub(crate) fn cycle_summary_text_with_warning(
    localizer: Localizer,
    cycle_summary: &ReviewCycleSummary,
) -> String {
    let summary = cycle_summary.stats.summary_text(
        localizer,
        cycle_summary.total_cards,
        cycle_summary.elapsed_seconds,
        cycle_summary.ended_early,
        cycle_summary.was_scheduled,
    );
    match &cycle_summary.warning {
        Some(warning) => format!("{warning}\n\n{summary}"),
        None => summary,
    }
}

fn apply_text_report_visual_style(
    status: StaticText,
    text: TextCtrl,
    save_button: Button,
    close_button: Button,
) {
    increase_font_to_at_least(status, 11);
    increase_font_to_at_least(text, 12);
    for button in [save_button, close_button] {
        increase_font_to_at_least(button, 11);
        button.set_min_size(Size::new(120, 40));
    }
}

fn increase_font_to_at_least<W>(widget: W, minimum_point_size: i32)
where
    W: WxWidget,
{
    let Some(mut font) = widget.get_font() else {
        return;
    };
    let point_size = font.get_point_size();
    if point_size > 0 && point_size < minimum_point_size {
        font.set_point_size(minimum_point_size);
        widget.set_font(&font);
    }
}

fn bind_close_on_text_accept(text: TextCtrl, dialog: Dialog) {
    text.on_key_down(move |event| {
        if let WindowEventData::Keyboard(key_event) = event {
            if is_dialog_accept_key(key_event.get_unicode_key(), key_event.get_key_code()) {
                dialog.end_modal(ID_OK);
                key_event.event.skip(false);
            } else {
                key_event.event.skip(true);
            }
        }
    });
}

fn apply_learning_dashboard_visual_style(
    status: StaticText,
    section_controls: &[(Panel, StaticText, TextCtrl)],
    save_button: Button,
    copy_button: Button,
    close_button: Button,
) {
    increase_font_to_at_least(status, 11);
    for (_, label, text) in section_controls {
        increase_font_to_at_least(*label, 11);
        increase_font_to_at_least(*text, 12);
    }
    for button in [save_button, copy_button, close_button] {
        increase_font_to_at_least(button, 11);
        button.set_min_size(Size::new(140, 40));
    }
}

pub(crate) fn copy_text_to_clipboard(text: &str) -> bool {
    let clipboard = Clipboard::get();
    let copied = clipboard.set_text(text);
    if copied {
        let _ = clipboard.flush();
    }
    copied
}

pub(crate) fn show_open_deck_dialog(
    parent: Frame,
    localizer: Localizer,
    default_dir: &Path,
    default_file: &str,
) -> Option<String> {
    let dialog = FileDialog::builder(&parent)
        .with_message(localizer.open_deck_dialog_message())
        .with_default_dir(default_dir.to_str().unwrap_or(""))
        .with_default_file(default_file)
        .with_wildcard(localizer.open_deck_wildcard())
        // Do not add FileMustExist. Opening a missing path is how the user
        // creates a new deck through this flow.
        .with_style(FileDialogStyle::Open)
        .build();

    let selected_path = if dialog.show_modal() == ID_OK {
        dialog.get_path()
    } else {
        None
    };
    drop(dialog);
    selected_path
}

pub(crate) fn show_import_anki_deck_dialog(
    parent: Frame,
    localizer: Localizer,
    default_dir: &Path,
) -> Option<String> {
    let dialog = FileDialog::builder(&parent)
        .with_message(localizer.import_anki_deck_dialog_message())
        .with_default_dir(default_dir.to_str().unwrap_or(""))
        .with_wildcard(localizer.anki_deck_wildcard())
        .with_style(FileDialogStyle::Open | FileDialogStyle::FileMustExist)
        .build();

    let selected_path = if dialog.show_modal() == ID_OK {
        dialog.get_path()
    } else {
        None
    };
    drop(dialog);
    selected_path
}

pub(crate) fn show_save_imported_deck_dialog(
    parent: Frame,
    localizer: Localizer,
    default_dir: &Path,
    default_file: &str,
) -> Option<String> {
    let dialog = FileDialog::builder(&parent)
        .with_message(localizer.save_imported_deck_dialog_message())
        .with_default_dir(default_dir.to_str().unwrap_or(""))
        .with_default_file(default_file)
        .with_wildcard(localizer.open_deck_wildcard())
        .with_style(FileDialogStyle::Save | FileDialogStyle::OverwritePrompt)
        .build();

    let selected_path = if dialog.show_modal() == ID_OK {
        dialog.get_path()
    } else {
        None
    };
    drop(dialog);
    selected_path
}

pub(crate) fn show_export_deck_bundle_dialog(
    parent: Frame,
    localizer: Localizer,
    default_dir: &Path,
    default_file: &str,
) -> Option<String> {
    show_deck_bundle_save_dialog(
        parent,
        localizer,
        default_dir,
        default_file,
        localizer.export_deck_bundle_dialog_message(),
    )
}

pub(crate) fn show_export_deck_bundle_with_study_history_dialog(
    parent: Frame,
    localizer: Localizer,
    default_dir: &Path,
    default_file: &str,
) -> Option<String> {
    show_deck_bundle_save_dialog(
        parent,
        localizer,
        default_dir,
        default_file,
        localizer.export_deck_bundle_with_study_history_dialog_message(),
    )
}

pub(crate) fn show_restore_deck_bundle_dialog(
    parent: Frame,
    localizer: Localizer,
    default_dir: &Path,
) -> Option<String> {
    let dialog = FileDialog::builder(&parent)
        .with_message(localizer.restore_deck_bundle_dialog_message())
        .with_default_dir(default_dir.to_str().unwrap_or(""))
        .with_wildcard(localizer.deck_bundle_wildcard())
        .with_style(FileDialogStyle::Open)
        .build();

    let selected_path = if dialog.show_modal() == ID_OK {
        dialog.get_path()
    } else {
        None
    };
    drop(dialog);
    selected_path
}

fn show_deck_bundle_save_dialog(
    parent: Frame,
    localizer: Localizer,
    default_dir: &Path,
    default_file: &str,
    message: &str,
) -> Option<String> {
    let dialog = FileDialog::builder(&parent)
        .with_message(message)
        .with_default_dir(default_dir.to_str().unwrap_or(""))
        .with_default_file(default_file)
        .with_wildcard(localizer.deck_bundle_wildcard())
        .with_style(FileDialogStyle::Save | FileDialogStyle::OverwritePrompt)
        .build();

    let selected_path = if dialog.show_modal() == ID_OK {
        dialog.get_path()
    } else {
        None
    };
    drop(dialog);
    selected_path
}

pub(crate) fn show_card_editor_dialog(
    parent: Frame,
    localizer: Localizer,
    existing_datum: Option<&Datum>,
) -> Option<Datum> {
    let dialog = CardEditorUi::new(Some(&parent), false);
    let save_id = CardEditorUi::xrc_id("button_card_save");
    let status_label = localizer.card_editor_status(existing_datum.is_some());

    dialog
        .dialog_card_editor
        .set_label(localizer.card_editor_title());
    dialog.label_card_status.set_label(status_label);
    dialog
        .label_card_prompt_heading
        .set_label(localizer.card_editor_prompt_heading());
    dialog
        .label_card_response_heading
        .set_label(localizer.card_editor_response_heading());
    let non_reversible_label = localizer.card_editor_non_reversible_checkbox();
    dialog
        .checkbox_card_non_reversible
        .set_label(non_reversible_label);
    dialog
        .checkbox_card_non_reversible
        .set_name(non_reversible_label);
    dialog.checkbox_card_non_reversible.set_value(false);
    if existing_datum.is_some() {
        dialog.checkbox_card_non_reversible.show(false);
        dialog.panel_card_editor_root.layout();
    }
    dialog
        .button_card_cancel
        .set_label(localizer.card_editor_cancel_button());
    dialog
        .button_card_save
        .set_label(localizer.card_editor_save_button());
    if let Some(existing_datum) = existing_datum {
        dialog.text_card_prompt.set_value(existing_datum.prompt());
        dialog
            .text_card_response
            .set_value(existing_datum.response());
    }
    dialog.text_card_prompt.set_insertion_point(0);
    dialog.text_card_response.set_insertion_point(0);

    let modal_dialog = dialog.dialog_card_editor;
    let status = dialog.label_card_status;
    let prompt = dialog.text_card_prompt;
    let response = dialog.text_card_response;
    dialog.button_card_save.on_click(move |_| {
        let prompt_value = prompt.get_value();
        let response_value = response.get_value();
        match Datum::new(prompt_value, response_value) {
            Ok(_) => modal_dialog.end_modal(ID_OK),
            Err(DatumValidationError::EmptyPrompt) => {
                status.set_label(localizer.card_editor_empty_prompt_status());
                prompt.set_focus();
            }
            Err(DatumValidationError::EmptyResponse) => {
                status.set_label(localizer.card_editor_empty_response_status());
                response.set_focus();
            }
        }
    });

    let modal_dialog = dialog.dialog_card_editor;
    dialog
        .button_card_cancel
        .on_click(move |_| modal_dialog.end_modal(ID_CANCEL));

    dialog.dialog_card_editor.set_affirmative_id(save_id);
    dialog
        .dialog_card_editor
        .set_escape_id(CardEditorUi::xrc_id("button_card_cancel"));
    dialog.text_card_prompt.set_focus();

    let result = dialog.dialog_card_editor.show_modal();
    let saved_datum = if is_affirmative_dialog_result(result, save_id) {
        Datum::new(
            dialog.text_card_prompt.get_value(),
            dialog.text_card_response.get_value(),
        )
        .map(|datum| {
            if existing_datum.is_none() {
                datum.with_reversal_prohibited(dialog.checkbox_card_non_reversible.is_checked())
            } else {
                datum
            }
        })
        .ok()
    } else {
        None
    };
    dialog.dialog_card_editor.destroy();
    saved_datum
}

pub(crate) fn confirm_remove_selected_card_dialog(
    parent: Frame,
    localizer: Localizer,
    prompt_preview: &str,
    confirmation: RemoveCardConfirmation,
) -> bool {
    let message = match confirmation {
        RemoveCardConfirmation::Normal => localizer.remove_selected_card_message(prompt_preview),
        RemoveCardConfirmation::ReversibleParent => {
            localizer.remove_reversible_parent_card_message(prompt_preview)
        }
        RemoveCardConfirmation::Reverse => localizer.remove_reverse_card_message(prompt_preview),
    };
    show_yes_no_dialog_with_parent(&parent, localizer.remove_card_caption(), &message)
}

pub(crate) fn confirm_resolve_duplicate_prompts_dialog(
    parent: Frame,
    localizer: Localizer,
    prompt_group_count: usize,
    duplicate_card_count: usize,
) -> bool {
    show_yes_no_dialog_with_parent(
        &parent,
        localizer.resolve_duplicate_prompts_caption(),
        &localizer.resolve_duplicate_prompts_message(prompt_group_count, duplicate_card_count),
    )
}

pub(crate) fn confirm_make_eligible_cards_reversible_dialog(
    parent: Frame,
    localizer: Localizer,
    card_count: usize,
    overwrites_review_db_backup: bool,
) -> bool {
    show_yes_no_dialog_with_parent(
        &parent,
        localizer.make_eligible_cards_reversible_caption(),
        &localizer.make_eligible_cards_reversible_message(card_count, overwrites_review_db_backup),
    )
}

pub(crate) fn confirm_undo_latest_bulk_reversal_dialog(
    parent: Frame,
    localizer: Localizer,
    card_count: usize,
    overwrites_review_db_backup: bool,
) -> bool {
    show_yes_no_dialog_with_parent(
        &parent,
        localizer.undo_last_bulk_reversal_caption(),
        &localizer.undo_last_bulk_reversal_message(card_count, overwrites_review_db_backup),
    )
}

pub(crate) fn confirm_scrub_review_database_dialog(
    parent: Frame,
    localizer: Localizer,
    backups_disabled: bool,
    overwrites_review_db_backup: bool,
) -> bool {
    show_yes_no_dialog_with_parent(
        &parent,
        localizer.scrub_review_database_caption(),
        &localizer.scrub_review_database_message(backups_disabled, overwrites_review_db_backup),
    )
}

pub(crate) fn confirm_reclaim_database_space_dialog(
    parent: Frame,
    localizer: Localizer,
    backups_disabled: bool,
) -> bool {
    show_yes_no_dialog_with_parent(
        &parent,
        localizer.reclaim_database_space_caption(),
        &localizer.reclaim_database_space_message(backups_disabled),
    )
}

pub(crate) fn confirm_wipe_backup_directory_dialog(
    parent: Frame,
    localizer: Localizer,
    backup_dir: &str,
) -> bool {
    show_yes_no_dialog_with_parent(
        &parent,
        localizer.wipe_backup_directory_caption(),
        &localizer.wipe_backup_directory_message(backup_dir),
    )
}

pub(crate) fn confirm_create_deck_dialog(
    parent: Frame,
    localizer: Localizer,
    deck_path: &str,
) -> bool {
    show_yes_no_dialog_with_parent(
        &parent,
        localizer.create_deck_caption(),
        &localizer.create_deck_message(deck_path),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::review_session::{ReviewCycleStats, ReviewCycleSummary};

    #[test]
    fn stats_continue_button_is_configured_as_default() {
        let source = include_str!("review_dialogs.rs");

        assert!(source.contains("dialog.button_continue.set_default();"));
    }

    #[test]
    fn stats_summary_enter_key_is_handled_as_continue() {
        let source = include_str!("review_dialogs.rs");

        assert!(source.contains("dialog.text_stats_summary.on_key_down("));
        assert!(source.contains("is_dialog_accept_key("));
    }

    #[test]
    fn stats_summary_includes_persistence_warning() {
        let summary = ReviewCycleSummary {
            stats: ReviewCycleStats::default(),
            total_cards: 1,
            initial_cards: 1,
            started_at_unix_seconds: 0,
            finished_at_unix_seconds: 0,
            elapsed_seconds: 0,
            ended_early: false,
            was_scheduled: true,
            items: Vec::new(),
            warning: Some("Warning: could not record the study session summary.".to_string()),
        };

        let text = cycle_summary_text_with_warning(Localizer::default(), &summary);

        assert!(text.starts_with("Warning: could not record the study session summary."));
        assert!(text.contains("Completed one scheduled study session."));
    }

    #[test]
    fn editor_checkbox_names_are_user_facing() {
        let source = include_str!("review_dialogs.rs")
            .split("#[cfg(test)]")
            .next()
            .expect("runtime source before tests");

        assert!(source.contains(
            "checkbox_deck_properties_manual_audio\n        .set_name(manual_audio_label);"
        ));
        assert!(
            source
                .contains("checkbox_card_non_reversible\n        .set_name(non_reversible_label);")
        );
    }

    #[test]
    fn deck_metadata_editor_does_not_include_redundant_audio_generation_label() {
        let source = include_str!("review_dialogs.rs")
            .split("#[cfg(test)]")
            .next()
            .expect("runtime source before tests");
        let xrc = include_str!("../ui/deck_properties_editor_dialog.xrc");

        assert!(!source.contains("label_deck_properties_audio_generation\n        .set_label"));
        assert!(!xrc.contains("label_deck_properties_audio_generation"));
        assert!(xrc.contains(
            "<object class=\"wxCheckBox\" name=\"checkbox_deck_properties_manual_audio\">\n            <label>Manual audio deck (protect against automatic audio generation)</label>"
        ));
    }

    #[test]
    fn deck_metadata_editor_keeps_sync_auth_out_of_deck_metadata() {
        let xrc = include_str!("../ui/deck_properties_editor_dialog.xrc");

        assert!(xrc.contains("name=\"choice_deck_properties_visibility\""));
        assert!(!xrc.contains("text_deck_properties_sync_owner"));
        assert!(!xrc.contains("text_deck_properties_sync_directory"));
        assert!(!xrc.contains("text_deck_properties_device_name"));
    }

    #[test]
    fn deck_metadata_editor_has_independent_copy_action() {
        let source = include_str!("review_dialogs.rs")
            .split("#[cfg(test)]")
            .next()
            .expect("runtime source before tests");
        let xrc = include_str!("../ui/deck_properties_editor_dialog.xrc");

        assert!(xrc.contains("name=\"button_deck_properties_make_independent_copy\""));
        assert!(source.contains("deck_properties_make_independent_copy_message()"));
        assert!(source.contains("SyncUuid::new_v4()"));
        assert!(source.contains("apply_independent_deck_copy(&mut updated_metadata, new_uuid)"));
    }

    #[test]
    fn sync_remote_decks_dialog_uses_selectable_list_and_download_action() {
        let source = include_str!("review_dialogs.rs")
            .split("#[cfg(test)]")
            .next()
            .expect("runtime source before tests");
        let xrc = include_str!("../ui/sync_remote_decks_dialog.xrc");

        assert!(xrc.contains("name=\"list_sync_remote_decks\""));
        assert!(xrc.contains("name=\"button_sync_remote_decks_details\""));
        assert!(xrc.contains("name=\"button_sync_remote_decks_copy_id\""));
        assert!(xrc.contains("name=\"button_sync_remote_decks_find_by_id\""));
        assert!(xrc.contains("name=\"button_sync_remote_decks_download\""));
        assert!(xrc.contains("name=\"button_sync_remote_decks_delete\""));
        assert!(source.contains(".list_sync_remote_decks\n        .set_name"));
        assert!(
            source
                .contains("button_sync_remote_decks_delete\n        .enable(selected_can_delete)")
        );
        assert!(source.contains("SyncRemoteDecksDialogResult::CopyId(selection)"));
        assert!(source.contains("SyncRemoteDecksDialogResult::FindById"));
        assert!(source.contains("SyncRemoteDecksDialogResult::Download(selection)"));
        assert!(source.contains("SyncRemoteDecksDialogResult::Delete(selection)"));
    }

    #[test]
    fn independent_deck_copy_replaces_uuid_and_resets_visibility() {
        let mut metadata = DeckMetadata::v2();
        let previous_uuid: SyncUuid = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        let new_uuid: SyncUuid = "650e8400-e29b-41d4-a716-446655440000".parse().unwrap();
        metadata.set_deck_uuid(&previous_uuid);
        metadata.set_deck_visibility(DeckVisibility::Public);
        metadata.set("title", "Indonesian");

        let replaced = apply_independent_deck_copy(&mut metadata, &new_uuid);

        assert_eq!(replaced, Some(previous_uuid));
        assert_eq!(metadata.deck_uuid(), Some(new_uuid));
        assert_eq!(metadata.deck_visibility(), DeckVisibility::Private);
        assert_eq!(metadata.get("title"), Some("Indonesian"));
    }

    #[test]
    fn independent_deck_copy_assigns_uuid_to_unsynced_deck() {
        let mut metadata = DeckMetadata::v2();
        let new_uuid: SyncUuid = "650e8400-e29b-41d4-a716-446655440000".parse().unwrap();

        let replaced = apply_independent_deck_copy(&mut metadata, &new_uuid);

        assert_eq!(replaced, None);
        assert_eq!(metadata.deck_uuid(), Some(new_uuid));
    }

    #[test]
    fn voice_choice_matching_accepts_label_or_storage_key() {
        let choices = vec![VoiceChoice {
            engine: VOICE_ENGINE_ONECORE.to_string(),
            label: "Helena Spanish [onecore]".to_string(),
            key: "onecore:helena".to_string(),
        }];

        assert_eq!(
            matching_voice_choice("Helena Spanish [onecore]", &choices)
                .map(|(_, choice)| &choice.key),
            Some(&"onecore:helena".to_string())
        );
        assert_eq!(
            matching_voice_choice("onecore:helena", &choices).map(|(_, choice)| &choice.label),
            Some(&"Helena Spanish [onecore]".to_string())
        );
        assert!(matching_voice_choice("manual filter", &choices).is_none());
    }

    #[test]
    fn voice_selection_from_both_engines_stores_the_chosen_engine() {
        let mut choices = BTreeMap::new();
        choices.insert(
            VOICE_ENGINE_ONECORE.to_string(),
            vec![VoiceChoice {
                engine: VOICE_ENGINE_ONECORE.to_string(),
                label: "Helena [es-ES]".to_string(),
                key: "onecore-id".to_string(),
            }],
        );
        choices.insert(
            VOICE_ENGINE_SAPI.to_string(),
            vec![VoiceChoice {
                engine: VOICE_ENGINE_SAPI.to_string(),
                label: "Helena [es-ES]".to_string(),
                key: "sapi-name".to_string(),
            }],
        );

        assert_eq!(
            metadata_value_for_voice_value("Helena [es-ES]", Some(1), &choices, VOICE_ENGINE_BOTH),
            VoiceMetadataSelection {
                voice: "sapi-name".to_string(),
                engine: Some(VOICE_ENGINE_SAPI.to_string())
            }
        );
    }

    #[test]
    fn manually_edited_voice_requires_concrete_engine() {
        let choices = BTreeMap::new();

        assert_eq!(
            metadata_value_for_voice_value("custom voice", None, &choices, VOICE_ENGINE_BOTH),
            VoiceMetadataSelection {
                voice: "custom voice".to_string(),
                engine: None
            }
        );
        assert_eq!(
            metadata_value_for_voice_value("custom voice", None, &choices, VOICE_ENGINE_SAPI),
            VoiceMetadataSelection {
                voice: "custom voice".to_string(),
                engine: Some(VOICE_ENGINE_SAPI.to_string())
            }
        );
    }
}
