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

const DAY_IN_SECONDS: i64 = 24 * 60 * 60;
const NEAR_TIE_DUE_WINDOW_SECONDS: i64 = 60 * 60;
const DESIRED_RETENTION: f64 = 0.9;
const FSRS6_LEARNING_STEPS_SECONDS: [i64; 2] = [60, 10 * 60];
const FSRS6_RELEARNING_STEPS_SECONDS: [i64; 1] = [10 * 60];
const FSRS6_STABILITY_MINIMUM: f64 = 0.001;
const FSRS6_STABILITY_MAXIMUM: f64 = 36_500.0;
const FSRS6_MINIMUM_DIFFICULTY: f64 = 1.0;
const FSRS6_MAXIMUM_DIFFICULTY: f64 = 10.0;
const FSRS6_MAXIMUM_INTERVAL_DAYS: u32 = 36_500;
const FSRS6_FUZZ_DELTA_BASE_DAYS: f64 = 1.0;
const FSRS6_MINIMUM_FUZZ_INTERVAL_DAYS: f64 = 2.5;
const FSRS6_FUZZ_RANGES: [Fsrs6FuzzRange; 3] = [
    Fsrs6FuzzRange {
        start_days: 2.5,
        end_days: 7.0,
        factor: 0.15,
    },
    Fsrs6FuzzRange {
        start_days: 7.0,
        end_days: 20.0,
        factor: 0.10,
    },
    Fsrs6FuzzRange {
        start_days: 20.0,
        end_days: f64::INFINITY,
        factor: 0.05,
    },
];
const FSRS6_DEFAULT_PARAMETERS: [f64; 21] = [
    0.212, 1.2931, 2.3065, 8.2956, 6.4133, 0.8334, 3.0194, 0.001, 1.8722, 0.1666, 0.796, 1.4835,
    0.0614, 0.2629, 1.6483, 0.6014, 1.8729, 0.5425, 0.0912, 0.0658, 0.1542,
];
pub const CURRENT_REVIEW_SCHEDULER_ALGORITHM: SchedulerAlgorithm = SchedulerAlgorithm::Fsrs6;

#[derive(Debug, Clone, Copy, PartialEq)]
struct Fsrs6SchedulerSettings {
    desired_retention: f64,
    maximum_interval_days: u32,
    learning_steps_seconds: [i64; 2],
    relearning_steps_seconds: [i64; 1],
    enable_fuzzing: bool,
    parameters: [f64; 21],
}

impl Fsrs6SchedulerSettings {
    fn decay(self) -> f64 {
        -self.parameters[20]
    }

    fn factor(self) -> f64 {
        0.9_f64.powf(1.0 / self.decay()) - 1.0
    }
}

impl Default for Fsrs6SchedulerSettings {
    fn default() -> Self {
        Self {
            desired_retention: DESIRED_RETENTION,
            maximum_interval_days: FSRS6_MAXIMUM_INTERVAL_DAYS,
            learning_steps_seconds: FSRS6_LEARNING_STEPS_SECONDS,
            relearning_steps_seconds: FSRS6_RELEARNING_STEPS_SECONDS,
            enable_fuzzing: true,
            parameters: FSRS6_DEFAULT_PARAMETERS,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
struct Fsrs6FuzzRange {
    start_days: f64,
    end_days: f64,
    factor: f64,
}

trait SchedulerEngine {
    fn reviewed(
        &self,
        schedule: &CardSchedule,
        review: &Review,
        reviewed_at_unix_seconds: i64,
    ) -> CardSchedule;
}

#[derive(Debug, Clone, Copy, PartialEq)]
struct Fsrs6SchedulerEngine {
    settings: Fsrs6SchedulerSettings,
}

impl Fsrs6SchedulerEngine {
    fn new(settings: Fsrs6SchedulerSettings) -> Self {
        Self { settings }
    }
}

impl Default for Fsrs6SchedulerEngine {
    fn default() -> Self {
        Self::new(Fsrs6SchedulerSettings::default())
    }
}

impl SchedulerEngine for Fsrs6SchedulerEngine {
    fn reviewed(
        &self,
        schedule: &CardSchedule,
        review: &Review,
        reviewed_at_unix_seconds: i64,
    ) -> CardSchedule {
        let settings = &self.settings;
        let rating = fsrs_rating(review);
        let elapsed_days = schedule
            .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) =
            fsrs6_next_memory(schedule, rating, elapsed_days, settings);
        let current_step = schedule.learning_step_index.unwrap_or(0);
        let (memory_state, learning_step_index, mut interval_seconds, mut scheduled_days) =
            fsrs6_next_interval(
                schedule.memory_state,
                current_step,
                rating,
                stability_days,
                settings,
            );
        if settings.enable_fuzzing
            && schedule.memory_state == CardMemoryState::Review
            && memory_state == CardMemoryState::Review
            && matches!(
                rating,
                FsrsRating::Hard | FsrsRating::Good | FsrsRating::Easy
            )
        {
            scheduled_days = fsrs6_fuzzed_interval_days(
                schedule.datum_key(),
                reviewed_at_unix_seconds,
                schedule.repetitions(),
                rating,
                elapsed_days,
                scheduled_days,
                settings,
            );
            interval_seconds = interval_seconds_from_days(scheduled_days);
        }
        let lapses = if schedule.memory_state == CardMemoryState::Review
            && rating == FsrsRating::Again
            && memory_state == CardMemoryState::Relearning
        {
            schedule.lapses + 1
        } else {
            schedule.lapses
        };

        CardSchedule {
            datum_key: schedule.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: schedule.repetitions + 1,
            lapses,
            last_reviewed_at_unix_seconds: Some(reviewed_at_unix_seconds),
            last_recall_feedback: Some(review.recall_feedback()),
            scheduler_algorithm: SchedulerAlgorithm::Fsrs6,
            learning_step_index,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchedulerConfig {
    minimum_cards_per_session: usize,
    now_unix_seconds: i64,
    filler_seed: u64,
    new_card_parent_limit: Option<usize>,
    introduced_parent_keys: HashSet<DatumKey>,
}

impl SchedulerConfig {
    pub 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,
            new_card_parent_limit: None,
            introduced_parent_keys: HashSet::new(),
        }
    }

    pub fn with_new_card_introduction_limit(
        mut self,
        introduced_parent_keys: HashSet<DatumKey>,
        remaining_new_cards: usize,
    ) -> Self {
        self.introduced_parent_keys = introduced_parent_keys;
        self.new_card_parent_limit = Some(remaining_new_cards);
        self
    }

    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
    }

    fn new_card_parent_limit(&self) -> Option<usize> {
        self.new_card_parent_limit
    }

    fn parent_key_is_introduced(&self, datum_key: &DatumKey) -> bool {
        let parent_key = parent_key_for_introduction_limit(datum_key);
        self.introduced_parent_keys.contains(&parent_key)
    }
}

#[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,
        }
    }

    pub fn uses_learning_step(self) -> bool {
        matches!(self, Self::Learning | Self::Relearning)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchedulerAlgorithm {
    LegacyFsrsLike,
    Fsrs6,
}

impl SchedulerAlgorithm {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::LegacyFsrsLike => "legacy_fsrs_like_v1",
            Self::Fsrs6 => "fsrs6",
        }
    }

    pub fn from_storage(value: &str) -> Option<Self> {
        match value {
            "legacy_fsrs_like_v1" => Some(Self::LegacyFsrsLike),
            "fsrs6" => Some(Self::Fsrs6),
            _ => 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>,
    scheduler_algorithm: SchedulerAlgorithm,
    learning_step_index: Option<u32>,
}

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,
            scheduler_algorithm: CURRENT_REVIEW_SCHEDULER_ALGORITHM,
            learning_step_index: 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::from_storage_with_scheduler_metadata(
            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,
            SchedulerAlgorithm::LegacyFsrsLike,
            default_learning_step_index(memory_state),
        )
    }

    #[allow(clippy::too_many_arguments)]
    pub(crate) fn from_storage_with_scheduler_metadata(
        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>,
        scheduler_algorithm: SchedulerAlgorithm,
        learning_step_index: Option<u32>,
    ) -> 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,
            scheduler_algorithm,
            learning_step_index,
        }
    }

    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 scheduler_algorithm(&self) -> SchedulerAlgorithm {
        self.scheduler_algorithm
    }

    pub fn learning_step_index(&self) -> Option<u32> {
        self.learning_step_index
    }

    pub fn reviewed(&self, review: &Review, reviewed_at_unix_seconds: i64) -> Self {
        Fsrs6SchedulerEngine::default().reviewed(self, review, reviewed_at_unix_seconds)
    }
}

fn default_learning_step_index(memory_state: CardMemoryState) -> Option<u32> {
    memory_state.uses_learning_step().then_some(0)
}

#[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 estimated_recall_now(schedule: &CardSchedule, now_unix_seconds: i64) -> Option<f64> {
    let last_reviewed_at = schedule.last_reviewed_at_unix_seconds()?;
    let stability_days = recall_estimate_stability_days(schedule);
    if stability_days <= 0.0 {
        return None;
    }

    let elapsed_seconds = now_unix_seconds.saturating_sub(last_reviewed_at);
    let elapsed_days = elapsed_seconds as f64 / DAY_IN_SECONDS as f64;
    Some(fsrs6_retrievability_fractional(
        elapsed_days,
        stability_days,
        &Fsrs6SchedulerSettings::default(),
    ))
}

pub fn recent_scheduler_underperformance_signal(
    reviews: &[ReviewEvent],
    decay: f64,
    attempts: usize,
) -> f64 {
    if attempts == 0 {
        return 0.0;
    }

    let Some(first_review) = reviews.first() else {
        return 0.0;
    };

    let mut schedule = CardSchedule::new(first_review.datum_key().clone());
    let mut misses = Vec::new();
    for event in reviews {
        if let Some(expected_recall) =
            estimated_recall_now(&schedule, event.reviewed_at_unix_seconds())
        {
            let observed_recall = observed_recall_value(event.review().recall_feedback());
            misses.push((expected_recall - observed_recall).max(0.0));
        }
        schedule = schedule.reviewed(event.review(), event.reviewed_at_unix_seconds());
    }

    decayed_recent_average(misses.iter().rev().copied().take(attempts), decay)
}

fn observed_recall_value(feedback: RecallFeedback) -> f64 {
    match feedback {
        RecallFeedback::Again => 0.0,
        RecallFeedback::Hard => 0.5,
        RecallFeedback::Good | RecallFeedback::Easy => 1.0,
    }
}

fn decayed_recent_average(values: impl Iterator<Item = f64>, decay: f64) -> f64 {
    let mut total_weight = 0.0;
    let mut weighted_value = 0.0;
    let mut weight = 1.0;
    for value in values {
        weighted_value += value * weight;
        total_weight += weight;
        weight *= decay;
    }
    if total_weight == 0.0 {
        0.0
    } else {
        weighted_value / total_weight
    }
}

fn recall_estimate_stability_days(schedule: &CardSchedule) -> f64 {
    let stability_days = schedule.stability_days();
    let interval_days = schedule.interval_seconds() as f64 / DAY_IN_SECONDS as f64;
    if schedule.memory_state().uses_learning_step() && interval_days > 0.0 {
        // Learning and relearning steps are deliberately shorter than the
        // model stability. For user-facing diagnostics, treat the short step
        // as the 90% point so due learning cards do not look perfectly fresh.
        stability_days.min(interval_days)
    } else {
        stability_days
    }
}

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. When a new-card introduction limit is active, unintroduced
    // cards are selected in deck order so imported decks can preserve a
    // pedagogical or frequency order.
    let mut learning_due_cards = Vec::new();
    let mut review_due_cards = Vec::new();
    let mut introduced_new_cards = Vec::new();
    let mut unintroduced_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) => {
                let card = ScheduledCard {
                    deck_index,
                    scheduled,
                    selection_kind: ScheduledSelectionKind::Due,
                };
                if config.parent_key_is_introduced(datum_key) {
                    introduced_new_cards.push(card);
                } else {
                    unintroduced_new_cards.push(card);
                }
            }
            (_, 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));
    let new_cards = if let Some(parent_limit) = config.new_card_parent_limit() {
        limited_unintroduced_new_cards(unintroduced_new_cards, parent_limit)
    } else {
        shuffle_cards(
            &mut unintroduced_new_cards,
            group_seed(config.filler_seed(), 3),
        );
        unintroduced_new_cards
    };

    let mut review_cards = learning_due_cards;
    review_cards.extend(review_due_cards);
    review_cards.extend(introduced_new_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 parent_key_for_introduction_limit(datum_key: &DatumKey) -> DatumKey {
    datum_key
        .parent_key_for_reverse()
        .unwrap_or_else(|| datum_key.clone())
}

fn limited_unintroduced_new_cards(
    cards: Vec<ScheduledCard>,
    parent_limit: usize,
) -> Vec<ScheduledCard> {
    if parent_limit == 0 {
        return Vec::new();
    }

    let mut selected_parent_keys = HashSet::new();
    cards
        .into_iter()
        .filter(|card| {
            let parent_key = parent_key_for_introduction_limit(card.scheduled.datum_key());
            if selected_parent_keys.contains(&parent_key) {
                return true;
            }
            if selected_parent_keys.len() < parent_limit {
                selected_parent_keys.insert(parent_key);
                true
            } else {
                false
            }
        })
        .collect()
}

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 fsrs6_next_memory(
    schedule: &CardSchedule,
    rating: FsrsRating,
    elapsed_days: u32,
    settings: &Fsrs6SchedulerSettings,
) -> (f64, f64) {
    if schedule.memory_state == CardMemoryState::New || schedule.stability_days <= 0.0 {
        return (
            fsrs6_initial_difficulty(rating, true, settings),
            fsrs6_initial_stability(rating, settings),
        );
    }

    let previous_stability_days = fsrs6_clamp_stability(schedule.stability_days);
    let previous_difficulty = fsrs6_clamp_difficulty(schedule.difficulty);
    let stability_days = if elapsed_days < 1 {
        fsrs6_short_term_stability(previous_stability_days, rating, settings)
    } else {
        let retrievability = fsrs6_retrievability(elapsed_days, previous_stability_days, settings);
        fsrs6_next_stability(
            previous_difficulty,
            previous_stability_days,
            retrievability,
            rating,
            settings,
        )
    };
    let difficulty = fsrs6_next_difficulty(previous_difficulty, rating, settings);
    (difficulty, stability_days)
}

fn fsrs6_next_interval(
    current_state: CardMemoryState,
    current_step: u32,
    rating: FsrsRating,
    stability_days: f64,
    settings: &Fsrs6SchedulerSettings,
) -> (CardMemoryState, Option<u32>, i64, u32) {
    match current_state {
        CardMemoryState::New | CardMemoryState::Learning => fsrs6_next_learning_interval(
            CardMemoryState::Learning,
            current_step,
            rating,
            stability_days,
            &settings.learning_steps_seconds,
            settings,
        ),
        CardMemoryState::Review => match rating {
            FsrsRating::Again => (
                CardMemoryState::Relearning,
                Some(0),
                settings.relearning_steps_seconds[0],
                0,
            ),
            FsrsRating::Hard | FsrsRating::Good | FsrsRating::Easy => {
                let days = fsrs6_scheduled_days(stability_days, settings);
                (
                    CardMemoryState::Review,
                    None,
                    interval_seconds_from_days(days),
                    days,
                )
            }
        },
        CardMemoryState::Relearning => fsrs6_next_learning_interval(
            CardMemoryState::Relearning,
            current_step,
            rating,
            stability_days,
            &settings.relearning_steps_seconds,
            settings,
        ),
    }
}

fn fsrs6_next_learning_interval<const N: usize>(
    learning_state: CardMemoryState,
    current_step: u32,
    rating: FsrsRating,
    stability_days: f64,
    steps_seconds: &[i64; N],
    settings: &Fsrs6SchedulerSettings,
) -> (CardMemoryState, Option<u32>, i64, u32) {
    if steps_seconds.is_empty() {
        let days = fsrs6_scheduled_days(stability_days, settings);
        return (
            CardMemoryState::Review,
            None,
            interval_seconds_from_days(days),
            days,
        );
    }

    let step = (current_step as usize).min(steps_seconds.len());
    if step >= steps_seconds.len() && rating != FsrsRating::Again {
        let days = fsrs6_scheduled_days(stability_days, settings);
        return (
            CardMemoryState::Review,
            None,
            interval_seconds_from_days(days),
            days,
        );
    }

    match rating {
        FsrsRating::Again => (learning_state, Some(0), steps_seconds[0], 0),
        FsrsRating::Hard => {
            let interval_seconds = if step == 0 && steps_seconds.len() == 1 {
                steps_seconds[0] + steps_seconds[0] / 2
            } else if step == 0 {
                (steps_seconds[0] + steps_seconds[1]) / 2
            } else {
                steps_seconds[step]
            };
            (learning_state, Some(step as u32), interval_seconds, 0)
        }
        FsrsRating::Good => {
            if step + 1 == steps_seconds.len() {
                let days = fsrs6_scheduled_days(stability_days, settings);
                (
                    CardMemoryState::Review,
                    None,
                    interval_seconds_from_days(days),
                    days,
                )
            } else {
                let next_step = step + 1;
                (
                    learning_state,
                    Some(next_step as u32),
                    steps_seconds[next_step],
                    0,
                )
            }
        }
        FsrsRating::Easy => {
            let days = fsrs6_scheduled_days(stability_days, settings);
            (
                CardMemoryState::Review,
                None,
                interval_seconds_from_days(days),
                days,
            )
        }
    }
}

fn fsrs6_initial_stability(rating: FsrsRating, settings: &Fsrs6SchedulerSettings) -> f64 {
    fsrs6_clamp_stability(settings.parameters[rating_index(rating)])
}

fn fsrs6_initial_difficulty(
    rating: FsrsRating,
    clamp: bool,
    settings: &Fsrs6SchedulerSettings,
) -> f64 {
    let w = settings.parameters;
    let difficulty = w[4] - (w[5] * (rating_value(rating) - 1.0)).exp() + 1.0;
    if clamp {
        fsrs6_clamp_difficulty(difficulty)
    } else {
        difficulty
    }
}

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

    (1.0 + settings.factor() * f64::from(elapsed_days) / stability_days).powf(settings.decay())
}

fn fsrs6_retrievability_fractional(
    elapsed_days: f64,
    stability_days: f64,
    settings: &Fsrs6SchedulerSettings,
) -> f64 {
    if elapsed_days <= 0.0 || stability_days <= 0.0 {
        return 1.0;
    }

    (1.0 + settings.factor() * elapsed_days / stability_days).powf(settings.decay())
}

fn fsrs6_short_term_stability(
    stability_days: f64,
    rating: FsrsRating,
    settings: &Fsrs6SchedulerSettings,
) -> f64 {
    let w = settings.parameters;
    let increase =
        (w[17] * (rating_value(rating) - 3.0 + w[18])).exp() * stability_days.powf(-w[19]);
    let increase = if matches!(rating, FsrsRating::Good | FsrsRating::Easy) {
        increase.max(1.0)
    } else {
        increase
    };
    fsrs6_clamp_stability(stability_days * increase)
}

fn fsrs6_next_difficulty(
    difficulty: f64,
    rating: FsrsRating,
    settings: &Fsrs6SchedulerSettings,
) -> f64 {
    let w = settings.parameters;
    let easy_initial = fsrs6_initial_difficulty(FsrsRating::Easy, false, settings);
    let delta_difficulty = -(w[6] * (rating_value(rating) - 3.0));
    let damped = difficulty + (FSRS6_MAXIMUM_DIFFICULTY - difficulty) * delta_difficulty / 9.0;
    fsrs6_clamp_difficulty(w[7] * easy_initial + (1.0 - w[7]) * damped)
}

fn fsrs6_next_stability(
    difficulty: f64,
    stability_days: f64,
    retrievability: f64,
    rating: FsrsRating,
    settings: &Fsrs6SchedulerSettings,
) -> f64 {
    let stability = if rating == FsrsRating::Again {
        fsrs6_next_forget_stability(difficulty, stability_days, retrievability, settings)
    } else {
        fsrs6_next_recall_stability(difficulty, stability_days, retrievability, rating, settings)
    };
    fsrs6_clamp_stability(stability)
}

fn fsrs6_next_forget_stability(
    difficulty: f64,
    stability_days: f64,
    retrievability: f64,
    settings: &Fsrs6SchedulerSettings,
) -> f64 {
    let w = settings.parameters;
    let long_term = w[11]
        * difficulty.powf(-w[12])
        * ((stability_days + 1.0).powf(w[13]) - 1.0)
        * ((1.0 - retrievability) * w[14]).exp();
    let short_term = stability_days / (w[17] * w[18]).exp();
    long_term.min(short_term)
}

fn fsrs6_next_recall_stability(
    difficulty: f64,
    stability_days: f64,
    retrievability: f64,
    rating: FsrsRating,
    settings: &Fsrs6SchedulerSettings,
) -> f64 {
    let w = settings.parameters;
    let hard_penalty = if rating == FsrsRating::Hard {
        w[15]
    } else {
        1.0
    };
    let easy_bonus = if rating == FsrsRating::Easy {
        w[16]
    } else {
        1.0
    };
    stability_days
        * (1.0
            + w[8].exp()
                * (11.0 - difficulty)
                * stability_days.powf(-w[9])
                * (((1.0 - retrievability) * w[10]).exp() - 1.0)
                * hard_penalty
                * easy_bonus)
}

fn fsrs6_scheduled_days(stability_days: f64, settings: &Fsrs6SchedulerSettings) -> u32 {
    let interval = (stability_days / settings.factor())
        * (settings.desired_retention.powf(1.0 / settings.decay()) - 1.0);
    interval
        .round()
        .clamp(1.0, f64::from(settings.maximum_interval_days)) as u32
}

fn fsrs6_fuzzed_interval_days(
    datum_key: &DatumKey,
    reviewed_at_unix_seconds: i64,
    repetitions: u32,
    rating: FsrsRating,
    elapsed_days: u32,
    scheduled_days: u32,
    settings: &Fsrs6SchedulerSettings,
) -> u32 {
    let Some((minimum_days, maximum_days)) =
        fsrs6_fuzz_interval_bounds(scheduled_days, elapsed_days, settings)
    else {
        return scheduled_days;
    };

    let seed = fsrs6_fuzz_seed(
        datum_key,
        reviewed_at_unix_seconds,
        repetitions,
        rating,
        scheduled_days,
    );
    random_u32_inclusive(minimum_days, maximum_days, seed)
}

fn fsrs6_fuzz_interval_bounds(
    scheduled_days: u32,
    elapsed_days: u32,
    settings: &Fsrs6SchedulerSettings,
) -> Option<(u32, u32)> {
    let interval_days = f64::from(scheduled_days);
    if interval_days < FSRS6_MINIMUM_FUZZ_INTERVAL_DAYS {
        return None;
    }

    let delta_days = fsrs6_fuzz_delta_days(interval_days);
    let minimum_from_interval = (interval_days - delta_days).round().max(2.0) as u32;
    let minimum_from_elapsed = elapsed_days
        .saturating_add(1)
        .max(2)
        .min(settings.maximum_interval_days);
    let minimum_days = minimum_from_interval
        .max(minimum_from_elapsed)
        .min(settings.maximum_interval_days);
    let maximum_days = (interval_days + delta_days).round().clamp(
        f64::from(minimum_days),
        f64::from(settings.maximum_interval_days),
    ) as u32;

    Some((minimum_days, maximum_days))
}

fn fsrs6_fuzz_delta_days(interval_days: f64) -> f64 {
    let mut delta_days = FSRS6_FUZZ_DELTA_BASE_DAYS;
    for range in FSRS6_FUZZ_RANGES {
        if interval_days <= range.start_days {
            break;
        }

        let range_end = interval_days.min(range.end_days);
        delta_days += (range_end - range.start_days) * range.factor;
        if interval_days <= range.end_days {
            break;
        }
    }
    delta_days
}

fn fsrs6_fuzz_seed(
    datum_key: &DatumKey,
    reviewed_at_unix_seconds: i64,
    repetitions: u32,
    rating: FsrsRating,
    scheduled_days: u32,
) -> u64 {
    let mut hash = 0xcbf2_9ce4_8422_2325;
    stable_hash_bytes(&mut hash, b"cosimo-fsrs6-fuzz-v1");
    stable_hash_bytes(&mut hash, datum_key.as_str().as_bytes());
    stable_hash_bytes(&mut hash, &reviewed_at_unix_seconds.to_le_bytes());
    stable_hash_bytes(&mut hash, &repetitions.to_le_bytes());
    stable_hash_bytes(&mut hash, &[rating as u8]);
    stable_hash_bytes(&mut hash, &scheduled_days.to_le_bytes());
    splitmix64(&mut hash)
}

fn stable_hash_bytes(hash: &mut u64, bytes: &[u8]) {
    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
    for byte in bytes {
        *hash ^= u64::from(*byte);
        *hash = hash.wrapping_mul(FNV_PRIME);
    }
    *hash ^= 0xff;
    *hash = hash.wrapping_mul(FNV_PRIME);
}

fn random_u32_inclusive(minimum: u32, maximum: u32, seed: u64) -> u32 {
    if minimum >= maximum {
        return minimum;
    }

    let span = u64::from(maximum - minimum) + 1;
    let mut state = seed;
    minimum + (splitmix64(&mut state) % span) as u32
}

fn fsrs6_clamp_difficulty(difficulty: f64) -> f64 {
    difficulty.clamp(FSRS6_MINIMUM_DIFFICULTY, FSRS6_MAXIMUM_DIFFICULTY)
}

fn fsrs6_clamp_stability(stability: f64) -> f64 {
    stability.clamp(FSRS6_STABILITY_MINIMUM, FSRS6_STABILITY_MAXIMUM)
}

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 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;
    use proptest::prelude::*;

    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 fsrs6_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 review_for_rating(rating: FsrsRating) -> Review {
        let recall = match rating {
            FsrsRating::Again => RecallFeedback::Again,
            FsrsRating::Hard => RecallFeedback::Hard,
            FsrsRating::Good => RecallFeedback::Good,
            FsrsRating::Easy => RecallFeedback::Easy,
        };
        Review::new(PromptGuess::ProbablyKnow, recall)
    }

    fn fsrs6_schedule_after_day_sequence(
        datum: &Datum,
        sequence: &[(FsrsRating, u32)],
    ) -> CardSchedule {
        let mut reviewed_at = 1_000_000;
        let mut schedule = CardSchedule::new(DatumKey::for_datum(datum));
        for (rating, elapsed_days) in sequence {
            reviewed_at += i64::from(*elapsed_days) * DAY_IN_SECONDS;
            schedule = schedule.reviewed(&review_for_rating(*rating), reviewed_at);
        }
        schedule
    }

    fn assert_approx_eq(actual: f64, expected: f64) {
        assert!(
            (actual - expected).abs() < 0.000_001,
            "expected {actual} to be approximately {expected}"
        );
    }

    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 {
        stored_schedule_for_key_with_state(
            DatumKey::for_datum(datum),
            memory_state,
            due_at_unix_seconds,
            scheduled_days,
        )
    }

    fn stored_schedule_for_key_with_state(
        datum_key: DatumKey,
        memory_state: CardMemoryState,
        due_at_unix_seconds: i64,
        scheduled_days: u32,
    ) -> CardSchedule {
        CardSchedule::from_storage(
            datum_key,
            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),
        )
    }

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    enum GeneratedSessionParentState {
        UnintroducedNew,
        IntroducedNew,
        DueReview,
        DueLearning,
        NotDueReview,
    }

    impl GeneratedSessionParentState {
        fn from_raw(value: u8) -> Self {
            match value % 5 {
                0 => Self::UnintroducedNew,
                1 => Self::IntroducedNew,
                2 => Self::DueReview,
                3 => Self::DueLearning,
                _ => Self::NotDueReview,
            }
        }

        fn is_new(self) -> bool {
            matches!(self, Self::UnintroducedNew | Self::IntroducedNew)
        }

        fn is_required_due(self) -> bool {
            matches!(
                self,
                Self::IntroducedNew | Self::DueReview | Self::DueLearning
            )
        }

        fn is_unintroduced_new(self) -> bool {
            matches!(self, Self::UnintroducedNew)
        }

        fn is_filler_candidate(self) -> bool {
            matches!(self, Self::NotDueReview)
        }
    }

    #[derive(Debug, Clone, PartialEq, Eq)]
    struct GeneratedSessionItemExpectation {
        parent_key: DatumKey,
        state: GeneratedSessionParentState,
    }

    #[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(), 8 * DAY_IN_SECONDS);
        assert_eq!(
            scheduled.due_at_unix_seconds(),
            1_000_000 + 8 * 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(), 8);
        assert_eq!(schedule.interval_seconds(), 8 * DAY_IN_SECONDS);
        assert_eq!(schedule.last_recall_feedback(), Some(RecallFeedback::Easy));
        assert_eq!(schedule.scheduler_algorithm(), SchedulerAlgorithm::Fsrs6);
        assert_eq!(schedule.learning_step_index(), None);
        assert!(schedule.stability_days() > 0.0);
        assert!(schedule.difficulty() >= 1.0);
    }

    #[test]
    fn default_scheduler_engine_matches_public_review_path() {
        let datum = datum("Capital of Spain", "Madrid");
        let schedule = CardSchedule::new(DatumKey::for_datum(&datum));
        let review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Easy);
        let reviewed_at = 1_000_000;

        let public_result = schedule.reviewed(&review, reviewed_at);
        let engine_result =
            Fsrs6SchedulerEngine::default().reviewed(&schedule, &review, reviewed_at);

        assert_eq!(engine_result, public_result);
    }

    #[test]
    fn reviewing_legacy_stored_schedule_uses_current_scheduler() {
        let reviewed_at = 1_000_000 + DAY_IN_SECONDS;
        let schedule = CardSchedule::from_storage_with_scheduler_metadata(
            DatumKey::for_card_id(1),
            CardMemoryState::Review,
            reviewed_at,
            5.0,
            5.0,
            0,
            5,
            5 * DAY_IN_SECONDS,
            1,
            0,
            Some(1_000_000),
            Some(RecallFeedback::Good),
            SchedulerAlgorithm::LegacyFsrsLike,
            None,
        );

        let updated = schedule.reviewed(
            &Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Good),
            reviewed_at,
        );

        assert_eq!(updated.scheduler_algorithm(), SchedulerAlgorithm::Fsrs6);
        assert_eq!(updated.repetitions(), 2);
    }

    #[test]
    fn fsrs6_new_good_card_uses_learning_steps_before_graduation() {
        let datum = datum("Capital of Spain", "Madrid");
        let reviewed_at = 1_000_000;
        let first = fsrs6_schedule_after(
            &datum,
            Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Good),
            reviewed_at,
        );

        assert_eq!(first.scheduler_algorithm(), SchedulerAlgorithm::Fsrs6);
        assert_eq!(first.memory_state(), CardMemoryState::Learning);
        assert_eq!(first.learning_step_index(), Some(1));
        assert_eq!(first.scheduled_days(), 0);
        assert_eq!(first.interval_seconds(), 10 * 60);
        assert_approx_eq(first.stability_days(), 2.3065);
        assert_approx_eq(first.difficulty(), 2.118_103_970_459_016);

        let second = first.reviewed(
            &Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Good),
            reviewed_at + first.interval_seconds(),
        );

        assert_eq!(second.scheduler_algorithm(), SchedulerAlgorithm::Fsrs6);
        assert_eq!(second.memory_state(), CardMemoryState::Review);
        assert_eq!(second.learning_step_index(), None);
        assert_eq!(second.scheduled_days(), 2);
        assert_eq!(second.interval_seconds(), 2 * DAY_IN_SECONDS);
    }

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

        assert_eq!(schedule.memory_state(), CardMemoryState::Review);
        assert_eq!(schedule.learning_step_index(), None);
        assert_eq!(schedule.scheduled_days(), 8);
        assert_eq!(schedule.interval_seconds(), 8 * DAY_IN_SECONDS);
        assert_approx_eq(schedule.stability_days(), 8.2956);
        assert_approx_eq(schedule.difficulty(), 1.0);
    }

    #[test]
    fn fsrs6_review_again_uses_relearning_step_and_counts_lapse() {
        let datum = datum("Capital of Spain", "Madrid");
        let first = fsrs6_schedule_after(
            &datum,
            Review::new(PromptGuess::CertainlyKnow, 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.scheduler_algorithm(), SchedulerAlgorithm::Fsrs6);
        assert_eq!(failed.memory_state(), CardMemoryState::Relearning);
        assert_eq!(failed.learning_step_index(), Some(0));
        assert_eq!(failed.lapses(), 1);
        assert_eq!(failed.scheduled_days(), 0);
        assert_eq!(failed.interval_seconds(), 10 * 60);
        assert_approx_eq(failed.stability_days(), 1.274_963_892_169_691_5);
        assert_approx_eq(failed.difficulty(), 7.026_989_569_296_838_5);
    }

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

        assert_eq!(relearned.memory_state(), CardMemoryState::Review);
        assert_eq!(relearned.learning_step_index(), None);
        assert_eq!(relearned.lapses(), 1);
        assert!(relearned.scheduled_days() >= 1);
        assert_eq!(
            relearned.interval_seconds(),
            interval_seconds_from_days(relearned.scheduled_days())
        );
    }

    #[test]
    fn fsrs6_long_history_keeps_step_and_lapse_semantics() {
        let datum = datum("Capital of Spain", "Madrid");
        let schedule = fsrs6_schedule_after_day_sequence(
            &datum,
            &[
                (FsrsRating::Again, 0),
                (FsrsRating::Good, 0),
                (FsrsRating::Good, 0),
                (FsrsRating::Hard, 2),
                (FsrsRating::Good, 5),
                (FsrsRating::Easy, 10),
                (FsrsRating::Again, 21),
                (FsrsRating::Good, 0),
                (FsrsRating::Hard, 1),
            ],
        );

        assert_eq!(schedule.scheduler_algorithm(), SchedulerAlgorithm::Fsrs6);
        assert_eq!(schedule.memory_state(), CardMemoryState::Review);
        assert_eq!(schedule.learning_step_index(), None);
        assert_eq!(schedule.repetitions(), 9);
        assert_eq!(schedule.lapses(), 1);
        assert_eq!(schedule.last_recall_feedback(), Some(RecallFeedback::Hard));
        assert!(schedule.stability_days() > 0.0);
        assert!(schedule.scheduled_days() > 0);
        assert_eq!(
            schedule.interval_seconds(),
            interval_seconds_from_days(schedule.scheduled_days())
        );
    }

    #[test]
    fn fsrs6_clamps_existing_memory_state_before_update() {
        let datum_key = DatumKey::for_card_id(1);
        let reviewed_at = 3 * DAY_IN_SECONDS;
        let review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Good);
        let settings = Fsrs6SchedulerSettings {
            enable_fuzzing: false,
            ..Fsrs6SchedulerSettings::default()
        };
        let out_of_range_schedule = CardSchedule::from_storage_with_scheduler_metadata(
            datum_key.clone(),
            CardMemoryState::Review,
            reviewed_at,
            FSRS6_STABILITY_MAXIMUM * 2.0,
            FSRS6_MAXIMUM_DIFFICULTY * 2.0,
            0,
            FSRS6_MAXIMUM_INTERVAL_DAYS,
            interval_seconds_from_days(FSRS6_MAXIMUM_INTERVAL_DAYS),
            8,
            0,
            Some(0),
            Some(RecallFeedback::Good),
            SchedulerAlgorithm::Fsrs6,
            None,
        );
        let clamped_schedule = CardSchedule::from_storage_with_scheduler_metadata(
            datum_key,
            CardMemoryState::Review,
            reviewed_at,
            FSRS6_STABILITY_MAXIMUM,
            FSRS6_MAXIMUM_DIFFICULTY,
            0,
            FSRS6_MAXIMUM_INTERVAL_DAYS,
            interval_seconds_from_days(FSRS6_MAXIMUM_INTERVAL_DAYS),
            8,
            0,
            Some(0),
            Some(RecallFeedback::Good),
            SchedulerAlgorithm::Fsrs6,
            None,
        );

        let out_of_range_result = Fsrs6SchedulerEngine::new(settings).reviewed(
            &out_of_range_schedule,
            &review,
            reviewed_at,
        );
        let clamped_result =
            Fsrs6SchedulerEngine::new(settings).reviewed(&clamped_schedule, &review, reviewed_at);

        assert_eq!(out_of_range_result, clamped_result);
        assert!(out_of_range_result.stability_days() <= FSRS6_STABILITY_MAXIMUM);
        assert!(out_of_range_result.difficulty() <= FSRS6_MAXIMUM_DIFFICULTY);
    }

    #[test]
    fn fsrs6_clamps_tiny_existing_stability_before_update() {
        let datum_key = DatumKey::for_card_id(1);
        let reviewed_at = 3 * DAY_IN_SECONDS;
        let review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Hard);
        let settings = Fsrs6SchedulerSettings {
            enable_fuzzing: false,
            ..Fsrs6SchedulerSettings::default()
        };
        let tiny_stability_schedule = CardSchedule::from_storage_with_scheduler_metadata(
            datum_key.clone(),
            CardMemoryState::Review,
            reviewed_at,
            FSRS6_STABILITY_MINIMUM / 10.0,
            FSRS6_MINIMUM_DIFFICULTY / 10.0,
            0,
            1,
            interval_seconds_from_days(1),
            8,
            0,
            Some(0),
            Some(RecallFeedback::Hard),
            SchedulerAlgorithm::Fsrs6,
            None,
        );
        let clamped_schedule = CardSchedule::from_storage_with_scheduler_metadata(
            datum_key,
            CardMemoryState::Review,
            reviewed_at,
            FSRS6_STABILITY_MINIMUM,
            FSRS6_MINIMUM_DIFFICULTY,
            0,
            1,
            interval_seconds_from_days(1),
            8,
            0,
            Some(0),
            Some(RecallFeedback::Hard),
            SchedulerAlgorithm::Fsrs6,
            None,
        );

        let tiny_result = Fsrs6SchedulerEngine::new(settings).reviewed(
            &tiny_stability_schedule,
            &review,
            reviewed_at,
        );
        let clamped_result =
            Fsrs6SchedulerEngine::new(settings).reviewed(&clamped_schedule, &review, reviewed_at);

        assert_eq!(tiny_result, clamped_result);
        assert!(tiny_result.stability_days() >= FSRS6_STABILITY_MINIMUM);
        assert!(tiny_result.difficulty() >= FSRS6_MINIMUM_DIFFICULTY);
    }

    #[test]
    fn estimated_recall_uses_current_scheduler_curve_for_stored_rows() {
        let datum_key = DatumKey::for_card_id(1);
        let now = 1_000_000 + 5 * DAY_IN_SECONDS;
        let legacy_schedule = CardSchedule::from_storage_with_scheduler_metadata(
            datum_key.clone(),
            CardMemoryState::Review,
            now,
            10.0,
            5.0,
            0,
            10,
            10 * DAY_IN_SECONDS,
            1,
            0,
            Some(now - 5 * DAY_IN_SECONDS),
            Some(RecallFeedback::Good),
            SchedulerAlgorithm::LegacyFsrsLike,
            None,
        );
        let fsrs6_schedule = CardSchedule::from_storage_with_scheduler_metadata(
            datum_key,
            CardMemoryState::Review,
            now,
            10.0,
            5.0,
            0,
            10,
            10 * DAY_IN_SECONDS,
            1,
            0,
            Some(now - 5 * DAY_IN_SECONDS),
            Some(RecallFeedback::Good),
            SchedulerAlgorithm::Fsrs6,
            None,
        );

        let legacy_recall =
            estimated_recall_now(&legacy_schedule, now).expect("stored recall estimate");
        let fsrs6_recall =
            estimated_recall_now(&fsrs6_schedule, now).expect("current recall estimate");

        assert_approx_eq(
            legacy_recall,
            fsrs6_retrievability_fractional(5.0, 10.0, &Fsrs6SchedulerSettings::default()),
        );
        assert_approx_eq(legacy_recall, fsrs6_recall);
    }

    #[test]
    fn estimated_recall_uses_short_learning_interval_as_stability_cap() {
        let now = 1_000_000;
        let schedule = CardSchedule::from_storage_with_scheduler_metadata(
            DatumKey::for_card_id(1),
            CardMemoryState::Relearning,
            now,
            10.0,
            5.0,
            0,
            0,
            10 * 60,
            1,
            1,
            Some(now - 10 * 60),
            Some(RecallFeedback::Again),
            SchedulerAlgorithm::Fsrs6,
            Some(0),
        );

        let recall = estimated_recall_now(&schedule, now).expect("recall estimate");

        assert!(recall > 0.89);
        assert!(recall < 0.91);
    }

    #[test]
    fn estimated_recall_for_review_cards_uses_stability_not_fuzzed_interval() {
        let now = 1_000_000;
        let schedule = CardSchedule::from_storage_with_scheduler_metadata(
            DatumKey::for_card_id(1),
            CardMemoryState::Review,
            now,
            10.0,
            5.0,
            0,
            5,
            5 * DAY_IN_SECONDS,
            3,
            0,
            Some(now - 5 * DAY_IN_SECONDS),
            Some(RecallFeedback::Good),
            SchedulerAlgorithm::Fsrs6,
            None,
        );

        let recall = estimated_recall_now(&schedule, now).expect("recall estimate");

        assert_approx_eq(
            recall,
            fsrs6_retrievability_fractional(5.0, 10.0, &Fsrs6SchedulerSettings::default()),
        );
    }

    #[cfg(feature = "fsrs-reference")]
    mod fsrs_reference_tests {
        use super::*;
        use ::fsrs::{DEFAULT_PARAMETERS, FSRS, FSRSItem, FSRSReview, MemoryState};

        fn assert_close(actual: f64, expected: f32) {
            assert!(
                (actual - f64::from(expected)).abs() < 0.000_1,
                "expected {actual} to be close to {expected}"
            );
        }

        fn reference_memory_state(sequence: &[(FsrsRating, u32)]) -> MemoryState {
            let item = FSRSItem {
                reviews: sequence
                    .iter()
                    .map(|(rating, delta_t)| FSRSReview {
                        rating: *rating as u32,
                        delta_t: *delta_t,
                    })
                    .collect(),
            };
            FSRS::new(Some(&DEFAULT_PARAMETERS))
                .unwrap()
                .memory_state(item, None)
                .unwrap()
        }

        fn expected_scheduled_days(reference_interval: f32) -> u32 {
            reference_interval
                .round()
                .clamp(1.0, FSRS6_MAXIMUM_INTERVAL_DAYS as f32) as u32
        }

        #[test]
        fn fsrs6_default_parameters_match_reference_crate() {
            assert_eq!(
                FSRS6_DEFAULT_PARAMETERS.map(|value| value as f32),
                DEFAULT_PARAMETERS
            );
        }

        #[test]
        fn fsrs6_new_card_memory_states_match_reference_crate() {
            let reference = FSRS::new(Some(&[]))
                .unwrap()
                .next_states(None, DESIRED_RETENTION as f32, 0)
                .unwrap();
            let settings = Fsrs6SchedulerSettings::default();
            let schedule = CardSchedule::new(DatumKey::for_card_id(1));

            for (rating, reference_state) in [
                (FsrsRating::Again, reference.again),
                (FsrsRating::Hard, reference.hard),
                (FsrsRating::Good, reference.good),
                (FsrsRating::Easy, reference.easy),
            ] {
                let (difficulty, stability) = fsrs6_next_memory(&schedule, rating, 0, &settings);
                assert_close(stability, reference_state.memory.stability);
                assert_close(difficulty, reference_state.memory.difficulty);
                assert_eq!(
                    fsrs6_scheduled_days(stability, &settings),
                    expected_scheduled_days(reference_state.interval),
                );
            }
        }

        #[test]
        fn fsrs6_review_memory_states_match_reference_crate() {
            let stability = 8.2956;
            let difficulty = 1.0;
            let elapsed_days = 3;
            let reference = FSRS::new(Some(&[]))
                .unwrap()
                .next_states(
                    Some(MemoryState {
                        stability: stability as f32,
                        difficulty: difficulty as f32,
                    }),
                    DESIRED_RETENTION as f32,
                    elapsed_days,
                )
                .unwrap();
            let settings = Fsrs6SchedulerSettings::default();
            let schedule = CardSchedule::from_storage_with_scheduler_metadata(
                DatumKey::for_card_id(1),
                CardMemoryState::Review,
                0,
                stability,
                difficulty,
                elapsed_days,
                0,
                0,
                1,
                0,
                Some(0),
                Some(RecallFeedback::Easy),
                SchedulerAlgorithm::Fsrs6,
                None,
            );

            for (rating, reference_state) in [
                (FsrsRating::Again, reference.again),
                (FsrsRating::Hard, reference.hard),
                (FsrsRating::Good, reference.good),
                (FsrsRating::Easy, reference.easy),
            ] {
                let (difficulty, stability) =
                    fsrs6_next_memory(&schedule, rating, elapsed_days, &settings);
                assert_close(stability, reference_state.memory.stability);
                assert_close(difficulty, reference_state.memory.difficulty);
                assert_eq!(
                    fsrs6_scheduled_days(stability, &settings),
                    expected_scheduled_days(reference_state.interval),
                );
            }
        }

        #[test]
        fn fsrs6_long_history_matches_reference_crate_memory_state() {
            let datum = datum("Capital of Spain", "Madrid");
            let sequence = [
                (FsrsRating::Again, 0),
                (FsrsRating::Good, 0),
                (FsrsRating::Good, 0),
                (FsrsRating::Hard, 2),
                (FsrsRating::Good, 5),
                (FsrsRating::Easy, 10),
                (FsrsRating::Again, 21),
                (FsrsRating::Good, 0),
                (FsrsRating::Hard, 1),
            ];

            let schedule = fsrs6_schedule_after_day_sequence(&datum, &sequence);
            let reference = reference_memory_state(&sequence);

            assert_close(schedule.stability_days(), reference.stability);
            assert_close(schedule.difficulty(), reference.difficulty);
        }
    }

    #[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(), 0);
        assert_eq!(schedule.scheduled_days(), 0);
        assert_eq!(schedule.interval_seconds(), FSRS6_LEARNING_STEPS_SECONDS[0]);
        assert_eq!(
            schedule.due_at_unix_seconds(),
            reviewed_at + FSRS6_LEARNING_STEPS_SECONDS[0]
        );
        assert_eq!(schedule.learning_step_index(), Some(0));
    }

    #[test]
    fn failed_learning_card_stays_learning_without_counting_lapse() {
        let datum = datum("Capital of Spain", "Madrid");
        let first = schedule_after(
            &datum,
            Review::new(PromptGuess::ProbablyDontKnow, RecallFeedback::Again),
            1_000_000,
        );
        let failed_again = first.reviewed(
            &Review::new(PromptGuess::ProbablyDontKnow, RecallFeedback::Again),
            1_000_000 + FSRS6_LEARNING_STEPS_SECONDS[0],
        );

        assert_eq!(failed_again.memory_state(), CardMemoryState::Learning);
        assert_eq!(failed_again.lapses(), 0);
        assert_eq!(failed_again.repetitions(), 2);
        assert_eq!(
            failed_again.interval_seconds(),
            FSRS6_LEARNING_STEPS_SECONDS[0]
        );
        assert_eq!(failed_again.learning_step_index(), Some(0));
    }

    #[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.learning_step_index(), None);
        assert_eq!(second.elapsed_days(), 10);
        assert!(second.stability_days() > first.stability_days());
        assert!(second.scheduled_days() >= first.scheduled_days());
    }

    #[test]
    fn fsrs6_review_intervals_are_fuzzed_deterministically_within_bounds() {
        let datum = datum("Capital of Spain", "Madrid");
        let first = fsrs6_schedule_after(
            &datum,
            Review::new(PromptGuess::CertainlyKnow, RecallFeedback::Easy),
            1_000_000,
        );
        let reviewed_at = 1_000_000 + 10 * DAY_IN_SECONDS;
        let review = Review::new(PromptGuess::ProbablyKnow, RecallFeedback::Good);
        let settings = Fsrs6SchedulerSettings::default();
        let unfuzzed_settings = Fsrs6SchedulerSettings {
            enable_fuzzing: false,
            ..Fsrs6SchedulerSettings::default()
        };

        let fuzzed_a = Fsrs6SchedulerEngine::new(settings).reviewed(&first, &review, reviewed_at);
        let fuzzed_b = Fsrs6SchedulerEngine::new(settings).reviewed(&first, &review, reviewed_at);
        let unfuzzed =
            Fsrs6SchedulerEngine::new(unfuzzed_settings).reviewed(&first, &review, reviewed_at);
        let (minimum_days, maximum_days) =
            fsrs6_fuzz_interval_bounds(unfuzzed.scheduled_days(), 10, &settings)
                .expect("fuzz bounds");

        assert_eq!(fuzzed_a, fuzzed_b);
        assert_eq!(fuzzed_a.memory_state(), CardMemoryState::Review);
        assert_eq!(fuzzed_a.learning_step_index(), None);
        assert_approx_eq(fuzzed_a.stability_days(), unfuzzed.stability_days());
        assert_approx_eq(fuzzed_a.difficulty(), unfuzzed.difficulty());
        assert!(fuzzed_a.scheduled_days() >= minimum_days);
        assert!(fuzzed_a.scheduled_days() <= maximum_days);
        assert_eq!(
            fuzzed_a.interval_seconds(),
            interval_seconds_from_days(fuzzed_a.scheduled_days())
        );
        assert_eq!(
            fuzzed_a.due_at_unix_seconds(),
            reviewed_at + fuzzed_a.interval_seconds()
        );
    }

    #[test]
    fn fsrs6_fuzz_bounds_respect_elapsed_days_and_maximum_interval() {
        let settings = Fsrs6SchedulerSettings {
            maximum_interval_days: 30,
            ..Fsrs6SchedulerSettings::default()
        };

        assert_eq!(fsrs6_fuzz_interval_bounds(2, 0, &settings), None);
        assert_eq!(
            fsrs6_fuzz_interval_bounds(20, 25, &settings),
            Some((26, 26))
        );
        assert_eq!(fsrs6_fuzz_interval_bounds(30, 0, &settings), Some((27, 30)));
    }

    #[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(), FSRS6_RELEARNING_STEPS_SECONDS[0]);
        assert_eq!(
            failed.due_at_unix_seconds(),
            1_000_000 + 3 * DAY_IN_SECONDS + FSRS6_RELEARNING_STEPS_SECONDS[0]
        );
        assert_eq!(failed.last_recall_feedback(), Some(RecallFeedback::Again));
        assert_eq!(failed.learning_step_index(), Some(0));
    }

    #[test]
    fn failed_relearning_card_stays_relearning_without_counting_another_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,
        );
        let failed_again = failed.reviewed(
            &Review::new(PromptGuess::ProbablyDontKnow, RecallFeedback::Again),
            1_000_000 + 3 * DAY_IN_SECONDS + FSRS6_RELEARNING_STEPS_SECONDS[0],
        );

        assert_eq!(failed_again.memory_state(), CardMemoryState::Relearning);
        assert_eq!(failed_again.lapses(), 1);
        assert_eq!(failed_again.repetitions(), 3);
        assert_eq!(
            failed_again.interval_seconds(),
            FSRS6_RELEARNING_STEPS_SECONDS[0]
        );
    }

    #[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 new_card_limit_introduces_unseen_cards_in_deck_order() {
        let data = [
            datum("One", "Answer"),
            datum("Two", "Answer"),
            datum("Three", "Answer"),
            datum("Four", "Answer"),
        ];

        let scheduled = schedule_session(
            &data,
            &HashMap::new(),
            SchedulerConfig::new(0, 1_000_000, 5)
                .with_new_card_introduction_limit(HashSet::new(), 2),
        );

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

    #[test]
    fn new_card_limit_counts_standard_and_reverse_as_one_parent_card() {
        let first = datum("One", "Uno").with_card_id(1);
        let second = datum("Two", "Dos").with_card_id(2);
        let first_key = DatumKey::for_datum(&first);
        let first_reverse_key = DatumKey::for_reverse_of(&first_key);
        let second_key = DatumKey::for_datum(&second);
        let data = vec![
            (first_key.clone(), &first),
            (first_reverse_key, &first),
            (second_key, &second),
        ];

        let scheduled = schedule_session_for_keys(
            &data,
            &HashMap::new(),
            SchedulerConfig::new(0, 1_000_000, 5)
                .with_new_card_introduction_limit(HashSet::new(), 1),
        );

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

    #[test]
    fn reverse_of_introduced_card_is_not_limited_as_a_new_parent_card() {
        let first = datum("One", "Uno").with_card_id(1);
        let second = datum("Two", "Dos").with_card_id(2);
        let first_key = DatumKey::for_datum(&first);
        let first_reverse_key = DatumKey::for_reverse_of(&first_key);
        let second_key = DatumKey::for_datum(&second);
        let data = vec![(first_reverse_key, &first), (second_key, &second)];

        let scheduled = schedule_session_for_keys(
            &data,
            &HashMap::new(),
            SchedulerConfig::new(0, 1_000_000, 5)
                .with_new_card_introduction_limit(HashSet::from([first_key]), 0),
        );

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

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(128))]

        #[test]
        fn limited_new_card_queue_preserves_session_invariants(
            generated_parents in prop::collection::vec((0u8..5, any::<bool>()), 0usize..30),
            minimum_cards_per_session in 0usize..50,
            new_parent_limit in 0usize..30,
            filler_seed in any::<u64>(),
        ) {
            let now = 2_000_000;
            let data = generated_parents
                .iter()
                .enumerate()
                .map(|(index, _)| {
                    datum(&format!("Prompt {index}"), &format!("Response {index}"))
                        .with_card_id((index + 1) as u64)
                })
                .collect::<Vec<_>>();
            let mut keyed_data = Vec::new();
            let mut schedules = HashMap::new();
            let mut introduced_parent_keys = HashSet::new();
            let mut expectations = Vec::new();

            for (parent_index, ((raw_state, has_reverse), datum)) in
                generated_parents.iter().zip(data.iter()).enumerate()
            {
                let state = GeneratedSessionParentState::from_raw(*raw_state);
                let parent_key = DatumKey::for_datum(datum);
                let mut item_keys = vec![parent_key.clone()];
                if *has_reverse {
                    item_keys.push(DatumKey::for_reverse_of(&parent_key));
                }
                if matches!(state, GeneratedSessionParentState::IntroducedNew) {
                    introduced_parent_keys.insert(parent_key.clone());
                }

                for key in item_keys {
                    keyed_data.push((key.clone(), datum));
                    expectations.push(GeneratedSessionItemExpectation {
                        parent_key: parent_key.clone(),
                        state,
                    });

                    if !state.is_new() {
                        let (memory_state, due_at_unix_seconds) = match state {
                            GeneratedSessionParentState::DueLearning => {
                                (CardMemoryState::Learning, now - 60)
                            }
                            GeneratedSessionParentState::DueReview => {
                                (CardMemoryState::Review, now - DAY_IN_SECONDS)
                            }
                            GeneratedSessionParentState::NotDueReview => {
                                (CardMemoryState::Review, now + DAY_IN_SECONDS)
                            }
                            GeneratedSessionParentState::UnintroducedNew
                            | GeneratedSessionParentState::IntroducedNew => unreachable!(),
                        };
                        schedules.insert(
                            key.clone(),
                            stored_schedule_for_key_with_state(
                                key,
                                memory_state,
                                due_at_unix_seconds,
                                1,
                            ),
                        );
                    }
                }

                prop_assert_eq!(
                    parent_key,
                    DatumKey::for_card_id((parent_index + 1) as u64)
                );
            }

            let mut selected_unintroduced_parent_keys = HashSet::new();
            let mut seen_unintroduced_parent_keys = HashSet::new();
            for expectation in expectations
                .iter()
                .filter(|expectation| expectation.state.is_unintroduced_new())
            {
                if seen_unintroduced_parent_keys.insert(expectation.parent_key.clone())
                    && selected_unintroduced_parent_keys.len() < new_parent_limit
                {
                    selected_unintroduced_parent_keys.insert(expectation.parent_key.clone());
                }
            }

            let expected_due_phase_indexes = expectations
                .iter()
                .enumerate()
                .filter_map(|(index, expectation)| {
                    (expectation.state.is_required_due()
                        || (expectation.state.is_unintroduced_new()
                            && selected_unintroduced_parent_keys
                                .contains(&expectation.parent_key)))
                    .then_some(index)
                })
                .collect::<HashSet<_>>();
            let filler_candidate_count = expectations
                .iter()
                .filter(|expectation| expectation.state.is_filler_candidate())
                .count();
            let expected_len = expected_due_phase_indexes.len()
                + filler_candidate_count.min(
                    minimum_cards_per_session.saturating_sub(expected_due_phase_indexes.len()),
                );

            let scheduled = schedule_session_for_keys(
                &keyed_data,
                &schedules,
                SchedulerConfig::new(minimum_cards_per_session, now, filler_seed)
                    .with_new_card_introduction_limit(introduced_parent_keys, new_parent_limit),
            );
            let scheduled_indexes = scheduled
                .iter()
                .map(ScheduledCard::deck_index)
                .collect::<HashSet<_>>();

            prop_assert_eq!(scheduled.len(), expected_len);
            prop_assert_eq!(scheduled_indexes.len(), scheduled.len());
            for expected_index in expected_due_phase_indexes {
                prop_assert!(scheduled_indexes.contains(&expected_index));
            }

            let scheduled_unintroduced_parent_keys = scheduled
                .iter()
                .filter_map(|card| {
                    let expectation = &expectations[card.deck_index()];
                    expectation
                        .state
                        .is_unintroduced_new()
                        .then(|| expectation.parent_key.clone())
                })
                .collect::<HashSet<_>>();
            prop_assert!(scheduled_unintroduced_parent_keys.len() <= new_parent_limit);
            prop_assert_eq!(
                &scheduled_unintroduced_parent_keys,
                &selected_unintroduced_parent_keys
            );

            let scheduled_unintroduced_indexes = scheduled
                .iter()
                .filter_map(|card| {
                    let expectation = &expectations[card.deck_index()];
                    expectation.state.is_unintroduced_new().then_some(card.deck_index())
                })
                .collect::<Vec<_>>();
            let expected_unintroduced_indexes = expectations
                .iter()
                .enumerate()
                .filter_map(|(index, expectation)| {
                    (expectation.state.is_unintroduced_new()
                        && selected_unintroduced_parent_keys.contains(&expectation.parent_key))
                    .then_some(index)
                })
                .collect::<Vec<_>>();
            prop_assert_eq!(scheduled_unintroduced_indexes, expected_unintroduced_indexes);

            let mut seen_minimum_fill = false;
            for card in scheduled {
                let expectation = &expectations[card.deck_index()];
                match card.selection_kind() {
                    ScheduledSelectionKind::Due => {
                        prop_assert!(!seen_minimum_fill);
                        prop_assert!(
                            expectation.state.is_required_due()
                                || (expectation.state.is_unintroduced_new()
                                    && selected_unintroduced_parent_keys
                                        .contains(&expectation.parent_key))
                        );
                    }
                    ScheduledSelectionKind::MinimumFill => {
                        seen_minimum_fill = true;
                        prop_assert!(expectation.state.is_filler_candidate());
                    }
                }
            }
        }
    }

    #[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<_>>()
        );
    }
}
