use crate::runtime::shuffle_indices;
use crate::study_item::StudyItem;
#[cfg(test)]
use cosimo::Datum;
use cosimo::DatumKey;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExamSizeMode {
    QuestionCount,
    Percentage,
}

impl ExamSizeMode {
    pub(crate) const ALL: &'static [Self] = &[Self::QuestionCount, Self::Percentage];

    pub(crate) fn from_selection(selection: Option<ExamSizeModeSelection>) -> Self {
        selection
            .and_then(|selection| Self::ALL.get(selection.index_usize()).copied())
            .unwrap_or(Self::QuestionCount)
    }

    pub(crate) fn selection_index(self) -> ExamSizeModeSelection {
        let index = Self::ALL
            .iter()
            .position(|mode| *mode == self)
            .unwrap_or_default() as u32;
        ExamSizeModeSelection::new(index).expect("exam size mode index must be valid")
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ExamSizeModeSelection {
    index: u32,
}

impl ExamSizeModeSelection {
    pub(crate) fn new(index: u32) -> Option<Self> {
        (index < ExamSizeMode::ALL.len() as u32).then_some(Self { index })
    }

    pub(crate) fn from_wx_selection(selection: Option<u32>) -> Option<Self> {
        selection.and_then(Self::new)
    }

    pub(crate) fn index(self) -> u32 {
        self.index
    }

    fn index_usize(self) -> usize {
        self.index as usize
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExamStudyDirection {
    Both,
    StandardOnly,
    ReverseOnly,
}

impl ExamStudyDirection {
    pub(crate) const ALL: &'static [Self] = &[Self::StandardOnly, Self::ReverseOnly, Self::Both];

    pub(crate) fn from_selection(selection: Option<ExamStudyDirectionSelection>) -> Self {
        selection
            .and_then(|selection| Self::ALL.get(selection.index_usize()).copied())
            .unwrap_or(Self::Both)
    }

    pub(crate) fn selection_index(self) -> ExamStudyDirectionSelection {
        let index = Self::ALL
            .iter()
            .position(|direction| *direction == self)
            .unwrap_or(Self::ALL.len() - 1) as u32;
        ExamStudyDirectionSelection::new(index).expect("exam direction index must be valid")
    }

    pub(crate) fn includes(self, is_reverse: bool) -> bool {
        match self {
            Self::Both => true,
            Self::StandardOnly => !is_reverse,
            Self::ReverseOnly => is_reverse,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ExamStudyDirectionSelection {
    index: u32,
}

impl ExamStudyDirectionSelection {
    pub(crate) fn new(index: u32) -> Option<Self> {
        (index < ExamStudyDirection::ALL.len() as u32).then_some(Self { index })
    }

    pub(crate) fn from_wx_selection(selection: Option<u32>) -> Option<Self> {
        selection.and_then(Self::new)
    }

    pub(crate) fn index(self) -> u32 {
        self.index
    }

    fn index_usize(self) -> usize {
        self.index as usize
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ExamSizeRequest {
    mode: ExamSizeMode,
    value: usize,
    direction: ExamStudyDirection,
    exact_only: bool,
    time_limit_minutes: Option<usize>,
}

impl ExamSizeRequest {
    #[cfg(test)]
    pub(crate) fn new(mode: ExamSizeMode, value: usize) -> Self {
        Self::new_with_exact_only(mode, value, false)
    }

    #[cfg(test)]
    pub(crate) fn new_with_exact_only(mode: ExamSizeMode, value: usize, exact_only: bool) -> Self {
        Self::new_with_options(mode, value, exact_only, None)
    }

    #[cfg(test)]
    pub(crate) fn new_with_options(
        mode: ExamSizeMode,
        value: usize,
        exact_only: bool,
        time_limit_minutes: Option<usize>,
    ) -> Self {
        Self::new_with_direction_and_options(
            mode,
            value,
            ExamStudyDirection::Both,
            exact_only,
            time_limit_minutes,
        )
    }

    pub(crate) fn new_with_direction_and_options(
        mode: ExamSizeMode,
        value: usize,
        direction: ExamStudyDirection,
        exact_only: bool,
        time_limit_minutes: Option<usize>,
    ) -> Self {
        Self {
            mode,
            value,
            direction,
            exact_only,
            time_limit_minutes,
        }
    }

    pub(crate) fn mode(self) -> ExamSizeMode {
        self.mode
    }

    pub(crate) fn value(self) -> usize {
        self.value
    }

    pub(crate) fn direction(self) -> ExamStudyDirection {
        self.direction
    }

    pub(crate) fn exact_only(self) -> bool {
        self.exact_only
    }

    pub(crate) fn time_limit_minutes(self) -> Option<usize> {
        self.time_limit_minutes
    }
}

pub(crate) fn resolve_exam_question_count(
    request: ExamSizeRequest,
    active_card_count: usize,
) -> usize {
    if active_card_count == 0 {
        return 0;
    }

    match request.mode() {
        ExamSizeMode::QuestionCount => request.value().clamp(1, active_card_count),
        ExamSizeMode::Percentage => {
            let percentage = request.value().clamp(1, 100);
            (active_card_count * percentage).div_ceil(100)
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExamBuildError {
    NoActiveCards,
}

pub(crate) fn build_exam_pass(
    items: &[StudyItem],
    active_indices: Vec<usize>,
    request: ExamSizeRequest,
    seed: u64,
    started_at_unix_seconds: i64,
) -> Result<(ExamPass, Vec<usize>), ExamBuildError> {
    let mut exam_queue = active_indices;
    exam_queue.retain(|index| {
        items
            .get(*index)
            .map(|item| request.direction().includes(item.is_reverse()))
            .unwrap_or(false)
    });

    if exam_queue.is_empty() {
        return Err(ExamBuildError::NoActiveCards);
    }

    shuffle_indices(&mut exam_queue, seed);
    let question_count = resolve_exam_question_count(request, exam_queue.len());
    exam_queue.truncate(question_count);
    let session_queue = exam_queue.clone();
    let questions: Vec<_> = exam_queue
        .into_iter()
        .filter_map(|index| items.get(index))
        .map(|item| {
            ExamQuestion::new(
                item.datum_key().clone(),
                item.datum().prompt(),
                item.datum().response(),
            )
        })
        .collect();

    if questions.is_empty() {
        return Err(ExamBuildError::NoActiveCards);
    }

    Ok((
        ExamPass::new(started_at_unix_seconds, questions),
        session_queue,
    ))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TimedExamTick {
    pub(crate) remaining_seconds: u64,
    pub(crate) play_progress_tone: bool,
    pub(crate) timed_out: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TimedExamProgress {
    duration_seconds: u64,
    next_progress_percent: u8,
}

impl TimedExamProgress {
    pub(crate) fn new(limit_minutes: usize) -> Self {
        Self {
            duration_seconds: (limit_minutes as u64).saturating_mul(60).max(1),
            next_progress_percent: 10,
        }
    }

    pub(crate) fn tick(&mut self, elapsed_seconds: u64) -> TimedExamTick {
        let timed_out = elapsed_seconds >= self.duration_seconds;
        let remaining_seconds = self.duration_seconds.saturating_sub(elapsed_seconds);
        let elapsed_percent =
            (elapsed_seconds.saturating_mul(100) / self.duration_seconds).min(100) as u8;
        let play_progress_tone = !timed_out
            && self.next_progress_percent < 100
            && elapsed_percent >= self.next_progress_percent;
        if play_progress_tone {
            while self.next_progress_percent < 100 && elapsed_percent >= self.next_progress_percent
            {
                self.next_progress_percent += 10;
            }
        }

        TimedExamTick {
            remaining_seconds,
            play_progress_tone,
            timed_out,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExamPass {
    started_at_unix_seconds: i64,
    questions: Vec<ExamQuestion>,
}

impl ExamPass {
    pub(crate) fn new(started_at_unix_seconds: i64, questions: Vec<ExamQuestion>) -> Self {
        Self {
            started_at_unix_seconds,
            questions,
        }
    }

    pub(crate) fn started_at_unix_seconds(&self) -> i64 {
        self.started_at_unix_seconds
    }

    pub(crate) fn questions(&self) -> &[ExamQuestion] {
        &self.questions
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExamQuestion {
    datum_key: DatumKey,
    prompt: String,
    expected_response: String,
}

impl ExamQuestion {
    pub(crate) fn new(
        datum_key: DatumKey,
        prompt: impl Into<String>,
        expected_response: impl Into<String>,
    ) -> Self {
        Self {
            datum_key,
            prompt: prompt.into(),
            expected_response: expected_response.into(),
        }
    }

    pub(crate) fn datum_key(&self) -> &DatumKey {
        &self.datum_key
    }

    pub(crate) fn prompt(&self) -> &str {
        &self.prompt
    }

    pub(crate) fn expected_response(&self) -> &str {
        &self.expected_response
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExamAnswer {
    question: ExamQuestion,
    entered_response: String,
}

impl ExamAnswer {
    pub(crate) fn new(question: ExamQuestion, entered_response: impl Into<String>) -> Self {
        Self {
            question,
            entered_response: entered_response.into(),
        }
    }

    pub(crate) fn question(&self) -> &ExamQuestion {
        &self.question
    }

    pub(crate) fn entered_response(&self) -> &str {
        &self.entered_response
    }

    pub(crate) fn is_exact_match(&self) -> bool {
        exam_answer_is_exact_match(&self.entered_response, self.question.expected_response())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GradedExamAnswer {
    answer: ExamAnswer,
    is_correct: bool,
}

impl GradedExamAnswer {
    pub(crate) fn new(answer: ExamAnswer, is_correct: bool) -> Self {
        Self { answer, is_correct }
    }

    pub(crate) fn answer(&self) -> &ExamAnswer {
        &self.answer
    }

    pub(crate) fn is_correct(&self) -> bool {
        self.is_correct
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ManualExamGrading {
    Exact(GradedExamAnswer),
    ReviewIncorrect(ExamAnswer),
    NeedsDecision(ExamAnswer),
}

pub(crate) fn classify_exam_answer_for_manual_grading(answer: ExamAnswer) -> ManualExamGrading {
    if answer.is_exact_match() {
        ManualExamGrading::Exact(GradedExamAnswer::new(answer, true))
    } else if answer.entered_response().trim().is_empty() {
        ManualExamGrading::ReviewIncorrect(answer)
    } else {
        ManualExamGrading::NeedsDecision(answer)
    }
}

pub(crate) struct ExactExamReviewPlan {
    graded_answers: Vec<GradedExamAnswer>,
    inexact_answers: Vec<ExamAnswer>,
}

impl ExactExamReviewPlan {
    pub(crate) fn graded_answers(self) -> Vec<GradedExamAnswer> {
        self.graded_answers
    }

    #[cfg(test)]
    pub(crate) fn graded_answers_slice(&self) -> &[GradedExamAnswer] {
        &self.graded_answers
    }

    pub(crate) fn inexact_answers(&self) -> &[ExamAnswer] {
        &self.inexact_answers
    }
}

pub(crate) fn grade_exam_answers_exact_only(answers: Vec<ExamAnswer>) -> ExactExamReviewPlan {
    let mut graded_answers = Vec::with_capacity(answers.len());
    let mut inexact_answers = Vec::new();
    for answer in answers {
        let is_exact = answer.is_exact_match();
        if !is_exact {
            inexact_answers.push(answer.clone());
        }
        graded_answers.push(GradedExamAnswer::new(answer, is_exact));
    }
    ExactExamReviewPlan {
        graded_answers,
        inexact_answers,
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExamSummary {
    total_questions: usize,
    submitted_answers: usize,
    exact_matches: usize,
    accepted_non_exact: usize,
    incorrect_answers: usize,
    elapsed_seconds: u64,
    ended_early: bool,
    time_limit_minutes: Option<usize>,
    timed_out: bool,
}

impl ExamSummary {
    #[cfg(test)]
    pub(crate) fn from_answers(
        total_questions: usize,
        answers: &[GradedExamAnswer],
        elapsed_seconds: u64,
    ) -> Self {
        Self::from_answers_with_timing(total_questions, answers, elapsed_seconds, None, false)
    }

    pub(crate) fn from_answers_with_timing(
        total_questions: usize,
        answers: &[GradedExamAnswer],
        elapsed_seconds: u64,
        time_limit_minutes: Option<usize>,
        timed_out: bool,
    ) -> Self {
        let exact_matches = answers
            .iter()
            .filter(|answer| answer.answer().is_exact_match())
            .count();
        let correct_answers = answers.iter().filter(|answer| answer.is_correct()).count();
        let accepted_non_exact = correct_answers.saturating_sub(exact_matches);
        let submitted_answers = answers.len();
        let incorrect_answers = submitted_answers.saturating_sub(correct_answers);

        Self {
            total_questions,
            submitted_answers,
            exact_matches,
            accepted_non_exact,
            incorrect_answers,
            elapsed_seconds,
            ended_early: submitted_answers < total_questions,
            time_limit_minutes,
            timed_out,
        }
    }

    pub(crate) fn total_questions(&self) -> usize {
        self.total_questions
    }

    pub(crate) fn submitted_answers(&self) -> usize {
        self.submitted_answers
    }

    pub(crate) fn exact_matches(&self) -> usize {
        self.exact_matches
    }

    pub(crate) fn accepted_non_exact(&self) -> usize {
        self.accepted_non_exact
    }

    pub(crate) fn correct_answers(&self) -> usize {
        self.exact_matches + self.accepted_non_exact
    }

    pub(crate) fn correct_percentage(&self) -> usize {
        if self.submitted_answers == 0 {
            return 0;
        }
        (self.correct_answers() * 100) / self.submitted_answers
    }

    pub(crate) fn incorrect_answers(&self) -> usize {
        self.incorrect_answers
    }

    pub(crate) fn elapsed_seconds(&self) -> u64 {
        self.elapsed_seconds
    }

    pub(crate) fn seconds_per_submitted_answer(&self) -> u64 {
        if self.submitted_answers == 0 {
            return 0;
        }
        self.elapsed_seconds / self.submitted_answers as u64
    }

    pub(crate) fn ended_early(&self) -> bool {
        self.ended_early
    }

    pub(crate) fn time_limit_minutes(&self) -> Option<usize> {
        self.time_limit_minutes
    }

    pub(crate) fn timed_out(&self) -> bool {
        self.timed_out
    }
}

pub(crate) fn exam_answer_is_exact_match(entered: &str, expected: &str) -> bool {
    normalize_exam_answer(entered) == normalize_exam_answer(expected)
}

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

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

    #[test]
    fn exact_exam_answer_matching_ignores_surrounding_space_and_line_ending_style() {
        assert!(exam_answer_is_exact_match(" Madrid \r\n", "Madrid\n"));
    }

    #[test]
    fn exam_size_mode_selection_indices_follow_mode_order() {
        for (index, mode) in ExamSizeMode::ALL.iter().copied().enumerate() {
            let selection = ExamSizeModeSelection::new(index as u32).unwrap();
            assert_eq!(mode.selection_index(), selection);
            assert_eq!(mode.selection_index().index(), index as u32);
            assert_eq!(ExamSizeMode::from_selection(Some(selection)), mode);
        }
        assert_eq!(
            ExamSizeMode::from_selection(None),
            ExamSizeMode::QuestionCount
        );
        assert_eq!(
            ExamSizeModeSelection::new(ExamSizeMode::ALL.len() as u32),
            None
        );
        assert_eq!(ExamSizeModeSelection::from_wx_selection(None), None);
        assert_eq!(
            ExamSizeModeSelection::from_wx_selection(Some(1)),
            ExamSizeModeSelection::new(1)
        );
    }

    #[test]
    fn exam_study_direction_selection_indices_follow_choice_order() {
        for (index, direction) in ExamStudyDirection::ALL.iter().copied().enumerate() {
            let selection = ExamStudyDirectionSelection::new(index as u32).unwrap();
            assert_eq!(direction.selection_index(), selection);
            assert_eq!(direction.selection_index().index(), index as u32);
            assert_eq!(
                ExamStudyDirection::from_selection(Some(selection)),
                direction
            );
        }
        assert_eq!(
            ExamStudyDirection::from_selection(None),
            ExamStudyDirection::Both
        );
        assert_eq!(
            ExamStudyDirectionSelection::new(ExamStudyDirection::ALL.len() as u32),
            None
        );
        assert_eq!(ExamStudyDirectionSelection::from_wx_selection(None), None);
        assert_eq!(
            ExamStudyDirectionSelection::from_wx_selection(Some(1)),
            ExamStudyDirectionSelection::new(1)
        );
    }

    #[test]
    fn exact_exam_answer_matching_keeps_case_and_accents_significant() {
        assert!(!exam_answer_is_exact_match("madrid", "Madrid"));
        assert!(!exam_answer_is_exact_match("si", "sí"));
    }

    #[test]
    fn exam_question_count_clamps_requested_question_count_to_active_cards() {
        assert_eq!(
            resolve_exam_question_count(ExamSizeRequest::new(ExamSizeMode::QuestionCount, 10), 4,),
            4
        );
        assert_eq!(
            resolve_exam_question_count(ExamSizeRequest::new(ExamSizeMode::QuestionCount, 0), 4),
            1
        );
    }

    #[test]
    fn exam_question_count_uses_ceiling_for_percentages() {
        assert_eq!(
            resolve_exam_question_count(ExamSizeRequest::new(ExamSizeMode::Percentage, 10), 21),
            3
        );
        assert_eq!(
            resolve_exam_question_count(ExamSizeRequest::new(ExamSizeMode::Percentage, 100), 21),
            21
        );
        assert_eq!(
            resolve_exam_question_count(ExamSizeRequest::new(ExamSizeMode::Percentage, 0), 21),
            1
        );
    }

    #[test]
    fn timed_exam_progress_reports_remaining_time_tones_and_timeout() {
        let mut progress = TimedExamProgress::new(1);

        assert_eq!(
            progress.tick(0),
            TimedExamTick {
                remaining_seconds: 60,
                play_progress_tone: false,
                timed_out: false,
            }
        );
        assert_eq!(
            progress.tick(6),
            TimedExamTick {
                remaining_seconds: 54,
                play_progress_tone: true,
                timed_out: false,
            }
        );
        assert!(!progress.tick(7).play_progress_tone);
        assert!(progress.tick(12).play_progress_tone);
        assert_eq!(
            progress.tick(60),
            TimedExamTick {
                remaining_seconds: 0,
                play_progress_tone: false,
                timed_out: true,
            }
        );
    }

    #[test]
    fn exam_size_request_preserves_exact_only_choice() {
        let request =
            ExamSizeRequest::new_with_options(ExamSizeMode::Percentage, 50, true, Some(15));

        assert_eq!(request.mode(), ExamSizeMode::Percentage);
        assert_eq!(request.value(), 50);
        assert_eq!(request.direction(), ExamStudyDirection::Both);
        assert!(request.exact_only());
        assert_eq!(request.time_limit_minutes(), Some(15));
    }

    #[test]
    fn exam_size_request_preserves_direction_choice() {
        let request = ExamSizeRequest::new_with_direction_and_options(
            ExamSizeMode::QuestionCount,
            5,
            ExamStudyDirection::ReverseOnly,
            false,
            None,
        );

        assert_eq!(request.direction(), ExamStudyDirection::ReverseOnly);
    }

    #[test]
    fn build_exam_pass_filters_direction_and_returns_session_queue() {
        let standard = Datum::new("one", "uno").unwrap().with_card_id(1);
        let reversible = Datum::new("two", "dos").unwrap().with_card_id(2);
        let items = vec![
            StudyItem::forward(0, &standard),
            StudyItem::forward(1, &reversible),
            StudyItem::reverse(1, &reversible),
        ];
        let request = ExamSizeRequest::new_with_direction_and_options(
            ExamSizeMode::QuestionCount,
            1,
            ExamStudyDirection::ReverseOnly,
            false,
            None,
        );

        let (exam, session_queue) =
            build_exam_pass(&items, vec![0, 1, 2], request, 17, 1_800_000_000).expect("exam pass");

        assert_eq!(session_queue, vec![2]);
        assert_eq!(exam.questions().len(), 1);
        assert_eq!(exam.questions()[0].prompt(), "dos");
        assert_eq!(exam.questions()[0].expected_response(), "two");
    }

    #[test]
    fn graded_exam_answers_store_correctness_without_scheduler_feedback() {
        let question = ExamQuestion::new(DatumKey::for_card_id(1), "Prompt", "Response");
        let answer = ExamAnswer::new(question.clone(), "Response");

        assert!(GradedExamAnswer::new(answer.clone(), true).is_correct());
        assert!(!GradedExamAnswer::new(answer, false).is_correct());
    }

    #[test]
    fn manual_exam_grading_auto_accepts_exact_answers_reviews_blank_answers_and_defers_inexact_answers()
     {
        let exact_question = ExamQuestion::new(DatumKey::for_card_id(1), "Prompt 1", "Madrid");
        let inexact_question = ExamQuestion::new(DatumKey::for_card_id(2), "Prompt 2", "Paris");
        let blank_question = ExamQuestion::new(DatumKey::for_card_id(3), "Prompt 3", "Rome");

        let exact =
            classify_exam_answer_for_manual_grading(ExamAnswer::new(exact_question, " Madrid "));
        let inexact =
            classify_exam_answer_for_manual_grading(ExamAnswer::new(inexact_question, "París"));
        let blank =
            classify_exam_answer_for_manual_grading(ExamAnswer::new(blank_question, " \n "));

        assert!(matches!(
            exact,
            ManualExamGrading::Exact(answer) if answer.is_correct() && answer.answer().is_exact_match()
        ));
        assert!(matches!(
            blank,
            ManualExamGrading::ReviewIncorrect(answer)
                if answer.entered_response() == " \n " && !answer.is_exact_match()
        ));
        assert!(matches!(
            inexact,
            ManualExamGrading::NeedsDecision(answer)
                if answer.entered_response() == "París" && !answer.is_exact_match()
        ));
    }

    #[test]
    fn exact_only_exam_grading_marks_inexact_answers_incorrect_and_reviews_them() {
        let exact_question = ExamQuestion::new(DatumKey::for_card_id(1), "Prompt 1", "Madrid");
        let inexact_question = ExamQuestion::new(DatumKey::for_card_id(2), "Prompt 2", "Paris");

        let plan = grade_exam_answers_exact_only(vec![
            ExamAnswer::new(exact_question, "Madrid"),
            ExamAnswer::new(inexact_question, "París"),
        ]);

        assert_eq!(plan.graded_answers_slice().len(), 2);
        assert!(plan.graded_answers_slice()[0].is_correct());
        assert!(!plan.graded_answers_slice()[1].is_correct());
        assert_eq!(plan.inexact_answers().len(), 1);
        assert_eq!(plan.inexact_answers()[0].entered_response(), "París");
        assert_eq!(
            plan.inexact_answers()[0].question().expected_response(),
            "Paris"
        );
    }

    #[test]
    fn exam_summary_counts_exact_accepted_and_incorrect_answers() {
        let exact_question = ExamQuestion::new(DatumKey::for_card_id(1), "Prompt 1", "Madrid");
        let accepted_question = ExamQuestion::new(DatumKey::for_card_id(2), "Prompt 2", "Paris");
        let incorrect_question = ExamQuestion::new(DatumKey::for_card_id(3), "Prompt 3", "Rome");
        let answers = vec![
            GradedExamAnswer::new(ExamAnswer::new(exact_question, " Madrid "), true),
            GradedExamAnswer::new(ExamAnswer::new(accepted_question, "París"), true),
            GradedExamAnswer::new(ExamAnswer::new(incorrect_question, "Milan"), false),
        ];

        let summary = ExamSummary::from_answers(4, &answers, 12);

        assert_eq!(summary.total_questions(), 4);
        assert_eq!(summary.submitted_answers(), 3);
        assert_eq!(summary.exact_matches(), 1);
        assert_eq!(summary.accepted_non_exact(), 1);
        assert_eq!(summary.correct_answers(), 2);
        assert_eq!(summary.correct_percentage(), 66);
        assert_eq!(summary.incorrect_answers(), 1);
        assert_eq!(summary.elapsed_seconds(), 12);
        assert_eq!(summary.seconds_per_submitted_answer(), 4);
        assert!(summary.ended_early());
        assert_eq!(summary.time_limit_minutes(), None);
        assert!(!summary.timed_out());
    }

    #[test]
    fn exam_summary_can_record_timed_exam_timeout() {
        let question = ExamQuestion::new(DatumKey::for_card_id(1), "Prompt", "Madrid");
        let answers = vec![GradedExamAnswer::new(
            ExamAnswer::new(question, "Madrid"),
            true,
        )];

        let summary = ExamSummary::from_answers_with_timing(4, &answers, 120, Some(2), true);

        assert_eq!(summary.time_limit_minutes(), Some(2));
        assert!(summary.timed_out());
        assert!(summary.ended_early());
    }
}
