use crate::app_config::{ExamPostAnswerAudioPolicy, ReviewAudioPolicy};
use crate::dialog_support::{
    end_modal_once, is_affirmative_dialog_result, is_alt_n_key, is_dialog_accept_key,
};
use crate::exam_mode::{
    ExamAnswer, ExamQuestion, ExamSizeMode, ExamSizeModeSelection, ExamSizeRequest,
    ExamStudyDirection, ExamStudyDirectionSelection, TimedExamProgress,
};
use crate::keyboard::is_toggle_review_audio_key;
use crate::localization::Localizer;
use crate::notifications::{
    play_exam_finished_notification, play_exam_progress_notification,
    play_exam_timeout_notification,
};
use crate::review_audio::{ReviewAudioFile, ReviewAudioPlayer};
use crate::review_report::{exam_report_file, save_review_report};
use crate::time_format::format_relative_duration_complete;
use crate::{ExamGradeUi, ExamQuestionUi, ExamSetupUi, ExamSummaryUi, ExamTimeoutUi};
use std::cell::Cell;
use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;
use std::time::Instant;
use wxdragon::id::{ID_CANCEL, ID_NO, ID_OK, ID_YES};
use wxdragon::prelude::*;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ExamQuestionOutcome {
    Submitted(ExamAnswer),
    SubmittedAndEnded(ExamAnswer),
    TimedOut(Option<ExamAnswer>),
    Ended,
}

#[derive(Clone)]
pub(crate) struct TimedExamQuestionContext {
    started_at: Instant,
    progress: Rc<RefCell<TimedExamProgress>>,
    status_bar: StatusBar,
}

impl TimedExamQuestionContext {
    pub(crate) fn new(
        started_at: Instant,
        progress: Rc<RefCell<TimedExamProgress>>,
        status_bar: StatusBar,
    ) -> Self {
        Self {
            started_at,
            progress,
            status_bar,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExamInexactReviewOutcome {
    Next,
    Summary,
}

pub(crate) fn show_exam_setup_dialog(
    parent: Frame,
    localizer: Localizer,
    active_card_count: usize,
    show_study_direction: bool,
) -> Option<ExamSizeRequest> {
    let dialog = ExamSetupUi::new(Some(&parent), false);
    let start_id = ExamSetupUi::xrc_id("button_exam_setup_start");
    let cancel_id = ExamSetupUi::xrc_id("button_exam_setup_cancel");

    dialog
        .dialog_exam_setup
        .set_label(localizer.exam_setup_dialog_title());
    dialog
        .label_exam_setup_status
        .set_label(&localizer.exam_setup_status(active_card_count));
    dialog
        .label_exam_size_mode
        .set_label(localizer.exam_size_mode_label());
    dialog
        .label_exam_study_direction
        .set_label(localizer.quiz_study_direction_label());
    dialog
        .label_exam_size_value
        .set_label(localizer.exam_size_value_label());
    let exact_checkbox_label = localizer.exam_exact_checkbox_label();
    dialog.checkbox_exam_exact.set_label(exact_checkbox_label);
    dialog.checkbox_exam_exact.set_name(exact_checkbox_label);
    let timed_checkbox_label = localizer.exam_timed_checkbox_label();
    dialog.checkbox_exam_timed.set_label(timed_checkbox_label);
    dialog.checkbox_exam_timed.set_name(timed_checkbox_label);
    dialog
        .label_exam_time_limit
        .set_label(localizer.exam_time_limit_label());
    dialog.text_exam_time_limit.enable(false);
    dialog
        .button_exam_setup_cancel
        .set_label(localizer.options_cancel_button());
    dialog
        .button_exam_setup_start
        .set_label(localizer.exam_start_button());
    dialog.choice_exam_size_mode.clear();
    dialog
        .choice_exam_size_mode
        .append(localizer.exam_size_question_count_choice());
    dialog
        .choice_exam_size_mode
        .append(localizer.exam_size_percentage_choice());
    dialog
        .choice_exam_size_mode
        .set_selection(ExamSizeMode::QuestionCount.selection_index().index());
    dialog.choice_exam_study_direction.clear();
    dialog
        .choice_exam_study_direction
        .append(localizer.quiz_study_direction_standard_choice());
    dialog
        .choice_exam_study_direction
        .append(localizer.quiz_study_direction_reverse_choice());
    dialog
        .choice_exam_study_direction
        .append(localizer.quiz_study_direction_both_choice());
    dialog
        .choice_exam_study_direction
        .set_selection(ExamStudyDirection::Both.selection_index().index());
    dialog.label_exam_study_direction.show(show_study_direction);
    dialog
        .choice_exam_study_direction
        .show(show_study_direction);
    dialog
        .text_exam_size_value
        .set_value(&usize::min(20, active_card_count).max(1).to_string());
    dialog.choice_exam_size_mode.on_selection_changed({
        let value = dialog.text_exam_size_value;
        move |_| {
            value.set_value("");
        }
    });
    dialog.checkbox_exam_timed.on_toggled({
        let time_limit = dialog.text_exam_time_limit;
        move |event| {
            let enabled = event.is_checked();
            time_limit.enable(enabled);
            if !enabled {
                time_limit.set_value("");
            }
        }
    });

    let modal_dialog = dialog.dialog_exam_setup;
    dialog
        .button_exam_setup_cancel
        .on_click(move |_| modal_dialog.end_modal(ID_CANCEL));

    let modal_dialog = dialog.dialog_exam_setup;
    dialog
        .button_exam_setup_start
        .on_click(move |_| modal_dialog.end_modal(ID_OK));

    dialog.text_exam_size_value.on_key_down({
        let modal_dialog = dialog.dialog_exam_setup;
        move |event| {
            if let WindowEventData::Keyboard(key_event) = event {
                if is_dialog_accept_key(key_event.get_unicode_key(), key_event.get_key_code()) {
                    modal_dialog.end_modal(ID_OK);
                    key_event.event.skip(false);
                } else {
                    key_event.event.skip(true);
                }
            }
        }
    });
    dialog.text_exam_time_limit.on_key_down({
        let modal_dialog = dialog.dialog_exam_setup;
        move |event| {
            if let WindowEventData::Keyboard(key_event) = event {
                if is_dialog_accept_key(key_event.get_unicode_key(), key_event.get_key_code()) {
                    modal_dialog.end_modal(ID_OK);
                    key_event.event.skip(false);
                } else {
                    key_event.event.skip(true);
                }
            }
        }
    });

    dialog.dialog_exam_setup.set_affirmative_id(start_id);
    dialog.dialog_exam_setup.set_escape_id(cancel_id);
    dialog.button_exam_setup_start.set_default();
    dialog.text_exam_size_value.set_focus();
    apply_exam_setup_visual_style(
        dialog.label_exam_setup_status,
        dialog.choice_exam_size_mode,
        dialog.choice_exam_study_direction,
        dialog.text_exam_size_value,
        dialog.checkbox_exam_exact,
        dialog.checkbox_exam_timed,
        dialog.text_exam_time_limit,
        dialog.button_exam_setup_cancel,
        dialog.button_exam_setup_start,
    );
    dialog.panel_exam_setup_root.layout();

    let outcome = loop {
        let result = dialog.dialog_exam_setup.show_modal();
        if !is_affirmative_dialog_result(result, start_id) {
            break None;
        }

        let mode = ExamSizeMode::from_selection(ExamSizeModeSelection::from_wx_selection(
            dialog.choice_exam_size_mode.get_selection(),
        ));
        let direction = if show_study_direction {
            ExamStudyDirection::from_selection(ExamStudyDirectionSelection::from_wx_selection(
                dialog.choice_exam_study_direction.get_selection(),
            ))
        } else {
            ExamStudyDirection::Both
        };
        let value_text = dialog.text_exam_size_value.get_value();
        let Some(value) = value_text
            .trim()
            .parse::<usize>()
            .ok()
            .filter(|value| *value > 0)
        else {
            dialog
                .label_exam_setup_status
                .set_label(localizer.exam_invalid_size_status());
            dialog.text_exam_size_value.set_focus();
            continue;
        };
        if mode == ExamSizeMode::Percentage && value > 100 {
            dialog
                .label_exam_setup_status
                .set_label(localizer.exam_invalid_percentage_status());
            dialog.text_exam_size_value.set_focus();
            continue;
        }
        let time_limit_minutes = if dialog.checkbox_exam_timed.is_checked() {
            let time_limit_text = dialog.text_exam_time_limit.get_value();
            let Some(time_limit_minutes) = time_limit_text
                .trim()
                .parse::<usize>()
                .ok()
                .filter(|value| *value > 0)
            else {
                dialog
                    .label_exam_setup_status
                    .set_label(localizer.exam_invalid_time_limit_status());
                dialog.text_exam_time_limit.set_focus();
                continue;
            };
            Some(time_limit_minutes)
        } else {
            None
        };

        break Some(ExamSizeRequest::new_with_direction_and_options(
            mode,
            value,
            direction,
            dialog.checkbox_exam_exact.is_checked(),
            time_limit_minutes,
        ));
    };
    dialog.dialog_exam_setup.destroy();
    outcome
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn show_exam_question_dialog(
    parent: Frame,
    localizer: Localizer,
    question: &ExamQuestion,
    current_question: usize,
    total_questions: usize,
    review_audio_policy: ReviewAudioPolicy,
    prompt_audio: Option<ReviewAudioFile>,
    timed_context: Option<TimedExamQuestionContext>,
) -> ExamQuestionOutcome {
    let dialog = ExamQuestionUi::new(Some(&parent), false);
    let submit_id = ExamQuestionUi::xrc_id("button_exam_submit");
    let end_id = ExamQuestionUi::xrc_id("button_exam_end");
    let prompt_has_audio = prompt_audio.is_some() && review_audio_policy != ReviewAudioPolicy::Off;

    dialog
        .dialog_exam_question
        .set_label(localizer.exam_mode_dialog_title());
    let question_status =
        localizer.exam_question_status(current_question, total_questions, question.prompt());
    dialog
        .label_exam_question_status
        .set_label(&question_status);
    dialog
        .label_exam_prompt_heading
        .set_label(if prompt_has_audio {
            localizer.prompt_heading_with_audio()
        } else {
            localizer.prompt_heading()
        });
    dialog
        .button_exam_end
        .set_label(localizer.end_exam_button());
    dialog
        .button_exam_submit
        .set_label(localizer.submit_exam_answer_button());
    dialog.text_exam_prompt.set_value(question.prompt());
    dialog.text_exam_prompt.set_insertion_point(0);
    dialog.text_exam_answer.set_value("");
    dialog.text_exam_answer.set_name(&question_status);
    let audio_controls = configure_exam_audio_button(
        dialog.button_exam_question_audio,
        dialog.label_exam_question_status,
        localizer,
        review_audio_policy,
        prompt_audio,
    );

    let modal_dialog = dialog.dialog_exam_question;
    dialog
        .button_exam_end
        .on_click(move |_| modal_dialog.end_modal(ID_CANCEL));

    let modal_dialog = dialog.dialog_exam_question;
    dialog
        .button_exam_submit
        .on_click(move |_| modal_dialog.end_modal(ID_OK));

    dialog.text_exam_answer.on_key_down({
        let modal_dialog = dialog.dialog_exam_question;
        let audio_controls = audio_controls.clone();
        move |event| {
            if let WindowEventData::Keyboard(key_event) = event {
                if handle_exam_audio_shortcut(
                    key_event.get_unicode_key(),
                    key_event.get_key_code(),
                    key_event.control_down(),
                    &audio_controls,
                    dialog.label_exam_question_status,
                    localizer,
                ) {
                    key_event.event.skip(false);
                } else if is_dialog_accept_key(
                    key_event.get_unicode_key(),
                    key_event.get_key_code(),
                ) {
                    modal_dialog.end_modal(ID_OK);
                    key_event.event.skip(false);
                } else {
                    key_event.event.skip(true);
                }
            }
        }
    });
    if let Some(audio_controls) = audio_controls.clone() {
        bind_exam_audio_shortcut(
            dialog.text_exam_prompt,
            audio_controls.clone(),
            dialog.label_exam_question_status,
            localizer,
        );
        bind_exam_audio_shortcut(
            dialog.button_exam_question_audio,
            audio_controls.clone(),
            dialog.label_exam_question_status,
            localizer,
        );
        bind_exam_audio_shortcut(
            dialog.button_exam_end,
            audio_controls.clone(),
            dialog.label_exam_question_status,
            localizer,
        );
        bind_exam_audio_shortcut(
            dialog.button_exam_submit,
            audio_controls,
            dialog.label_exam_question_status,
            localizer,
        );
    }

    dialog.dialog_exam_question.set_affirmative_id(submit_id);
    dialog.dialog_exam_question.set_escape_id(end_id);
    dialog.button_exam_submit.set_default();
    dialog.text_exam_answer.set_focus();
    apply_exam_question_visual_style(
        dialog.label_exam_question_status,
        dialog.text_exam_prompt,
        dialog.text_exam_answer,
        dialog.button_exam_question_audio,
        dialog.button_exam_end,
        dialog.button_exam_submit,
    );
    dialog.panel_exam_question_root.layout();

    let question_timer = timed_context.map(|context| {
        install_exam_question_timer(dialog.dialog_exam_question, context, localizer)
    });

    let result = dialog.dialog_exam_question.show_modal();
    if let Some(timer) = question_timer.as_ref() {
        timer.deactivate();
    }
    let outcome = exam_question_outcome_from_modal_result(
        result,
        submit_id,
        question,
        dialog.text_exam_answer.get_value(),
    );
    stop_exam_audio(&audio_controls);
    dialog.dialog_exam_question.destroy();
    outcome
}

struct ExamQuestionTimer {
    timer: Timer<Dialog>,
    modal_active: Rc<Cell<bool>>,
}

impl ExamQuestionTimer {
    fn deactivate(&self) {
        self.modal_active.set(false);
        self.timer.stop();
    }
}

fn install_exam_question_timer(
    modal_dialog: Dialog,
    context: TimedExamQuestionContext,
    localizer: Localizer,
) -> ExamQuestionTimer {
    let timer = Timer::new(&modal_dialog);
    let timed_out = Rc::new(Cell::new(false));
    let modal_active = Rc::new(Cell::new(true));
    update_exam_time_remaining_status(&context, localizer);
    let callback_modal_active = Rc::clone(&modal_active);
    timer.on_tick(move |_| {
        let elapsed_seconds = context.started_at.elapsed().as_secs();
        let tick = context.progress.borrow_mut().tick(elapsed_seconds);
        set_exam_time_remaining_status(context.status_bar, localizer, tick.remaining_seconds);
        if tick.play_progress_tone {
            play_exam_progress_notification();
        }
        if should_end_timed_exam_question_dialog(tick.timed_out, &callback_modal_active, &timed_out)
        {
            callback_modal_active.set(false);
            play_exam_timeout_notification();
            modal_dialog.end_modal(ID_YES);
        }
    });
    timer.start(1_000, false);
    ExamQuestionTimer {
        timer,
        modal_active,
    }
}

fn should_end_timed_exam_question_dialog(
    tick_timed_out: bool,
    modal_active: &Cell<bool>,
    timed_out: &Cell<bool>,
) -> bool {
    tick_timed_out && modal_active.get() && !timed_out.replace(true)
}

fn update_exam_time_remaining_status(context: &TimedExamQuestionContext, localizer: Localizer) {
    let tick = context
        .progress
        .borrow_mut()
        .tick(context.started_at.elapsed().as_secs());
    set_exam_time_remaining_status(context.status_bar, localizer, tick.remaining_seconds);
}

fn set_exam_time_remaining_status(status_bar: StatusBar, localizer: Localizer, seconds: u64) {
    let remaining = format_relative_duration_complete(localizer, seconds as i64);
    status_bar.set_status_text(&localizer.exam_time_remaining_status(&remaining), 0);
}

fn exam_question_outcome_from_modal_result(
    result: i32,
    submit_id: i32,
    question: &ExamQuestion,
    entered_response: String,
) -> ExamQuestionOutcome {
    if result == ID_YES {
        if entered_response.trim().is_empty() {
            ExamQuestionOutcome::TimedOut(None)
        } else {
            ExamQuestionOutcome::TimedOut(Some(ExamAnswer::new(question.clone(), entered_response)))
        }
    } else if result == ID_OK || result == submit_id {
        ExamQuestionOutcome::Submitted(ExamAnswer::new(question.clone(), entered_response))
    } else if !entered_response.trim().is_empty() {
        ExamQuestionOutcome::SubmittedAndEnded(ExamAnswer::new(question.clone(), entered_response))
    } else {
        ExamQuestionOutcome::Ended
    }
}

pub(crate) fn confirm_exam_answer_correct_dialog(
    parent: Frame,
    localizer: Localizer,
    answer: &ExamAnswer,
    exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy,
    prompt_audio: Option<ReviewAudioFile>,
    response_audio: Option<ReviewAudioFile>,
) -> bool {
    let dialog = ExamGradeUi::new(Some(&parent), false);
    let yes_id = ExamGradeUi::xrc_id("button_exam_grade_yes");
    let no_id = ExamGradeUi::xrc_id("button_exam_grade_no");
    let entered_response = if answer.entered_response().trim().is_empty() {
        localizer.no_answer_entered().to_string()
    } else {
        answer.entered_response().to_string()
    };

    dialog
        .dialog_exam_grade
        .set_label(localizer.exam_grade_answer_caption());
    dialog
        .label_exam_grade_status
        .set_label(localizer.exam_grade_answer_instruction());
    dialog
        .label_exam_grade_prompt_heading
        .set_label(localizer.prompt_heading());
    dialog
        .label_exam_grade_answer_heading
        .set_label(localizer.exam_answer_heading());
    dialog
        .label_exam_grade_expected_heading
        .set_label(localizer.exam_expected_response_heading());
    dialog
        .button_exam_grade_no
        .set_label(localizer.no_button_with_shortcut());
    dialog
        .button_exam_grade_yes
        .set_label(localizer.yes_button_with_shortcut());
    dialog
        .text_exam_grade_prompt
        .set_value(answer.question().prompt());
    dialog.text_exam_grade_prompt.set_insertion_point(0);
    dialog.text_exam_grade_answer.set_value(&entered_response);
    dialog.text_exam_grade_answer.set_insertion_point(0);
    dialog
        .text_exam_grade_expected
        .set_value(answer.question().expected_response());
    dialog.text_exam_grade_expected.set_insertion_point(0);

    let modal_dialog = dialog.dialog_exam_grade;
    dialog
        .button_exam_grade_no
        .on_click(move |_| modal_dialog.end_modal(ID_NO));

    let modal_dialog = dialog.dialog_exam_grade;
    dialog
        .button_exam_grade_yes
        .on_click(move |_| modal_dialog.end_modal(ID_YES));
    let audio_controls = configure_exam_post_answer_audio(
        dialog.label_exam_grade_status,
        localizer,
        exam_post_answer_audio_policy,
        prompt_audio,
        response_audio,
    );
    bind_exam_grade_and_audio_shortcuts(
        dialog.text_exam_grade_prompt,
        dialog.dialog_exam_grade,
        audio_controls.clone(),
        dialog.label_exam_grade_status,
        localizer,
    );
    bind_exam_grade_and_audio_shortcuts(
        dialog.text_exam_grade_answer,
        dialog.dialog_exam_grade,
        audio_controls.clone(),
        dialog.label_exam_grade_status,
        localizer,
    );
    bind_exam_grade_and_audio_shortcuts(
        dialog.text_exam_grade_expected,
        dialog.dialog_exam_grade,
        audio_controls.clone(),
        dialog.label_exam_grade_status,
        localizer,
    );
    bind_exam_grade_and_audio_shortcuts(
        dialog.button_exam_grade_no,
        dialog.dialog_exam_grade,
        audio_controls.clone(),
        dialog.label_exam_grade_status,
        localizer,
    );
    bind_exam_grade_and_audio_shortcuts(
        dialog.button_exam_grade_yes,
        dialog.dialog_exam_grade,
        audio_controls.clone(),
        dialog.label_exam_grade_status,
        localizer,
    );

    dialog.dialog_exam_grade.set_affirmative_id(yes_id);
    dialog.dialog_exam_grade.set_escape_id(no_id);
    dialog.button_exam_grade_yes.set_default();
    dialog.text_exam_grade_prompt.set_focus();
    apply_exam_grade_visual_style(
        dialog.label_exam_grade_status,
        dialog.text_exam_grade_prompt,
        dialog.text_exam_grade_answer,
        dialog.text_exam_grade_expected,
        dialog.button_exam_grade_no,
        dialog.button_exam_grade_yes,
    );
    dialog.panel_exam_grade_root.layout();

    let result = dialog.dialog_exam_grade.show_modal();
    let accepted = result == ID_YES || result == yes_id;
    stop_exam_audio(&audio_controls);
    dialog.dialog_exam_grade.destroy();
    accepted
}

pub(crate) fn show_exam_inexact_answer_review_dialog(
    parent: Frame,
    localizer: Localizer,
    answer: &ExamAnswer,
    has_next_review_item: bool,
    exam_post_answer_audio_policy: ExamPostAnswerAudioPolicy,
    prompt_audio: Option<ReviewAudioFile>,
    response_audio: Option<ReviewAudioFile>,
) -> ExamInexactReviewOutcome {
    let dialog = ExamGradeUi::new(Some(&parent), false);
    let next_id = ExamGradeUi::xrc_id("button_exam_grade_yes");
    let entered_response = if answer.entered_response().trim().is_empty() {
        localizer.no_answer_entered().to_string()
    } else {
        answer.entered_response().to_string()
    };

    dialog
        .dialog_exam_grade
        .set_label(localizer.exam_review_inexact_caption());
    dialog
        .label_exam_grade_status
        .set_label(localizer.exam_review_inexact_instruction());
    dialog
        .label_exam_grade_prompt_heading
        .set_label(localizer.prompt_heading());
    dialog
        .label_exam_grade_answer_heading
        .set_label(localizer.exam_answer_heading());
    dialog
        .label_exam_grade_expected_heading
        .set_label(localizer.exam_expected_response_heading());
    dialog
        .button_exam_grade_no
        .set_label(localizer.exam_summary_button());
    dialog
        .button_exam_grade_yes
        .set_label(localizer.exam_next_button());
    dialog.button_exam_grade_yes.show(has_next_review_item);
    dialog.button_exam_grade_yes.enable(has_next_review_item);
    dialog
        .text_exam_grade_prompt
        .set_value(answer.question().prompt());
    dialog.text_exam_grade_prompt.set_insertion_point(0);
    dialog.text_exam_grade_answer.set_value(&entered_response);
    dialog.text_exam_grade_answer.set_insertion_point(0);
    dialog
        .text_exam_grade_expected
        .set_value(answer.question().expected_response());
    dialog.text_exam_grade_expected.set_insertion_point(0);

    let modal_active = Rc::new(Cell::new(true));
    let modal_dialog = dialog.dialog_exam_grade;
    let modal_active_copy = Rc::clone(&modal_active);
    dialog.button_exam_grade_no.on_click(move |_| {
        end_modal_once(modal_dialog, &modal_active_copy, ID_CANCEL);
    });

    if has_next_review_item {
        let modal_dialog = dialog.dialog_exam_grade;
        let modal_active_copy = Rc::clone(&modal_active);
        dialog.button_exam_grade_yes.on_click(move |_| {
            end_modal_once(modal_dialog, &modal_active_copy, ID_OK);
        });
    }
    let audio_controls = configure_exam_post_answer_audio(
        dialog.label_exam_grade_status,
        localizer,
        exam_post_answer_audio_policy,
        prompt_audio,
        response_audio,
    );
    bind_exam_review_shortcuts(
        dialog.text_exam_grade_prompt,
        dialog.dialog_exam_grade,
        Rc::clone(&modal_active),
        audio_controls.clone(),
        dialog.label_exam_grade_status,
        localizer,
        has_next_review_item,
    );
    bind_exam_review_shortcuts(
        dialog.text_exam_grade_answer,
        dialog.dialog_exam_grade,
        Rc::clone(&modal_active),
        audio_controls.clone(),
        dialog.label_exam_grade_status,
        localizer,
        has_next_review_item,
    );
    bind_exam_review_shortcuts(
        dialog.text_exam_grade_expected,
        dialog.dialog_exam_grade,
        Rc::clone(&modal_active),
        audio_controls.clone(),
        dialog.label_exam_grade_status,
        localizer,
        has_next_review_item,
    );
    bind_exam_review_shortcuts(
        dialog.button_exam_grade_no,
        dialog.dialog_exam_grade,
        Rc::clone(&modal_active),
        audio_controls.clone(),
        dialog.label_exam_grade_status,
        localizer,
        has_next_review_item,
    );
    if has_next_review_item {
        bind_exam_review_shortcuts(
            dialog.button_exam_grade_yes,
            dialog.dialog_exam_grade,
            Rc::clone(&modal_active),
            audio_controls.clone(),
            dialog.label_exam_grade_status,
            localizer,
            has_next_review_item,
        );
    }

    dialog.text_exam_grade_prompt.set_focus();
    apply_exam_grade_visual_style(
        dialog.label_exam_grade_status,
        dialog.text_exam_grade_prompt,
        dialog.text_exam_grade_answer,
        dialog.text_exam_grade_expected,
        dialog.button_exam_grade_no,
        dialog.button_exam_grade_yes,
    );
    dialog.panel_exam_grade_root.layout();

    let result = dialog.dialog_exam_grade.show_modal();
    modal_active.set(false);
    let outcome =
        exam_inexact_review_outcome_from_modal_result(result, next_id, has_next_review_item);
    stop_exam_audio(&audio_controls);
    dialog.dialog_exam_grade.destroy();
    outcome
}

pub(crate) fn show_exam_timeout_dialog(
    parent: Frame,
    localizer: Localizer,
    submitted_answers: usize,
) {
    show_exam_transition_dialog(
        parent,
        localizer.exam_timeout_caption(),
        &localizer.exam_timeout_message(submitted_answers),
        localizer,
    );
}

pub(crate) fn show_exam_finished_dialog(
    parent: Frame,
    localizer: Localizer,
    submitted_answers: usize,
) {
    play_exam_finished_notification();
    show_exam_transition_dialog(
        parent,
        localizer.exam_finished_caption(),
        &localizer.exam_finished_message(submitted_answers),
        localizer,
    );
}

fn show_exam_transition_dialog(parent: Frame, caption: &str, message: &str, localizer: Localizer) {
    let dialog = ExamTimeoutUi::new(Some(&parent), false);

    dialog.dialog_exam_timeout.set_label(caption);
    dialog.text_exam_timeout_message.set_value(message);
    dialog.text_exam_timeout_message.set_insertion_point(0);
    dialog
        .button_exam_timeout_next
        .set_label(localizer.exam_next_button());
    let modal_dialog = dialog.dialog_exam_timeout;
    dialog
        .button_exam_timeout_next
        .on_click(move |_| modal_dialog.end_modal(ID_OK));
    bind_timeout_message_keys(dialog.text_exam_timeout_message);

    dialog.text_exam_timeout_message.set_focus();
    apply_exam_timeout_visual_style(
        dialog.text_exam_timeout_message,
        dialog.button_exam_timeout_next,
    );
    dialog.panel_exam_timeout_root.layout();

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

fn bind_timeout_message_keys(message: TextCtrl) {
    message.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())
                || key_event.get_unicode_key() == Some(' ' as i32)
                || key_event.get_key_code() == Some(32)
            {
                key_event.event.skip(false);
            } else {
                key_event.event.skip(true);
            }
        }
    });
}

fn exam_inexact_review_outcome_from_modal_result(
    result: i32,
    next_id: i32,
    has_next_review_item: bool,
) -> ExamInexactReviewOutcome {
    if has_next_review_item && (result == ID_OK || result == next_id) {
        ExamInexactReviewOutcome::Next
    } else {
        ExamInexactReviewOutcome::Summary
    }
}

type ExamAudioControls = (Rc<RefCell<ReviewAudioPlayer>>, Vec<ReviewAudioFile>);

fn exam_audio_files(
    prompt_audio: Option<ReviewAudioFile>,
    response_audio: Option<ReviewAudioFile>,
) -> Vec<ReviewAudioFile> {
    [prompt_audio, response_audio]
        .into_iter()
        .flatten()
        .collect()
}

fn automatic_exam_post_answer_audio_files(
    policy: ExamPostAnswerAudioPolicy,
    prompt_audio: Option<ReviewAudioFile>,
    response_audio: Option<ReviewAudioFile>,
) -> Vec<ReviewAudioFile> {
    match policy {
        ExamPostAnswerAudioPolicy::Off => Vec::new(),
        ExamPostAnswerAudioPolicy::Question => prompt_audio.into_iter().collect(),
        ExamPostAnswerAudioPolicy::Answer => response_audio.into_iter().collect(),
        ExamPostAnswerAudioPolicy::Both => exam_audio_files(prompt_audio, response_audio),
    }
}

fn configure_exam_audio_button(
    button: Button,
    status: StaticText,
    localizer: Localizer,
    policy: ReviewAudioPolicy,
    audio_file: Option<ReviewAudioFile>,
) -> Option<ExamAudioControls> {
    button.set_label(localizer.play_stop_review_audio_button_label());
    let Some(audio_file) = audio_file.filter(|_| policy != ReviewAudioPolicy::Off) else {
        button.show(false);
        button.enable(false);
        return None;
    };

    button.show(true);
    button.enable(true);
    let player = Rc::new(RefCell::new(ReviewAudioPlayer::default()));
    let audio_files = vec![audio_file];
    if policy == ReviewAudioPolicy::Automatic {
        play_exam_audio_files(&player, status, localizer, audio_files.clone());
    }

    let controls = (player, audio_files);
    let button_controls = controls.clone();
    button.on_click(move |_| {
        toggle_exam_audio_files(&button_controls, status, localizer);
    });
    Some(controls)
}

fn configure_exam_post_answer_audio(
    status: StaticText,
    localizer: Localizer,
    policy: ExamPostAnswerAudioPolicy,
    prompt_audio: Option<ReviewAudioFile>,
    response_audio: Option<ReviewAudioFile>,
) -> Option<ExamAudioControls> {
    let manual_audio_files = exam_audio_files(prompt_audio.clone(), response_audio.clone());
    if manual_audio_files.is_empty() {
        return None;
    }

    let player = Rc::new(RefCell::new(ReviewAudioPlayer::default()));
    let automatic_audio_files =
        automatic_exam_post_answer_audio_files(policy, prompt_audio, response_audio);
    if !automatic_audio_files.is_empty() {
        play_exam_audio_files(&player, status, localizer, automatic_audio_files);
    }

    Some((player, manual_audio_files))
}

fn play_exam_audio_files(
    player: &Rc<RefCell<ReviewAudioPlayer>>,
    status: StaticText,
    localizer: Localizer,
    audio_files: Vec<ReviewAudioFile>,
) {
    let play_result = { player.borrow_mut().play_audio_files(audio_files) };
    if let Err(path) = play_result {
        status.set_label(&localizer.could_not_play_review_audio(&path.display().to_string()));
    }
}

fn toggle_exam_audio_files(controls: &ExamAudioControls, status: StaticText, localizer: Localizer) {
    let (player, audio_files) = controls;
    let toggle_result = { player.borrow_mut().toggle_audio_files(audio_files.clone()) };
    if let Err(path) = toggle_result {
        status.set_label(&localizer.could_not_play_review_audio(&path.display().to_string()));
    }
}

fn stop_exam_audio(controls: &Option<ExamAudioControls>) {
    if let Some((player, _)) = controls {
        player.borrow_mut().stop();
    }
}

fn handle_exam_audio_shortcut(
    unicode_key: Option<i32>,
    key_code: Option<i32>,
    control_down: bool,
    controls: &Option<ExamAudioControls>,
    status: StaticText,
    localizer: Localizer,
) -> bool {
    if !is_toggle_review_audio_key(unicode_key, key_code, control_down) {
        return false;
    }

    if let Some(controls) = controls {
        toggle_exam_audio_files(controls, status, localizer);
    }
    true
}

fn bind_exam_audio_shortcut<W>(
    widget: W,
    controls: ExamAudioControls,
    status: StaticText,
    localizer: Localizer,
) where
    W: WxWidget + WindowEvents + Copy + 'static,
{
    widget.on_key_down(move |event| {
        if let WindowEventData::Keyboard(key_event) = event {
            if handle_exam_audio_shortcut(
                key_event.get_unicode_key(),
                key_event.get_key_code(),
                key_event.control_down(),
                &Some(controls.clone()),
                status,
                localizer,
            ) {
                key_event.event.skip(false);
            } else {
                key_event.event.skip(true);
            }
        }
    });
}

fn bind_exam_review_shortcuts<W>(
    widget: W,
    modal_dialog: Dialog,
    modal_active: Rc<Cell<bool>>,
    controls: Option<ExamAudioControls>,
    status: StaticText,
    localizer: Localizer,
    has_next_review_item: bool,
) where
    W: WxWidget + WindowEvents + Copy + 'static,
{
    widget.on_key_down(move |event| {
        if let WindowEventData::Keyboard(key_event) = event {
            if handle_exam_audio_shortcut(
                key_event.get_unicode_key(),
                key_event.get_key_code(),
                key_event.control_down(),
                &controls,
                status,
                localizer,
            ) {
                key_event.event.skip(false);
            } else if has_next_review_item
                && is_alt_n_key(
                    key_event.get_unicode_key(),
                    key_event.get_key_code(),
                    key_event.alt_down(),
                )
            {
                end_modal_once(modal_dialog, &modal_active, ID_OK);
                key_event.event.skip(false);
            } else {
                key_event.event.skip(true);
            }
        }
    });
}

fn bind_exam_grade_and_audio_shortcuts<W>(
    widget: W,
    modal_dialog: Dialog,
    controls: Option<ExamAudioControls>,
    status: StaticText,
    localizer: Localizer,
) where
    W: WxWidget + WindowEvents + Copy + 'static,
{
    widget.on_key_down(move |event| {
        if let WindowEventData::Keyboard(key_event) = event {
            if handle_exam_audio_shortcut(
                key_event.get_unicode_key(),
                key_event.get_key_code(),
                key_event.control_down(),
                &controls,
                status,
                localizer,
            ) {
                key_event.event.skip(false);
                return;
            }
            match exam_grade_shortcut_result(
                key_event.get_unicode_key(),
                key_event.get_key_code(),
                key_event.control_down(),
            ) {
                Some(true) => {
                    modal_dialog.end_modal(ID_YES);
                    key_event.event.skip(false);
                }
                Some(false) => {
                    modal_dialog.end_modal(ID_NO);
                    key_event.event.skip(false);
                }
                None => key_event.event.skip(true),
            }
        }
    });
}

fn exam_grade_shortcut_result(
    unicode_key: Option<i32>,
    key_code: Option<i32>,
    control_down: bool,
) -> Option<bool> {
    if !control_down {
        return None;
    }
    let character = unicode_key
        .and_then(|key| u32::try_from(key).ok())
        .and_then(char::from_u32);
    if matches!(character, Some('y' | 'Y')) || matches!(key_code, Some(89) | Some(25)) {
        return Some(true);
    }
    if matches!(character, Some('n' | 'N')) || matches!(key_code, Some(78) | Some(14)) {
        return Some(false);
    }
    None
}

pub(crate) fn show_exam_summary_dialog(
    parent: Frame,
    localizer: Localizer,
    summary: &str,
    deck_path: &Path,
    review_db_path: &Path,
    started_at_unix_seconds: i64,
    finished_at_unix_seconds: i64,
) {
    let dialog = ExamSummaryUi::new(Some(&parent), false);
    let ok_id = ExamSummaryUi::xrc_id("button_exam_summary_ok");

    dialog
        .dialog_exam_summary
        .set_label(localizer.exam_summary_caption());
    dialog
        .label_exam_summary_status
        .set_label(localizer.exam_summary_caption());
    dialog
        .button_exam_summary_ok
        .set_label(localizer.options_ok_button());
    dialog
        .button_exam_summary_save_report
        .set_label(localizer.save_report_button());
    dialog.text_exam_summary.set_value(summary);
    dialog.text_exam_summary.set_insertion_point(0);
    dialog.button_exam_summary_save_report.on_click({
        let status = dialog.label_exam_summary_status;
        let deck_path = deck_path.to_path_buf();
        let review_db_path = review_db_path.to_path_buf();
        let summary = summary.to_string();
        move |_| {
            let report = exam_report_file(
                localizer,
                &deck_path,
                &review_db_path,
                started_at_unix_seconds,
                finished_at_unix_seconds,
                &summary,
            );
            match save_review_report(&report) {
                Ok(path) => {
                    status.set_label(
                        &localizer.saved_exam_report_status(&path.display().to_string()),
                    );
                }
                Err(error) => {
                    status.set_label(&localizer.could_not_save_exam_report_status(
                        &report.path().display().to_string(),
                        &error.to_string(),
                    ));
                }
            }
        }
    });

    let modal_dialog = dialog.dialog_exam_summary;
    dialog
        .button_exam_summary_ok
        .on_click(move |_| modal_dialog.end_modal(ID_OK));
    dialog.text_exam_summary.on_key_down({
        let modal_dialog = dialog.dialog_exam_summary;
        move |event| {
            if let WindowEventData::Keyboard(key_event) = event {
                if is_dialog_accept_key(key_event.get_unicode_key(), key_event.get_key_code()) {
                    modal_dialog.end_modal(ID_OK);
                    key_event.event.skip(false);
                } else {
                    key_event.event.skip(true);
                }
            }
        }
    });

    dialog.dialog_exam_summary.set_affirmative_id(ok_id);
    dialog.dialog_exam_summary.set_escape_id(ok_id);
    dialog.button_exam_summary_ok.set_default();
    dialog.text_exam_summary.set_focus();
    apply_exam_summary_visual_style(
        dialog.label_exam_summary_status,
        dialog.text_exam_summary,
        dialog.button_exam_summary_save_report,
        dialog.button_exam_summary_ok,
    );
    dialog.panel_exam_summary_root.layout();

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

#[allow(clippy::too_many_arguments)]
fn apply_exam_setup_visual_style(
    status: StaticText,
    size_mode: Choice,
    study_direction: Choice,
    size_value: TextCtrl,
    exact_checkbox: CheckBox,
    timed_checkbox: CheckBox,
    time_limit: TextCtrl,
    cancel_button: Button,
    start_button: Button,
) {
    increase_font_to_at_least(status, 11);
    increase_font_to_at_least(size_mode, 11);
    increase_font_to_at_least(study_direction, 11);
    increase_font_to_at_least(size_value, 11);
    increase_font_to_at_least(exact_checkbox, 11);
    increase_font_to_at_least(timed_checkbox, 11);
    increase_font_to_at_least(time_limit, 11);
    for button in [cancel_button, start_button] {
        increase_font_to_at_least(button, 11);
        button.set_min_size(Size::new(140, 40));
    }
}

fn apply_exam_question_visual_style(
    status: StaticText,
    prompt: TextCtrl,
    answer: TextCtrl,
    audio_button: Button,
    end_button: Button,
    submit_button: Button,
) {
    increase_font_to_at_least(status, 11);
    increase_font_to_at_least(prompt, 12);
    increase_font_to_at_least(answer, 12);
    for button in [audio_button, end_button, submit_button] {
        increase_font_to_at_least(button, 11);
        button.set_min_size(Size::new(140, 40));
    }
}

fn apply_exam_grade_visual_style(
    status: StaticText,
    prompt: TextCtrl,
    answer: TextCtrl,
    expected: TextCtrl,
    no_button: Button,
    yes_button: Button,
) {
    increase_font_to_at_least(status, 11);
    for text in [prompt, answer, expected] {
        increase_font_to_at_least(text, 12);
    }
    for button in [no_button, yes_button] {
        increase_font_to_at_least(button, 11);
        button.set_min_size(Size::new(120, 40));
    }
}

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

fn apply_exam_timeout_visual_style(message: TextCtrl, next_button: Button) {
    increase_font_to_at_least(message, 12);
    increase_font_to_at_least(next_button, 11);
    next_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);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use cosimo::DatumKey;
    use std::path::PathBuf;

    fn test_audio_file(side: crate::review_audio::ReviewAudioSide) -> ReviewAudioFile {
        ReviewAudioFile::for_test(1, side, PathBuf::from(format!("{side:?}.wav")))
    }

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

        assert!(source.contains("checkbox_exam_exact.set_name(exact_checkbox_label);"));
        assert!(source.contains("checkbox_exam_timed.set_name(timed_checkbox_label);"));
    }

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

        assert!(source.contains("show_study_direction"));
        assert!(source.contains("label_exam_study_direction"));
        assert!(source.contains("choice_exam_study_direction"));
        assert!(source.contains("ExamStudyDirection::Both"));
    }

    #[test]
    fn exam_grade_shortcuts_accept_control_y_and_control_n() {
        assert_eq!(
            exam_grade_shortcut_result(Some('y' as i32), Some(89), true),
            Some(true)
        );
        assert_eq!(
            exam_grade_shortcut_result(Some('N' as i32), Some(78), true),
            Some(false)
        );
        assert_eq!(exam_grade_shortcut_result(None, Some(25), true), Some(true));
        assert_eq!(
            exam_grade_shortcut_result(None, Some(14), true),
            Some(false)
        );
    }

    #[test]
    fn exam_grade_shortcuts_require_control() {
        assert_eq!(
            exam_grade_shortcut_result(Some('y' as i32), Some(89), false),
            None
        );
        assert_eq!(
            exam_grade_shortcut_result(Some('n' as i32), Some(78), false),
            None
        );
    }

    #[test]
    fn exam_review_source_binds_alt_n_to_next_on_review_controls() {
        let source = include_str!("exam_dialog.rs")
            .split("#[cfg(test)]")
            .next()
            .expect("runtime source before tests");
        let review_start = source
            .find("pub(crate) fn show_exam_inexact_answer_review_dialog")
            .expect("review dialog function");
        let timeout_start = source[review_start..]
            .find("pub(crate) fn show_exam_timeout_dialog")
            .expect("next function after review dialog");
        let review_source = &source[review_start..review_start + timeout_start];

        assert!(review_source.contains("bind_exam_review_shortcuts"));
        assert!(review_source.contains("dialog.text_exam_grade_prompt"));
        assert!(review_source.contains("dialog.text_exam_grade_answer"));
        assert!(review_source.contains("dialog.text_exam_grade_expected"));
        assert!(review_source.contains("button_exam_grade_yes.show(has_next_review_item);"));
        assert!(review_source.contains("if has_next_review_item {"));
        assert!(source.contains("is_alt_n_key("));
        assert!(review_source.contains("let modal_active = Rc::new(Cell::new(true));"));
        assert!(review_source.contains("end_modal_once(modal_dialog, &modal_active_copy, ID_OK);"));
        assert!(review_source.contains("modal_active.set(false);"));
        assert!(!review_source.contains("set_affirmative_id"));
        assert!(!review_source.contains("set_escape_id"));
    }

    #[test]
    fn exam_summary_enter_key_is_handled_as_ok() {
        let source = include_str!("exam_dialog.rs")
            .split("#[cfg(test)]")
            .next()
            .expect("runtime source before tests");
        let summary_start = source
            .find("pub(crate) fn show_exam_summary_dialog")
            .expect("summary dialog function");
        let style_start = source[summary_start..]
            .find("fn apply_exam_setup_visual_style")
            .expect("next function after summary dialog");
        let summary_source = &source[summary_start..summary_start + style_start];

        assert!(summary_source.contains("dialog.text_exam_summary.on_key_down("));
        assert!(summary_source.contains("is_dialog_accept_key("));
        assert!(summary_source.contains("modal_dialog.end_modal(ID_OK);"));
    }

    #[test]
    fn exam_finished_notice_reuses_guarded_transition_dialog() {
        let source = include_str!("exam_dialog.rs")
            .split("#[cfg(test)]")
            .next()
            .expect("runtime source before tests");
        let finished_start = source
            .find("pub(crate) fn show_exam_finished_dialog")
            .expect("finished dialog function");
        let transition_start = source
            .find("fn show_exam_transition_dialog")
            .expect("shared transition dialog function");
        let finished_source = &source[finished_start..transition_start];
        let transition_source = &source[transition_start..];

        assert!(finished_source.contains("play_exam_finished_notification();"));
        assert!(finished_source.contains("localizer.exam_finished_caption()"));
        assert!(finished_source.contains("localizer.exam_finished_message(submitted_answers)"));
        assert!(transition_source.contains("ExamTimeoutUi::new"));
        assert!(transition_source.contains("bind_timeout_message_keys"));
        assert!(transition_source.contains("text_exam_timeout_message.set_focus();"));
        assert!(transition_source.contains("show_modal()"));
    }

    #[test]
    fn ending_exam_question_with_text_submits_answer_and_ends() {
        let question = ExamQuestion::new(DatumKey::for_card_id(1), "Prompt", "Response");

        let outcome = exam_question_outcome_from_modal_result(
            ID_CANCEL,
            50_000,
            &question,
            "typed".to_string(),
        );

        assert!(matches!(
            outcome,
            ExamQuestionOutcome::SubmittedAndEnded(answer)
                if answer.entered_response() == "typed"
        ));
    }

    #[test]
    fn ending_exam_question_without_text_ends_without_answer() {
        let question = ExamQuestion::new(DatumKey::for_card_id(1), "Prompt", "Response");

        let outcome =
            exam_question_outcome_from_modal_result(ID_CANCEL, 50_000, &question, "  ".to_string());

        assert_eq!(outcome, ExamQuestionOutcome::Ended);
    }

    #[test]
    fn inexact_exam_review_modal_result_maps_next_and_summary() {
        let next_id = 50_000;

        assert_eq!(
            exam_inexact_review_outcome_from_modal_result(ID_OK, next_id, true),
            ExamInexactReviewOutcome::Next
        );
        assert_eq!(
            exam_inexact_review_outcome_from_modal_result(next_id, next_id, true),
            ExamInexactReviewOutcome::Next
        );
        assert_eq!(
            exam_inexact_review_outcome_from_modal_result(ID_CANCEL, next_id, true),
            ExamInexactReviewOutcome::Summary
        );
        assert_eq!(
            exam_inexact_review_outcome_from_modal_result(ID_OK, next_id, false),
            ExamInexactReviewOutcome::Summary
        );
    }

    #[test]
    fn timed_exam_timer_only_closes_active_modal_once() {
        let modal_active = Cell::new(true);
        let timed_out = Cell::new(false);

        assert!(!should_end_timed_exam_question_dialog(
            false,
            &modal_active,
            &timed_out
        ));
        assert!(!timed_out.get());

        assert!(should_end_timed_exam_question_dialog(
            true,
            &modal_active,
            &timed_out
        ));
        assert!(timed_out.get());
        assert!(!should_end_timed_exam_question_dialog(
            true,
            &modal_active,
            &timed_out
        ));

        let inactive_modal = Cell::new(false);
        let inactive_timed_out = Cell::new(false);
        assert!(!should_end_timed_exam_question_dialog(
            true,
            &inactive_modal,
            &inactive_timed_out
        ));
        assert!(!inactive_timed_out.get());
    }

    #[test]
    fn timed_exam_question_timer_is_deactivated_before_dialog_destroy() {
        let source = include_str!("exam_dialog.rs")
            .split("#[cfg(test)]")
            .next()
            .expect("runtime source before tests");
        let function_start = source
            .find("pub(crate) fn show_exam_question_dialog")
            .expect("question dialog function");
        let function_end = source[function_start..]
            .find("fn install_exam_question_timer")
            .expect("next function after question dialog");
        let function_source = &source[function_start..function_start + function_end];
        let show_modal = function_source
            .find("show_modal()")
            .expect("question dialog should be shown modally");
        let deactivate = function_source
            .find("timer.deactivate();")
            .expect("timed question timer should be deactivated after modal close");
        let destroy = function_source
            .find("dialog.dialog_exam_question.destroy();")
            .expect("question dialog should be destroyed");

        assert!(show_modal < deactivate);
        assert!(deactivate < destroy);
    }

    #[test]
    fn exam_post_answer_audio_policy_selects_automatic_files() {
        let prompt_audio = test_audio_file(crate::review_audio::ReviewAudioSide::Prompt);
        let response_audio = test_audio_file(crate::review_audio::ReviewAudioSide::Response);

        assert_eq!(
            automatic_exam_post_answer_audio_files(
                ExamPostAnswerAudioPolicy::Off,
                Some(prompt_audio.clone()),
                Some(response_audio.clone()),
            ),
            Vec::<ReviewAudioFile>::new()
        );
        assert_eq!(
            automatic_exam_post_answer_audio_files(
                ExamPostAnswerAudioPolicy::Question,
                Some(prompt_audio.clone()),
                Some(response_audio.clone()),
            ),
            vec![prompt_audio.clone()]
        );
        assert_eq!(
            automatic_exam_post_answer_audio_files(
                ExamPostAnswerAudioPolicy::Answer,
                Some(prompt_audio.clone()),
                Some(response_audio.clone()),
            ),
            vec![response_audio.clone()]
        );
        assert_eq!(
            automatic_exam_post_answer_audio_files(
                ExamPostAnswerAudioPolicy::Both,
                Some(prompt_audio.clone()),
                Some(response_audio.clone()),
            ),
            vec![prompt_audio, response_audio]
        );
    }
}
