use crate::{Datum, DatumKey, RecallFeedback, Review};
use std::collections::HashMap;

const DAY_IN_SECONDS: i64 = 24 * 60 * 60;
const LEARNING_STEP_SECONDS: i64 = 10 * 60;
const NEAR_TIE_DUE_WINDOW_SECONDS: i64 = 60 * 60;
const DESIRED_RETENTION: f64 = 0.9;
const MINIMUM_STABILITY: f64 = 0.1;
const MAXIMUM_STABILITY: f64 = 365.0;
const FSRS_PARAMETERS: [f64; 17] = [
    0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14, 0.94, 2.18, 0.05, 0.34, 1.26, 0.29,
    2.61,
];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SchedulerConfig {
    minimum_cards_per_session: usize,
    now_unix_seconds: i64,
    filler_seed: u64,
}

impl SchedulerConfig {
    pub const fn new(
        minimum_cards_per_session: usize,
        now_unix_seconds: i64,
        filler_seed: u64,
    ) -> Self {
        Self {
            minimum_cards_per_session,
            now_unix_seconds,
            filler_seed,
        }
    }

    pub const fn minimum_cards_per_session(self) -> usize {
        self.minimum_cards_per_session
    }

    pub const fn now_unix_seconds(self) -> i64 {
        self.now_unix_seconds
    }

    pub const fn filler_seed(self) -> u64 {
        self.filler_seed
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduledDatum {
    datum_key: DatumKey,
    due_at_unix_seconds: i64,
    interval_seconds: i64,
    last_reviewed_at_unix_seconds: Option<i64>,
    last_recall_feedback: Option<RecallFeedback>,
    is_due: bool,
}

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

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

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

    pub fn last_reviewed_at_unix_seconds(&self) -> Option<i64> {
        self.last_reviewed_at_unix_seconds
    }

    pub fn last_recall_feedback(&self) -> Option<RecallFeedback> {
        self.last_recall_feedback
    }

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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CardMemoryState {
    New,
    Learning,
    Review,
    Relearning,
}

impl CardMemoryState {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::New => "new",
            Self::Learning => "learning",
            Self::Review => "review",
            Self::Relearning => "relearning",
        }
    }

    pub fn from_storage(value: &str) -> Option<Self> {
        match value {
            "new" => Some(Self::New),
            "learning" => Some(Self::Learning),
            "review" => Some(Self::Review),
            "relearning" => Some(Self::Relearning),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct CardSchedule {
    datum_key: DatumKey,
    memory_state: CardMemoryState,
    due_at_unix_seconds: i64,
    stability_days: f64,
    difficulty: f64,
    elapsed_days: u32,
    scheduled_days: u32,
    interval_seconds: i64,
    repetitions: u32,
    lapses: u32,
    last_reviewed_at_unix_seconds: Option<i64>,
    last_recall_feedback: Option<RecallFeedback>,
}

impl CardSchedule {
    pub fn new(datum_key: DatumKey) -> Self {
        Self {
            datum_key,
            memory_state: CardMemoryState::New,
            due_at_unix_seconds: 0,
            stability_days: 0.0,
            difficulty: 0.0,
            elapsed_days: 0,
            scheduled_days: 0,
            interval_seconds: 0,
            repetitions: 0,
            lapses: 0,
            last_reviewed_at_unix_seconds: None,
            last_recall_feedback: None,
        }
    }

    #[allow(clippy::too_many_arguments)]
    pub fn from_storage(
        datum_key: DatumKey,
        memory_state: CardMemoryState,
        due_at_unix_seconds: i64,
        stability_days: f64,
        difficulty: f64,
        elapsed_days: u32,
        scheduled_days: u32,
        interval_seconds: i64,
        repetitions: u32,
        lapses: u32,
        last_reviewed_at_unix_seconds: Option<i64>,
        last_recall_feedback: Option<RecallFeedback>,
    ) -> Self {
        Self {
            datum_key,
            memory_state,
            due_at_unix_seconds,
            stability_days,
            difficulty,
            elapsed_days,
            scheduled_days,
            interval_seconds,
            repetitions,
            lapses,
            last_reviewed_at_unix_seconds,
            last_recall_feedback,
        }
    }

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

    pub fn memory_state(&self) -> CardMemoryState {
        self.memory_state
    }

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

    pub fn stability_days(&self) -> f64 {
        self.stability_days
    }

    pub fn difficulty(&self) -> f64 {
        self.difficulty
    }

    pub fn elapsed_days(&self) -> u32 {
        self.elapsed_days
    }

    pub fn scheduled_days(&self) -> u32 {
        self.scheduled_days
    }

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

    pub fn repetitions(&self) -> u32 {
        self.repetitions
    }

    pub fn lapses(&self) -> u32 {
        self.lapses
    }

    pub fn last_reviewed_at_unix_seconds(&self) -> Option<i64> {
        self.last_reviewed_at_unix_seconds
    }

    pub fn last_recall_feedback(&self) -> Option<RecallFeedback> {
        self.last_recall_feedback
    }

    pub fn reviewed(&self, review: &Review, reviewed_at_unix_seconds: i64) -> Self {
        let rating = fsrs_rating(review);
        let elapsed_days = self
            .last_reviewed_at_unix_seconds
            .map(|last_reviewed_at| elapsed_days(last_reviewed_at, reviewed_at_unix_seconds))
            .unwrap_or(0);

        let (difficulty, stability_days, memory_state, lapses) = if self.memory_state
            == CardMemoryState::New
            || self.stability_days <= 0.0
        {
            (
                initial_difficulty(rating),
                initial_stability(rating),
                if rating == FsrsRating::Again {
                    CardMemoryState::Learning
                } else {
                    CardMemoryState::Review
                },
                if rating == FsrsRating::Again { 1 } else { 0 },
            )
        } else {
            let retrievability = retrievability(elapsed_days, self.stability_days);
            let next_difficulty = next_difficulty(self.difficulty, rating);
            if rating == FsrsRating::Again {
                (
                    next_difficulty,
                    forgetting_stability(next_difficulty, self.stability_days, retrievability),
                    CardMemoryState::Relearning,
                    self.lapses + 1,
                )
            } else {
                (
                    next_difficulty,
                    recall_stability(next_difficulty, self.stability_days, retrievability, rating),
                    CardMemoryState::Review,
                    self.lapses,
                )
            }
        };

        let scheduled_days = scheduled_days(stability_days);
        let interval_seconds = if rating == FsrsRating::Again {
            LEARNING_STEP_SECONDS
        } else {
            interval_seconds_from_days(scheduled_days)
        };
        Self {
            datum_key: self.datum_key.clone(),
            memory_state,
            due_at_unix_seconds: reviewed_at_unix_seconds + interval_seconds,
            stability_days,
            difficulty,
            elapsed_days,
            scheduled_days,
            interval_seconds,
            repetitions: self.repetitions + 1,
            lapses,
            last_reviewed_at_unix_seconds: Some(reviewed_at_unix_seconds),
            last_recall_feedback: Some(review.recall_feedback()),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScheduledSelectionKind {
    Due,
    MinimumFill,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduledCard {
    deck_index: usize,
    scheduled: ScheduledDatum,
    selection_kind: ScheduledSelectionKind,
}

impl ScheduledCard {
    pub fn deck_index(&self) -> usize {
        self.deck_index
    }

    pub fn scheduled(&self) -> &ScheduledDatum {
        &self.scheduled
    }

    pub fn selection_kind(&self) -> ScheduledSelectionKind {
        self.selection_kind
    }
}

pub fn schedule_datum(
    datum: &Datum,
    schedule: Option<&CardSchedule>,
    now_unix_seconds: i64,
) -> ScheduledDatum {
    schedule_datum_for_key(DatumKey::for_datum(datum), schedule, now_unix_seconds)
}

pub fn schedule_datum_for_key(
    datum_key: DatumKey,
    schedule: Option<&CardSchedule>,
    now_unix_seconds: i64,
) -> ScheduledDatum {
    let due_at_unix_seconds = schedule.map(CardSchedule::due_at_unix_seconds).unwrap_or(0);
    let interval_seconds = schedule.map(CardSchedule::interval_seconds).unwrap_or(0);
    let last_reviewed_at_unix_seconds =
        schedule.and_then(CardSchedule::last_reviewed_at_unix_seconds);
    let last_recall_feedback = schedule.and_then(CardSchedule::last_recall_feedback);

    ScheduledDatum {
        datum_key,
        due_at_unix_seconds,
        interval_seconds,
        last_reviewed_at_unix_seconds,
        last_recall_feedback,
        is_due: schedule.is_none() || due_at_unix_seconds <= now_unix_seconds,
    }
}

pub fn schedule_session(
    data: &[Datum],
    schedules: &HashMap<DatumKey, CardSchedule>,
    config: SchedulerConfig,
) -> Vec<ScheduledCard> {
    let keyed_data = data
        .iter()
        .map(|datum| (DatumKey::for_datum(datum), datum))
        .collect::<Vec<_>>();
    schedule_session_for_keys(&keyed_data, schedules, config)
}

pub fn schedule_session_for_keys(
    data: &[(DatumKey, &Datum)],
    schedules: &HashMap<DatumKey, CardSchedule>,
    config: SchedulerConfig,
) -> Vec<ScheduledCard> {
    // Preserve scheduling priority without preserving deck order inside each
    // priority group: learning/relearning cards come first, overdue review
    // cards follow, new cards follow those, and optional minimum-fill cards
    // are last. Randomizing ties avoids teaching users cards in the thematic
    // clumps in which they were added.
    let mut learning_due_cards = Vec::new();
    let mut review_due_cards = Vec::new();
    let mut new_cards = Vec::new();
    let mut filler_cards = Vec::new();

    for (deck_index, (datum_key, _datum)) in data.iter().enumerate() {
        let schedule = schedules.get(datum_key);
        let scheduled =
            schedule_datum_for_key(datum_key.clone(), schedule, config.now_unix_seconds());
        match (schedule, scheduled.is_due()) {
            (Some(schedule), true)
                if matches!(
                    schedule.memory_state(),
                    CardMemoryState::Learning | CardMemoryState::Relearning
                ) =>
            {
                learning_due_cards.push(ScheduledCard {
                    deck_index,
                    scheduled,
                    selection_kind: ScheduledSelectionKind::Due,
                });
            }
            (Some(_), true) => review_due_cards.push(ScheduledCard {
                deck_index,
                scheduled,
                selection_kind: ScheduledSelectionKind::Due,
            }),
            (None, true) => new_cards.push(ScheduledCard {
                deck_index,
                scheduled,
                selection_kind: ScheduledSelectionKind::Due,
            }),
            (_, false) => filler_cards.push(ScheduledCard {
                deck_index,
                scheduled,
                selection_kind: ScheduledSelectionKind::MinimumFill,
            }),
        }
    }

    learning_due_cards.sort_by_key(due_priority_sort_key);
    randomize_near_tie_due_cards(&mut learning_due_cards, group_seed(config.filler_seed(), 1));
    review_due_cards.sort_by_key(due_priority_sort_key);
    randomize_near_tie_due_cards(&mut review_due_cards, group_seed(config.filler_seed(), 2));
    shuffle_cards(&mut new_cards, group_seed(config.filler_seed(), 3));

    let mut review_cards = learning_due_cards;
    review_cards.extend(review_due_cards);
    review_cards.extend(new_cards);

    let minimum_cards = config.minimum_cards_per_session();
    if review_cards.len() < minimum_cards {
        shuffle_cards(&mut filler_cards, group_seed(config.filler_seed(), 4));
        filler_cards.truncate(minimum_cards - review_cards.len());
        review_cards.extend(filler_cards);
    }

    review_cards
}

fn due_priority_sort_key(card: &ScheduledCard) -> i64 {
    card.scheduled.due_at_unix_seconds()
}

fn randomize_near_tie_due_cards(cards: &mut [ScheduledCard], seed: u64) {
    let mut group_start = 0;
    let mut group_index = 0;

    while group_start < cards.len() {
        let group_due_at = cards[group_start].scheduled.due_at_unix_seconds();
        let mut group_end = group_start + 1;
        while group_end < cards.len()
            && cards[group_end]
                .scheduled
                .due_at_unix_seconds()
                .saturating_sub(group_due_at)
                <= NEAR_TIE_DUE_WINDOW_SECONDS
        {
            group_end += 1;
        }

        shuffle_cards(
            &mut cards[group_start..group_end],
            group_seed(seed, group_index),
        );
        group_start = group_end;
        group_index += 1;
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FsrsRating {
    Again = 1,
    Hard = 2,
    Good = 3,
    Easy = 4,
}

fn fsrs_rating(review: &Review) -> FsrsRating {
    match review.recall_feedback() {
        RecallFeedback::Again => FsrsRating::Again,
        RecallFeedback::Hard => FsrsRating::Hard,
        RecallFeedback::Good => FsrsRating::Good,
        RecallFeedback::Easy => FsrsRating::Easy,
    }
}

fn initial_stability(rating: FsrsRating) -> f64 {
    FSRS_PARAMETERS[rating_index(rating)].max(MINIMUM_STABILITY)
}

fn initial_difficulty(rating: FsrsRating) -> f64 {
    let w = FSRS_PARAMETERS;
    clamp_difficulty(w[4] - (rating_value(rating) - 3.0) * w[5])
}

fn next_difficulty(difficulty: f64, rating: FsrsRating) -> f64 {
    let w = FSRS_PARAMETERS;
    let target = initial_difficulty(FsrsRating::Good);
    let next = difficulty - w[6] * (rating_value(rating) - 3.0);
    clamp_difficulty(w[7] * target + (1.0 - w[7]) * next)
}

fn retrievability(elapsed_days: u32, stability_days: f64) -> f64 {
    if elapsed_days == 0 || stability_days <= 0.0 {
        return 1.0;
    }

    (1.0 + f64::from(elapsed_days) / (9.0 * stability_days)).powf(-1.0)
}

fn recall_stability(
    difficulty: f64,
    stability_days: f64,
    retrievability: f64,
    rating: FsrsRating,
) -> f64 {
    let w = FSRS_PARAMETERS;
    let hard_multiplier = if rating == FsrsRating::Hard {
        w[15]
    } else {
        1.0
    };
    let easy_multiplier = if rating == FsrsRating::Easy {
        w[16]
    } else {
        1.0
    };
    let increase = w[8].exp()
        * (11.0 - difficulty)
        * stability_days.powf(-w[9])
        * ((w[10] * (1.0 - retrievability)).exp() - 1.0)
        * hard_multiplier
        * easy_multiplier;

    clamp_stability(stability_days * (increase.max(0.0) + 1.0))
}

fn forgetting_stability(difficulty: f64, stability_days: f64, retrievability: f64) -> f64 {
    let w = FSRS_PARAMETERS;
    let stability = w[11]
        * difficulty.powf(-w[12])
        * ((stability_days + 1.0).powf(w[13]) - 1.0)
        * (w[14] * (1.0 - retrievability)).exp();
    clamp_stability(stability)
}

fn scheduled_days(stability_days: f64) -> u32 {
    let interval = 9.0 * stability_days * (1.0 / DESIRED_RETENTION - 1.0);
    interval.round().clamp(1.0, MAXIMUM_STABILITY) as u32
}

fn interval_seconds_from_days(days: u32) -> i64 {
    i64::from(days) * DAY_IN_SECONDS
}

fn elapsed_days(previous_unix_seconds: i64, reviewed_at_unix_seconds: i64) -> u32 {
    let elapsed_seconds = reviewed_at_unix_seconds.saturating_sub(previous_unix_seconds);
    (elapsed_seconds / DAY_IN_SECONDS).max(0) as u32
}

fn rating_index(rating: FsrsRating) -> usize {
    rating as usize - 1
}

fn rating_value(rating: FsrsRating) -> f64 {
    rating as u8 as f64
}

fn clamp_difficulty(difficulty: f64) -> f64 {
    difficulty.clamp(1.0, 10.0)
}

fn clamp_stability(stability: f64) -> f64 {
    stability.clamp(MINIMUM_STABILITY, MAXIMUM_STABILITY)
}

fn shuffle_cards(cards: &mut [ScheduledCard], seed: u64) {
    if cards.len() < 2 {
        return;
    }

    let mut state = seed ^ ((cards.len() as u64) << 32);
    for index in (1..cards.len()).rev() {
        let random = splitmix64(&mut state);
        let swap_index = (random as usize) % (index + 1);
        cards.swap(index, swap_index);
    }
}

fn group_seed(seed: u64, group: u64) -> u64 {
    let mut state = seed ^ group.wrapping_mul(0x9e3779b97f4a7c15);
    splitmix64(&mut state)
}

fn splitmix64(state: &mut u64) -> u64 {
    *state = state.wrapping_add(0x9e3779b97f4a7c15);
    let mut value = *state;
    value = (value ^ (value >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
    value = (value ^ (value >> 27)).wrapping_mul(0x94d049bb133111eb);
    value ^ (value >> 31)
}

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

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

    fn schedule_after(
        datum: &Datum,
        review: Review,
        reviewed_at_unix_seconds: i64,
    ) -> CardSchedule {
        CardSchedule::new(DatumKey::for_datum(datum)).reviewed(&review, reviewed_at_unix_seconds)
    }

    fn stored_schedule(
        datum: &Datum,
        due_at_unix_seconds: i64,
        scheduled_days: u32,
    ) -> CardSchedule {
        stored_schedule_with_state(
            datum,
            CardMemoryState::Review,
            due_at_unix_seconds,
            scheduled_days,
        )
    }

    fn stored_schedule_with_state(
        datum: &Datum,
        memory_state: CardMemoryState,
        due_at_unix_seconds: i64,
        scheduled_days: u32,
    ) -> CardSchedule {
        CardSchedule::from_storage(
            DatumKey::for_datum(datum),
            memory_state,
            due_at_unix_seconds,
            scheduled_days.into(),
            5.0,
            scheduled_days,
            scheduled_days,
            interval_seconds_from_days(scheduled_days),
            1,
            0,
            Some(due_at_unix_seconds - i64::from(scheduled_days) * DAY_IN_SECONDS),
            Some(RecallFeedback::Easy),
        )
    }

    #[test]
    fn new_cards_are_due_immediately() {
        let datum = datum("Capital of Spain", "Madrid");

        let scheduled = schedule_datum(&datum, None, 1_000_000);

        assert!(scheduled.is_due());
        assert_eq!(scheduled.due_at_unix_seconds(), 0);
        assert_eq!(scheduled.interval_seconds(), 0);
        assert_eq!(scheduled.last_recall_feedback(), None);
    }

    #[test]
    fn correct_answer_pushes_due_date_into_the_future() {
        let datum = datum("Capital of Spain", "Madrid");
        let schedule = schedule_after(
            &datum,
            Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
            1_000_000,
        );

        let scheduled = schedule_datum(&datum, Some(&schedule), 1_000_000);

        assert!(!scheduled.is_due());
        assert_eq!(scheduled.interval_seconds(), 6 * DAY_IN_SECONDS);
        assert_eq!(
            scheduled.due_at_unix_seconds(),
            1_000_000 + 6 * DAY_IN_SECONDS
        );
        assert_eq!(scheduled.last_recall_feedback(), Some(RecallFeedback::Easy));
    }

    #[test]
    fn fsrs_rating_maps_cosimo_feedback_to_four_grades() {
        assert_eq!(
            fsrs_rating(&Review::new(
                PromptGuess::CertainlyKnow,
                RecallFeedback::Again
            )),
            FsrsRating::Again
        );
        assert_eq!(
            fsrs_rating(&Review::new(
                PromptGuess::ProbablyKnow,
                RecallFeedback::Hard
            )),
            FsrsRating::Hard
        );
        assert_eq!(
            fsrs_rating(&Review::new(
                PromptGuess::ProbablyKnow,
                RecallFeedback::Good
            )),
            FsrsRating::Good
        );
        assert_eq!(
            fsrs_rating(&Review::new(
                PromptGuess::ProbablyKnow,
                RecallFeedback::Easy
            )),
            FsrsRating::Easy
        );
    }

    #[test]
    fn first_fsrs_review_initializes_memory_state() {
        let datum = datum("Capital of Spain", "Madrid");
        let schedule = schedule_after(
            &datum,
            Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
            1_000_000,
        );

        assert_eq!(schedule.memory_state(), CardMemoryState::Review);
        assert_eq!(schedule.repetitions(), 1);
        assert_eq!(schedule.lapses(), 0);
        assert_eq!(schedule.scheduled_days(), 6);
        assert_eq!(schedule.interval_seconds(), 6 * DAY_IN_SECONDS);
        assert_eq!(schedule.last_recall_feedback(), Some(RecallFeedback::Easy));
        assert!(schedule.stability_days() > 0.0);
        assert!(schedule.difficulty() >= 1.0);
    }

    #[test]
    fn first_failed_review_starts_learning_with_a_short_interval() {
        let datum = datum("Capital of Spain", "Madrid");
        let reviewed_at = 1_000_000;
        let schedule = schedule_after(
            &datum,
            Review::new(PromptGuess::ProbablyDontKnow, RecallFeedback::Again),
            reviewed_at,
        );

        assert_eq!(schedule.memory_state(), CardMemoryState::Learning);
        assert_eq!(schedule.repetitions(), 1);
        assert_eq!(schedule.lapses(), 1);
        assert_eq!(schedule.scheduled_days(), 1);
        assert_eq!(schedule.interval_seconds(), LEARNING_STEP_SECONDS);
        assert_eq!(
            schedule.due_at_unix_seconds(),
            reviewed_at + LEARNING_STEP_SECONDS
        );
    }

    #[test]
    fn fsrs_success_after_delay_increases_stability() {
        let datum = datum("Capital of Spain", "Madrid");
        let first = schedule_after(
            &datum,
            Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
            1_000_000,
        );
        let second = first.reviewed(
            &Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
            1_000_000 + 10 * DAY_IN_SECONDS,
        );

        assert_eq!(second.memory_state(), CardMemoryState::Review);
        assert_eq!(second.elapsed_days(), 10);
        assert!(second.stability_days() > first.stability_days());
        assert!(second.scheduled_days() >= first.scheduled_days());
    }

    #[test]
    fn fsrs_failure_moves_card_to_relearning_and_counts_lapse() {
        let datum = datum("Capital of Spain", "Madrid");
        let first = schedule_after(
            &datum,
            Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
            1_000_000,
        );
        let failed = first.reviewed(
            &Review::new(PromptGuess::ProbablyDontKnow, RecallFeedback::Again),
            1_000_000 + 3 * DAY_IN_SECONDS,
        );

        assert_eq!(failed.memory_state(), CardMemoryState::Relearning);
        assert_eq!(failed.lapses(), 1);
        assert_eq!(failed.repetitions(), 2);
        assert_eq!(failed.interval_seconds(), LEARNING_STEP_SECONDS);
        assert_eq!(
            failed.due_at_unix_seconds(),
            1_000_000 + 3 * DAY_IN_SECONDS + LEARNING_STEP_SECONDS
        );
        assert_eq!(failed.last_recall_feedback(), Some(RecallFeedback::Again));
    }

    #[test]
    fn due_cards_are_ordered_before_minimum_fill_cards() {
        let due = datum("Due card", "Answer");
        let not_due = datum("Not due card", "Answer");
        let now = 2_000_000;
        let schedules = HashMap::from([(
            DatumKey::for_datum(&not_due),
            schedule_after(
                &not_due,
                Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                now,
            ),
        )]);

        let scheduled = schedule_session(
            &[due.clone(), not_due.clone()],
            &schedules,
            SchedulerConfig::new(2, now, 9),
        );

        assert_eq!(scheduled.len(), 2);
        assert_eq!(scheduled[0].deck_index(), 0);
        assert_eq!(scheduled[0].selection_kind(), ScheduledSelectionKind::Due);
        assert_eq!(scheduled[1].deck_index(), 1);
        assert_eq!(
            scheduled[1].selection_kind(),
            ScheduledSelectionKind::MinimumFill
        );
    }

    #[test]
    fn scheduled_due_cards_are_ordered_before_new_cards() {
        let new_first = datum("New first", "Answer");
        let overdue = datum("Overdue", "Answer");
        let new_last = datum("New last", "Answer");
        let now = 2_000_000;
        let schedules = HashMap::from([(
            DatumKey::for_datum(&overdue),
            stored_schedule(&overdue, now - 3 * DAY_IN_SECONDS, 1),
        )]);

        let scheduled = schedule_session(
            &[new_first, overdue, new_last],
            &schedules,
            SchedulerConfig::new(0, now, 11),
        );

        assert_eq!(scheduled.len(), 3);
        assert_eq!(scheduled[0].deck_index(), 1);
        assert_eq!(scheduled[0].selection_kind(), ScheduledSelectionKind::Due);
    }

    #[test]
    fn scheduled_due_cards_are_ordered_by_how_overdue_they_are() {
        let barely_overdue = datum("Barely overdue", "Answer");
        let most_overdue = datum("Most overdue", "Answer");
        let middle_overdue = datum("Middle overdue", "Answer");
        let now = 2_000_000;
        let schedules = HashMap::from([
            (
                DatumKey::for_datum(&barely_overdue),
                stored_schedule(&barely_overdue, now - DAY_IN_SECONDS, 1),
            ),
            (
                DatumKey::for_datum(&most_overdue),
                stored_schedule(&most_overdue, now - 5 * DAY_IN_SECONDS, 1),
            ),
            (
                DatumKey::for_datum(&middle_overdue),
                stored_schedule(&middle_overdue, now - 3 * DAY_IN_SECONDS, 1),
            ),
        ]);

        let scheduled = schedule_session(
            &[barely_overdue, most_overdue, middle_overdue],
            &schedules,
            SchedulerConfig::new(0, now, 13),
        );

        assert_eq!(
            scheduled
                .iter()
                .map(ScheduledCard::deck_index)
                .collect::<Vec<_>>(),
            vec![1, 2, 0]
        );
    }

    #[test]
    fn due_learning_and_relearning_cards_are_ordered_before_review_cards() {
        let old_review = datum("Old review", "Answer");
        let recent_learning = datum("Recent learning", "Answer");
        let recent_relearning = datum("Recent relearning", "Answer");
        let now = 2_000_000;
        let schedules = HashMap::from([
            (
                DatumKey::for_datum(&old_review),
                stored_schedule(&old_review, now - 5 * DAY_IN_SECONDS, 1),
            ),
            (
                DatumKey::for_datum(&recent_learning),
                stored_schedule_with_state(
                    &recent_learning,
                    CardMemoryState::Learning,
                    now - 60,
                    1,
                ),
            ),
            (
                DatumKey::for_datum(&recent_relearning),
                stored_schedule_with_state(
                    &recent_relearning,
                    CardMemoryState::Relearning,
                    now - 120,
                    1,
                ),
            ),
        ]);

        let scheduled = schedule_session(
            &[old_review, recent_learning, recent_relearning],
            &schedules,
            SchedulerConfig::new(0, now, 17),
        );

        let order = scheduled
            .iter()
            .map(ScheduledCard::deck_index)
            .collect::<Vec<_>>();
        assert!(order[..2].contains(&1));
        assert!(order[..2].contains(&2));
        assert_eq!(order[2], 0);
    }

    #[test]
    fn scheduled_due_ties_are_randomized_deterministically() {
        let data = [
            datum("One", "Answer"),
            datum("Two", "Answer"),
            datum("Three", "Answer"),
            datum("Four", "Answer"),
        ];
        let now = 2_000_000;
        let schedules: HashMap<DatumKey, CardSchedule> = data
            .iter()
            .map(|datum| {
                (
                    DatumKey::for_datum(datum),
                    stored_schedule(datum, now - 2 * DAY_IN_SECONDS, 1),
                )
            })
            .collect();

        let first = schedule_session(&data, &schedules, SchedulerConfig::new(0, now, 77));
        let second = schedule_session(&data, &schedules, SchedulerConfig::new(0, now, 77));
        let first_order = first
            .iter()
            .map(ScheduledCard::deck_index)
            .collect::<Vec<_>>();

        assert_eq!(
            first_order,
            second
                .iter()
                .map(ScheduledCard::deck_index)
                .collect::<Vec<_>>()
        );
        assert_ne!(first_order, vec![0, 1, 2, 3]);
    }

    #[test]
    fn scheduled_due_cards_within_one_hour_are_randomized_deterministically() {
        let data = [
            datum("One", "Answer"),
            datum("Two", "Answer"),
            datum("Three", "Answer"),
            datum("Four", "Answer"),
        ];
        let now = 2_000_000;
        let schedules = HashMap::from([
            (
                DatumKey::for_datum(&data[0]),
                stored_schedule(&data[0], now - 3 * DAY_IN_SECONDS, 1),
            ),
            (
                DatumKey::for_datum(&data[1]),
                stored_schedule(&data[1], now - 3 * DAY_IN_SECONDS + 900, 1),
            ),
            (
                DatumKey::for_datum(&data[2]),
                stored_schedule(&data[2], now - 3 * DAY_IN_SECONDS + 1_800, 1),
            ),
            (
                DatumKey::for_datum(&data[3]),
                stored_schedule(&data[3], now - 3 * DAY_IN_SECONDS + 2_700, 1),
            ),
        ]);

        let first = schedule_session(&data, &schedules, SchedulerConfig::new(0, now, 79));
        let second = schedule_session(&data, &schedules, SchedulerConfig::new(0, now, 79));
        let first_order = first
            .iter()
            .map(ScheduledCard::deck_index)
            .collect::<Vec<_>>();

        assert_eq!(
            first_order,
            second
                .iter()
                .map(ScheduledCard::deck_index)
                .collect::<Vec<_>>()
        );
        assert_ne!(first_order, vec![0, 1, 2, 3]);
    }

    #[test]
    fn scheduled_due_cards_more_than_one_hour_apart_keep_urgency_order() {
        let data = [
            datum("Most overdue", "Answer"),
            datum("Middle overdue", "Answer"),
            datum("Barely overdue", "Answer"),
        ];
        let now = 2_000_000;
        let schedules = HashMap::from([
            (
                DatumKey::for_datum(&data[0]),
                stored_schedule(&data[0], now - 3 * DAY_IN_SECONDS, 1),
            ),
            (
                DatumKey::for_datum(&data[1]),
                stored_schedule(
                    &data[1],
                    now - 3 * DAY_IN_SECONDS + NEAR_TIE_DUE_WINDOW_SECONDS + 1,
                    1,
                ),
            ),
            (
                DatumKey::for_datum(&data[2]),
                stored_schedule(
                    &data[2],
                    now - 3 * DAY_IN_SECONDS + 2 * (NEAR_TIE_DUE_WINDOW_SECONDS + 1),
                    1,
                ),
            ),
        ]);

        let scheduled = schedule_session(&data, &schedules, SchedulerConfig::new(0, now, 81));

        assert_eq!(
            scheduled
                .iter()
                .map(ScheduledCard::deck_index)
                .collect::<Vec<_>>(),
            vec![0, 1, 2]
        );
    }

    #[test]
    fn new_cards_are_randomized_deterministically() {
        let data = [
            datum("One", "Answer"),
            datum("Two", "Answer"),
            datum("Three", "Answer"),
            datum("Four", "Answer"),
        ];
        let first = schedule_session(
            &data,
            &HashMap::new(),
            SchedulerConfig::new(0, 1_000_000, 5),
        );
        let second = schedule_session(
            &data,
            &HashMap::new(),
            SchedulerConfig::new(0, 1_000_000, 5),
        );
        let first_order = first
            .iter()
            .map(ScheduledCard::deck_index)
            .collect::<Vec<_>>();

        assert_eq!(
            first_order,
            second
                .iter()
                .map(ScheduledCard::deck_index)
                .collect::<Vec<_>>()
        );
        assert_ne!(first_order, vec![0, 1, 2, 3]);
    }

    #[test]
    fn session_keeps_all_due_cards_even_when_more_than_the_minimum() {
        let first = datum("Capital of Spain", "Madrid");
        let second = datum("Capital of France", "Paris");
        let third = datum("Capital of Italy", "Rome");

        let scheduled = schedule_session(
            &[first, second, third],
            &HashMap::new(),
            SchedulerConfig::new(2, 1_000_000, 17),
        );

        assert_eq!(scheduled.len(), 3);
        assert!(
            scheduled
                .iter()
                .all(|card| card.selection_kind() == ScheduledSelectionKind::Due)
        );
    }

    #[test]
    fn session_randomly_fills_up_to_the_minimum() {
        let first = datum("Capital of Spain", "Madrid");
        let second = datum("Capital of France", "Paris");
        let third = datum("Capital of Italy", "Rome");
        let now = 3_000_000;
        let schedules = HashMap::from([
            (
                DatumKey::for_datum(&second),
                schedule_after(
                    &second,
                    Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                    now,
                ),
            ),
            (
                DatumKey::for_datum(&third),
                schedule_after(
                    &third,
                    Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                    now,
                ),
            ),
        ]);

        let scheduled = schedule_session(
            &[first, second, third],
            &schedules,
            SchedulerConfig::new(2, now, 23),
        );

        assert_eq!(scheduled.len(), 2);
        assert_eq!(scheduled[0].deck_index(), 0);
        assert_eq!(scheduled[0].selection_kind(), ScheduledSelectionKind::Due);
        assert_eq!(
            scheduled[1].selection_kind(),
            ScheduledSelectionKind::MinimumFill
        );
    }

    #[test]
    fn filler_order_is_deterministic_for_the_same_seed() {
        let data = [
            datum("Capital of Spain", "Madrid"),
            datum("Capital of France", "Paris"),
            datum("Capital of Italy", "Rome"),
            datum("Capital of Portugal", "Lisbon"),
        ];
        let now = 4_000_000;
        let schedules: HashMap<DatumKey, CardSchedule> = data
            .iter()
            .skip(1)
            .map(|datum| {
                (
                    DatumKey::for_datum(datum),
                    schedule_after(
                        datum,
                        Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy),
                        now,
                    ),
                )
            })
            .collect();

        let first = schedule_session(&data, &schedules, SchedulerConfig::new(3, now, 77));
        let second = schedule_session(&data, &schedules, SchedulerConfig::new(3, now, 77));

        assert_eq!(
            first
                .iter()
                .map(ScheduledCard::deck_index)
                .collect::<Vec<_>>(),
            second
                .iter()
                .map(ScheduledCard::deck_index)
                .collect::<Vec<_>>()
        );
    }
}
