use crate::runtime::shuffle_indices;
use crate::study_item::StudyItem;
use cosimo::DatumKey;
use std::collections::HashSet;

pub(crate) const QUIZ_ANSWER_CHOICE_COUNT: usize = 4;

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

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

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

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

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

impl QuizSizeModeSelection {
    pub(crate) fn new(index: u32) -> Option<Self> {
        (index < QuizSizeMode::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 QuizStudyDirection {
    Both,
    StandardOnly,
    ReverseOnly,
}

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

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

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

    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 QuizStudyDirectionSelection {
    index: u32,
}

impl QuizStudyDirectionSelection {
    pub(crate) fn new(index: u32) -> Option<Self> {
        (index < QuizStudyDirection::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 QuizSizeRequest {
    mode: QuizSizeMode,
    value: usize,
    direction: QuizStudyDirection,
}

impl QuizSizeRequest {
    #[cfg(test)]
    pub(crate) fn new(mode: QuizSizeMode, value: usize) -> Self {
        Self {
            mode,
            value,
            direction: QuizStudyDirection::Both,
        }
    }

    pub(crate) fn with_direction(
        mode: QuizSizeMode,
        value: usize,
        direction: QuizStudyDirection,
    ) -> Self {
        Self {
            mode,
            value,
            direction,
        }
    }

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

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

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

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

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

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

impl QuizPass {
    pub(crate) fn new(started_at_unix_seconds: i64, questions: Vec<QuizQuestion>) -> 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) -> &[QuizQuestion] {
        &self.questions
    }
}

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

impl QuizChoiceIndex {
    pub(crate) fn new(index: usize) -> Self {
        Self { index }
    }

    pub(crate) fn from_wx_selection(selection: Option<u32>) -> Option<Self> {
        selection.map(|index| Self::new(index as usize))
    }

    pub(crate) fn from_wx_event_selection(selection: Option<i32>) -> Option<Self> {
        selection
            .and_then(|index| u32::try_from(index).ok())
            .map(|index| Self::new(index as usize))
    }

    pub(crate) fn from_one_based_shortcut_digit(digit: u8) -> Option<Self> {
        let index = usize::from(digit.checked_sub(1)?);
        (index < QUIZ_ANSWER_CHOICE_COUNT).then_some(Self::new(index))
    }

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

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

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct QuizQuestion {
    datum_key: DatumKey,
    family_key: DatumKey,
    prompt: String,
    correct_response: String,
    choices: Vec<QuizChoice>,
    correct_choice_index: QuizChoiceIndex,
}

impl QuizQuestion {
    pub(crate) fn new(
        datum_key: DatumKey,
        family_key: DatumKey,
        prompt: impl Into<String>,
        correct_response: impl Into<String>,
        choices: Vec<QuizChoice>,
        correct_choice_index: QuizChoiceIndex,
    ) -> Self {
        Self {
            datum_key,
            family_key,
            prompt: prompt.into(),
            correct_response: correct_response.into(),
            choices,
            correct_choice_index,
        }
    }

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

    #[cfg(test)]
    pub(crate) fn family_key(&self) -> &DatumKey {
        &self.family_key
    }

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

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

    pub(crate) fn choices(&self) -> &[QuizChoice] {
        &self.choices
    }

    pub(crate) fn correct_choice_index(&self) -> QuizChoiceIndex {
        self.correct_choice_index
    }
}

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

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

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

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

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct QuizAnswer {
    question: QuizQuestion,
    selected_choice_index: QuizChoiceIndex,
}

impl QuizAnswer {
    pub(crate) fn new(question: QuizQuestion, selected_choice_index: QuizChoiceIndex) -> Self {
        Self {
            question,
            selected_choice_index,
        }
    }

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

    pub(crate) fn selected_response(&self) -> Option<&str> {
        self.question
            .choices()
            .get(self.selected_choice_index.index_usize())
            .map(QuizChoice::response)
    }

    pub(crate) fn is_correct(&self) -> bool {
        self.selected_choice_index == self.question.correct_choice_index()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct QuizSummary {
    total_questions: usize,
    submitted_answers: usize,
    correct_answers: usize,
    elapsed_seconds: u64,
    ended_early: bool,
}

impl QuizSummary {
    pub(crate) fn from_answers(
        total_questions: usize,
        answers: &[QuizAnswer],
        elapsed_seconds: u64,
    ) -> Self {
        let submitted_answers = answers.len();
        let correct_answers = answers.iter().filter(|answer| answer.is_correct()).count();
        Self {
            total_questions,
            submitted_answers,
            correct_answers,
            elapsed_seconds,
            ended_early: submitted_answers < total_questions,
        }
    }

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

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

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

    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.submitted_answers.saturating_sub(self.correct_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
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum QuizBuildError {
    NoActiveCards,
    NotEnoughDistinctAnswerChoices { distinct_answer_choices: usize },
}

pub(crate) fn build_quiz_pass(
    items: &[StudyItem],
    active_indices: Vec<usize>,
    request: QuizSizeRequest,
    seed: u64,
    started_at_unix_seconds: i64,
) -> Result<(QuizPass, Vec<usize>), QuizBuildError> {
    if active_indices.is_empty() {
        return Err(QuizBuildError::NoActiveCards);
    }

    let candidates = quiz_candidates(items, &active_indices);
    let candidates = quiz_candidates_for_direction(candidates, request.direction());
    let distinct_answer_choice_count = max_distinct_answer_choices_for_one_direction(&candidates);
    if distinct_answer_choice_count < QUIZ_ANSWER_CHOICE_COUNT {
        return Err(QuizBuildError::NotEnoughDistinctAnswerChoices {
            distinct_answer_choices: distinct_answer_choice_count,
        });
    }

    let candidates = candidates_with_enough_same_direction_choices(candidates);
    let question_count = resolve_quiz_question_count(request, candidates.len());
    let selected_indices = select_quiz_candidate_indices(&candidates, question_count, seed);
    let questions: Vec<_> = selected_indices
        .iter()
        .filter_map(|candidate_index| {
            build_quiz_question(
                &candidates[*candidate_index],
                &candidates,
                seed ^ ((*candidate_index as u64) << 16),
            )
        })
        .collect();

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

    let session_queue = selected_indices
        .into_iter()
        .filter_map(|candidate_index| {
            candidates
                .get(candidate_index)
                .map(|candidate| candidate.index)
        })
        .collect();

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

#[derive(Debug, Clone)]
struct QuizCandidate {
    index: usize,
    datum_key: DatumKey,
    family_key: DatumKey,
    prompt: String,
    response: String,
    is_reverse: bool,
}

fn quiz_candidates(items: &[StudyItem], active_indices: &[usize]) -> Vec<QuizCandidate> {
    active_indices
        .iter()
        .filter_map(|index| {
            let item = items.get(*index)?;
            Some(QuizCandidate {
                index: *index,
                datum_key: item.datum_key().clone(),
                family_key: item.parent_datum_key(),
                prompt: item.datum().prompt().to_string(),
                response: item.datum().response().to_string(),
                is_reverse: item.is_reverse(),
            })
        })
        .collect()
}

fn quiz_candidates_for_direction(
    candidates: Vec<QuizCandidate>,
    direction: QuizStudyDirection,
) -> Vec<QuizCandidate> {
    candidates
        .into_iter()
        .filter(|candidate| direction.includes(candidate.is_reverse))
        .collect()
}

fn max_distinct_answer_choices_for_one_direction(candidates: &[QuizCandidate]) -> usize {
    usize::max(
        count_distinct_answer_choices_for_direction(candidates, false),
        count_distinct_answer_choices_for_direction(candidates, true),
    )
}

fn count_distinct_answer_choices_for_direction(
    candidates: &[QuizCandidate],
    is_reverse: bool,
) -> usize {
    candidates
        .iter()
        .filter(|candidate| candidate.is_reverse == is_reverse)
        .map(|candidate| candidate.response.as_str())
        .collect::<HashSet<_>>()
        .len()
}

fn candidates_with_enough_same_direction_choices(
    candidates: Vec<QuizCandidate>,
) -> Vec<QuizCandidate> {
    let forward_count = count_distinct_answer_choices_for_direction(&candidates, false);
    let reverse_count = count_distinct_answer_choices_for_direction(&candidates, true);
    candidates
        .into_iter()
        .filter(|candidate| {
            if candidate.is_reverse {
                reverse_count >= QUIZ_ANSWER_CHOICE_COUNT
            } else {
                forward_count >= QUIZ_ANSWER_CHOICE_COUNT
            }
        })
        .collect()
}

fn select_quiz_candidate_indices(
    candidates: &[QuizCandidate],
    question_count: usize,
    seed: u64,
) -> Vec<usize> {
    let mut candidate_indices: Vec<_> = (0..candidates.len()).collect();
    shuffle_indices(&mut candidate_indices, seed);

    let mut seen_families = HashSet::new();
    let mut selected = Vec::new();
    let mut deferred_same_family = Vec::new();

    for index in candidate_indices {
        if seen_families.insert(candidates[index].family_key.clone()) {
            selected.push(index);
        } else {
            deferred_same_family.push(index);
        }
    }

    if selected.len() >= question_count {
        selected.truncate(question_count);
        return selected;
    }

    shuffle_indices(&mut deferred_same_family, seed ^ 0x5155_5155_5155_5155);
    selected.extend(deferred_same_family);
    selected.truncate(question_count);
    selected
}

fn build_quiz_question(
    candidate: &QuizCandidate,
    candidates: &[QuizCandidate],
    seed: u64,
) -> Option<QuizQuestion> {
    let distractors =
        select_distractors(&candidate.response, candidate.is_reverse, candidates, seed)?;
    let mut choices = distractors;
    choices.push(QuizChoice::new(
        candidate.datum_key.clone(),
        candidate.response.clone(),
    ));
    shuffle_values(&mut choices, seed ^ 0x7175_697a_7175_697a);
    let correct_choice_index = choices
        .iter()
        .position(|choice| choice.response() == candidate.response)
        .expect("correct response inserted into quiz choices");

    Some(QuizQuestion::new(
        candidate.datum_key.clone(),
        candidate.family_key.clone(),
        candidate.prompt.clone(),
        candidate.response.clone(),
        choices,
        QuizChoiceIndex::new(correct_choice_index),
    ))
}

fn select_distractors(
    correct_response: &str,
    is_reverse: bool,
    candidates: &[QuizCandidate],
    seed: u64,
) -> Option<Vec<QuizChoice>> {
    let mut unique_responses = Vec::new();
    let mut seen = HashSet::new();
    for candidate in candidates {
        if candidate.is_reverse != is_reverse {
            continue;
        }
        if candidate.response == correct_response {
            continue;
        }
        if seen.insert(candidate.response.as_str()) {
            unique_responses.push(QuizChoice::new(
                candidate.datum_key.clone(),
                candidate.response.clone(),
            ));
        }
    }
    let distractor_count = QUIZ_ANSWER_CHOICE_COUNT - 1;
    if unique_responses.len() < distractor_count {
        return None;
    }

    let mut scored: Vec<_> = unique_responses
        .into_iter()
        .map(|choice| {
            let distance = normalized_levenshtein_distance(correct_response, choice.response());
            (choice, distance)
        })
        .collect();
    scored.sort_by(|left, right| {
        left.1
            .partial_cmp(&right.1)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| left.0.response().cmp(right.0.response()))
    });

    let split = scored.len().div_ceil(2);
    let mut near: Vec<_> = scored[..split]
        .iter()
        .map(|(choice, _)| choice.clone())
        .collect();
    let mut far: Vec<_> = scored[split..]
        .iter()
        .map(|(choice, _)| choice.clone())
        .collect();
    shuffle_values(&mut near, seed ^ 0x6e65_6172);
    shuffle_values(&mut far, seed ^ 0x6661_7221);

    let mut selected = Vec::new();
    if let Some(choice) = near.pop() {
        selected.push(choice);
    }
    if let Some(choice) = far.pop() {
        selected.push(choice);
    }

    let mut remaining: Vec<_> = scored
        .into_iter()
        .map(|(choice, _)| choice)
        .filter(|choice| {
            !selected
                .iter()
                .any(|selected_choice: &QuizChoice| selected_choice.response() == choice.response())
        })
        .collect();
    shuffle_values(&mut remaining, seed ^ 0x6669_6c6c);
    for choice in remaining {
        if selected.len() == distractor_count {
            break;
        }
        selected.push(choice);
    }

    (selected.len() == distractor_count).then_some(selected)
}

fn normalized_levenshtein_distance(left: &str, right: &str) -> f64 {
    let left: Vec<_> = left.to_lowercase().chars().collect();
    let right: Vec<_> = right.to_lowercase().chars().collect();
    let denominator = usize::max(left.len(), right.len()).max(1) as f64;
    levenshtein_distance(&left, &right) as f64 / denominator
}

fn levenshtein_distance(left: &[char], right: &[char]) -> usize {
    if left.is_empty() {
        return right.len();
    }
    if right.is_empty() {
        return left.len();
    }

    let mut previous: Vec<usize> = (0..=right.len()).collect();
    let mut current = vec![0; right.len() + 1];
    for (left_index, left_char) in left.iter().enumerate() {
        current[0] = left_index + 1;
        for (right_index, right_char) in right.iter().enumerate() {
            let deletion = previous[right_index + 1] + 1;
            let insertion = current[right_index] + 1;
            let substitution = previous[right_index] + usize::from(left_char != right_char);
            current[right_index + 1] = deletion.min(insertion).min(substitution);
        }
        std::mem::swap(&mut previous, &mut current);
    }
    previous[right.len()]
}

fn shuffle_values<T: Clone>(values: &mut [T], seed: u64) {
    let mut indices: Vec<_> = (0..values.len()).collect();
    shuffle_indices(&mut indices, seed);
    let original = values.to_vec();
    for (target, source) in indices.into_iter().enumerate() {
        values[target] = original[source].clone();
    }
}

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

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

    fn items(cards: &[Datum], reversible: &[u64]) -> Vec<StudyItem> {
        crate::study_item::study_items_for_deck(
            cards,
            &reversible
                .iter()
                .copied()
                .map(DatumKey::for_card_id)
                .collect::<HashSet<_>>(),
        )
    }

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

    #[test]
    fn quiz_question_count_uses_count_or_percentage() {
        assert_eq!(
            resolve_quiz_question_count(QuizSizeRequest::new(QuizSizeMode::QuestionCount, 10), 4),
            4
        );
        assert_eq!(
            resolve_quiz_question_count(QuizSizeRequest::new(QuizSizeMode::QuestionCount, 0), 4),
            1
        );
        assert_eq!(
            resolve_quiz_question_count(QuizSizeRequest::new(QuizSizeMode::Percentage, 50), 5),
            3
        );
    }

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

    #[test]
    fn quiz_choice_index_converts_widget_selections() {
        assert_eq!(QuizChoiceIndex::from_wx_selection(None), None);
        assert_eq!(QuizChoiceIndex::from_wx_event_selection(Some(-1)), None);
        assert_eq!(
            QuizChoiceIndex::from_wx_selection(Some(2)),
            Some(QuizChoiceIndex::new(2))
        );
        assert_eq!(
            QuizChoiceIndex::from_wx_event_selection(Some(3)),
            Some(QuizChoiceIndex::new(3))
        );
        assert_eq!(
            QuizChoiceIndex::from_one_based_shortcut_digit(1),
            Some(QuizChoiceIndex::new(0))
        );
        assert_eq!(
            QuizChoiceIndex::from_one_based_shortcut_digit(QUIZ_ANSWER_CHOICE_COUNT as u8),
            Some(QuizChoiceIndex::new(QUIZ_ANSWER_CHOICE_COUNT - 1))
        );
        assert_eq!(QuizChoiceIndex::from_one_based_shortcut_digit(0), None);
        assert_eq!(
            QuizChoiceIndex::from_one_based_shortcut_digit(QUIZ_ANSWER_CHOICE_COUNT as u8 + 1),
            None
        );
    }

    #[test]
    fn quiz_can_be_limited_to_standard_or_reverse_direction() {
        let cards = vec![
            datum(1, "one", "uno"),
            datum(2, "two", "dos"),
            datum(3, "three", "tres"),
            datum(4, "four", "cuatro"),
        ];
        let items = items(&cards, &[1, 2, 3, 4]);
        let active_indices: Vec<_> = (0..items.len()).collect();

        let (standard_quiz, _) = build_quiz_pass(
            &items,
            active_indices.clone(),
            QuizSizeRequest::with_direction(
                QuizSizeMode::Percentage,
                100,
                QuizStudyDirection::StandardOnly,
            ),
            123,
            1_800_000_000,
        )
        .expect("standard quiz");
        let (reverse_quiz, _) = build_quiz_pass(
            &items,
            active_indices,
            QuizSizeRequest::with_direction(
                QuizSizeMode::Percentage,
                100,
                QuizStudyDirection::ReverseOnly,
            ),
            123,
            1_800_000_000,
        )
        .expect("reverse quiz");

        assert_eq!(standard_quiz.questions().len(), 4);
        assert!(
            standard_quiz
                .questions()
                .iter()
                .all(|question| !question.datum_key().is_reverse())
        );
        assert_eq!(reverse_quiz.questions().len(), 4);
        assert!(
            reverse_quiz
                .questions()
                .iter()
                .all(|question| question.datum_key().is_reverse())
        );
    }

    #[test]
    fn quiz_sampling_avoids_both_directions_when_possible() {
        let cards = vec![
            datum(1, "one", "uno"),
            datum(2, "two", "dos"),
            datum(3, "three", "tres"),
            datum(4, "four", "cuatro"),
        ];
        let items = items(&cards, &[1, 2]);
        let active_indices: Vec<_> = (0..items.len()).collect();

        let (quiz, _) = build_quiz_pass(
            &items,
            active_indices,
            QuizSizeRequest::new(QuizSizeMode::QuestionCount, 4),
            123,
            1_800_000_000,
        )
        .expect("quiz");

        let families: HashSet<_> = quiz
            .questions()
            .iter()
            .map(|question| question.family_key().clone())
            .collect();
        assert_eq!(quiz.questions().len(), 4);
        assert_eq!(families.len(), 4);
    }

    #[test]
    fn quiz_sampling_allows_both_directions_when_coverage_requires_it() {
        let cards = vec![
            datum(1, "one", "uno"),
            datum(2, "two", "dos"),
            datum(3, "three", "tres"),
            datum(4, "four", "cuatro"),
        ];
        let items = items(&cards, &[1, 2, 3, 4]);
        let active_indices: Vec<_> = (0..items.len()).collect();

        let (quiz, _) = build_quiz_pass(
            &items,
            active_indices,
            QuizSizeRequest::new(QuizSizeMode::Percentage, 100),
            123,
            1_800_000_000,
        )
        .expect("quiz");

        assert_eq!(quiz.questions().len(), 8);
        let family_one_count = quiz
            .questions()
            .iter()
            .filter(|question| question.family_key() == &DatumKey::for_card_id(1))
            .count();
        assert_eq!(family_one_count, 2);
    }

    #[test]
    fn quiz_distractors_come_from_the_same_study_direction() {
        let cards = vec![
            datum(1, "one", "uno"),
            datum(2, "two", "dos"),
            datum(3, "three", "tres"),
            datum(4, "four", "cuatro"),
        ];
        let items = items(&cards, &[1, 2, 3, 4]);
        let active_indices: Vec<_> = (0..items.len()).collect();

        let (quiz, _) = build_quiz_pass(
            &items,
            active_indices,
            QuizSizeRequest::new(QuizSizeMode::Percentage, 100),
            123,
            1_800_000_000,
        )
        .expect("quiz");

        let prompts: HashSet<_> = cards.iter().map(Datum::prompt).collect();
        let responses: HashSet<_> = cards.iter().map(Datum::response).collect();
        for question in quiz.questions() {
            let answer_side = if prompts.contains(question.prompt()) {
                &responses
            } else {
                assert!(responses.contains(question.prompt()));
                &prompts
            };
            assert!(
                question
                    .choices()
                    .iter()
                    .all(|choice| answer_side.contains(choice.response())),
                "choices for prompt {:?} crossed study directions: {:?}",
                question.prompt(),
                question.choices()
            );
        }
    }

    #[test]
    fn reverse_quiz_choices_exclude_non_reversible_card_prompts() {
        let cards = vec![
            datum(1, "a", "b").with_reversal_prohibited(true),
            datum(2, "c", "d"),
            datum(3, "e", "f"),
            datum(4, "g", "h"),
            datum(5, "i", "j"),
        ];
        let items = items(&cards, &[1, 2, 3, 4, 5]);
        let active_indices: Vec<_> = (0..items.len()).collect();

        let (quiz, _) = build_quiz_pass(
            &items,
            active_indices,
            QuizSizeRequest::new(QuizSizeMode::Percentage, 100),
            123,
            1_800_000_000,
        )
        .expect("quiz");

        let reverse_questions: Vec<_> = quiz
            .questions()
            .iter()
            .filter(|question| question.datum_key().is_reverse())
            .collect();
        assert_eq!(reverse_questions.len(), 4);
        assert!(
            reverse_questions
                .iter()
                .flat_map(|question| question.choices())
                .all(|choice| choice.response() != "a"),
            "reverse choices must not use prompts from non-reversible cards"
        );
        assert!(
            quiz.questions().iter().all(|question| question.datum_key()
                != &DatumKey::for_reverse_of(&DatumKey::for_card_id(1))),
            "non-reversible cards must not appear as reverse quiz questions"
        );
    }

    #[test]
    fn quiz_skips_directions_without_enough_distinct_choices() {
        let cards = vec![
            datum(1, "one", "uno"),
            datum(2, "two", "dos"),
            datum(3, "three", "tres"),
            datum(4, "four", "cuatro"),
        ];
        let items = items(&cards, &[1]);
        let active_indices: Vec<_> = (0..items.len()).collect();

        let (quiz, _) = build_quiz_pass(
            &items,
            active_indices,
            QuizSizeRequest::new(QuizSizeMode::Percentage, 100),
            123,
            1_800_000_000,
        )
        .expect("quiz");

        assert_eq!(quiz.questions().len(), 4);
        assert!(
            quiz.questions()
                .iter()
                .all(|question| !question.datum_key().is_reverse())
        );
    }

    #[test]
    fn quiz_questions_have_one_correct_choice_and_three_distractors() {
        let cards = vec![
            datum(1, "one", "uno"),
            datum(2, "two", "dos"),
            datum(3, "three", "tres"),
            datum(4, "four", "cuatro"),
        ];
        let items = items(&cards, &[]);
        let active_indices: Vec<_> = (0..items.len()).collect();

        let (quiz, _) = build_quiz_pass(
            &items,
            active_indices,
            QuizSizeRequest::new(QuizSizeMode::QuestionCount, 1),
            123,
            1_800_000_000,
        )
        .expect("quiz");
        let question = &quiz.questions()[0];

        assert_eq!(question.choices().len(), 4);
        assert_eq!(
            question.choices()[question.correct_choice_index().index_usize()].response(),
            question.correct_response()
        );
        let distinct_choices: HashSet<_> = question
            .choices()
            .iter()
            .map(QuizChoice::response)
            .collect();
        assert_eq!(distinct_choices.len(), 4);
    }

    #[test]
    fn quiz_choices_keep_source_datum_keys() {
        let cards = vec![
            datum(1, "one", "uno"),
            datum(2, "two", "dos"),
            datum(3, "three", "tres"),
            datum(4, "four", "cuatro"),
        ];
        let items = items(&cards, &[1, 2, 3, 4]);
        let active_indices: Vec<_> = (0..items.len()).collect();

        let (quiz, _) = build_quiz_pass(
            &items,
            active_indices,
            QuizSizeRequest::new(QuizSizeMode::Percentage, 100),
            123,
            1_800_000_000,
        )
        .expect("quiz");

        for question in quiz.questions() {
            assert!(
                question
                    .choices()
                    .iter()
                    .all(|choice| choice.datum_key().is_reverse()
                        == question.datum_key().is_reverse()),
                "choice keys should use the same study direction as the question"
            );
        }
    }

    #[test]
    fn quiz_requires_four_distinct_answer_choices() {
        let cards = vec![
            datum(1, "one", "same"),
            datum(2, "two", "same"),
            datum(3, "three", "other"),
        ];
        let items = items(&cards, &[]);
        let active_indices: Vec<_> = (0..items.len()).collect();

        assert_eq!(
            build_quiz_pass(
                &items,
                active_indices,
                QuizSizeRequest::new(QuizSizeMode::QuestionCount, 1),
                123,
                1_800_000_000,
            ),
            Err(QuizBuildError::NotEnoughDistinctAnswerChoices {
                distinct_answer_choices: 2
            })
        );
    }

    #[test]
    fn quiz_answers_and_summary_count_correct_choices_without_scheduler_feedback() {
        let question = QuizQuestion::new(
            DatumKey::for_card_id(1),
            DatumKey::for_card_id(1),
            "one",
            "uno",
            vec![
                QuizChoice::new(DatumKey::for_card_id(2), "dos"),
                QuizChoice::new(DatumKey::for_card_id(1), "uno"),
                QuizChoice::new(DatumKey::for_card_id(3), "tres"),
            ],
            QuizChoiceIndex::new(1),
        );

        let correct = QuizAnswer::new(question.clone(), QuizChoiceIndex::new(1));
        let incorrect = QuizAnswer::new(question, QuizChoiceIndex::new(0));
        let summary = QuizSummary::from_answers(3, &[correct.clone(), incorrect], 12);

        assert!(correct.is_correct());
        assert_eq!(summary.total_questions(), 3);
        assert_eq!(summary.submitted_answers(), 2);
        assert_eq!(summary.correct_answers(), 1);
        assert_eq!(summary.incorrect_answers(), 1);
        assert_eq!(summary.correct_percentage(), 50);
        assert_eq!(summary.seconds_per_submitted_answer(), 6);
        assert!(summary.ended_early());
    }
}
