use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};

use cosimo::{
    CardSchedule, Datum, DatumKey, DeckMetadata, PromptGuess, RecallFeedback, Review, ReviewPass,
    ReviewPassSummary as StoredReviewPassSummary, SchedulerConfig, SqliteReviewStore,
    format_deck_with_metadata, parse_deck_file, schedule_session_for_keys,
};

use crate::app_config::{AppSettings, AutomaticFullDeckBackupPolicy, ReviewDbBackupPolicy};
use crate::card_details_model::{CardDetailsState, card_details_text};
use crate::deck_bundle::write_automatic_deck_bundle_backup_with_review_database_if_due;
use crate::deck_data::{
    card_ids_used_by_deck_and_store, deck_needs_card_ids, deck_needs_next_card_id_metadata_repair,
    ensure_next_card_id_metadata, enumerate_deck_cards, migrate_pending_legacy_content_keys,
    next_monotonic_card_id, review_db_path_for_deck,
};
use crate::deck_properties_model::deck_properties_text;
use crate::exam_mode::{
    ExamBuildError, ExamPass, ExamSizeRequest, ExamSummary, GradedExamAnswer, build_exam_pass,
};
use crate::file_ops::{
    apply_deck_backup_policy, backup_review_database, default_backup_dir, write_deck_file_durably,
    write_text_file_durably,
};
#[cfg(test)]
use crate::learning_dashboard_model::learning_dashboard_markdown_text;
use crate::learning_dashboard_model::{
    LearningDashboardCard, LearningDashboardReportInput, LearningDashboardTextSection,
    learning_dashboard_report_with_calibration_rule, learning_dashboard_sections,
};
use crate::localization::Localizer;
use crate::quiz_mode::{
    QuizAnswer, QuizBuildError, QuizPass, QuizSizeRequest, QuizSummary, build_quiz_pass,
};
use crate::ready_list::{
    ReadyCardFilter, ReadyCardSort, ReadyCardSortDirection, ReadyStudyItemFilterState,
    ReadyStudyItemReviewStats, normalize_ready_selection, ready_card_matches_text_filter,
    ready_study_item_indices_for_filter,
};
use crate::ready_screen::{
    append_status_warning, schedule_preview_if_started_now, scheduled_study_due_keys,
};
use crate::review_audio::ReviewAudioPlayer;
use crate::review_session::{
    ReviewCycleSummary, ReviewDeferralProgress, ReviewPhase, ReviewSession, ReviewSessionKind,
};
use crate::runtime::{current_time_and_seed, shuffle_indices};
use crate::study_item::{
    StudyItem, normalized_reverse_prompt, reversible_candidate_indices, study_items_for_deck,
};

const DEFAULT_DEFER_SECONDS: i64 = 24 * 60 * 60;
const NEW_CARD_INTRODUCTION_WINDOW_SECONDS: i64 = 24 * 60 * 60;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReadyShownBulkAction {
    Flag,
    Unflag,
    Suspend,
    Unsuspend,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ReadyShownBulkActionPreview {
    pub(crate) shown_card_count: usize,
    pub(crate) affected_card_count: usize,
}

struct StudyPassStartPreflight {
    items: Vec<StudyItem>,
    active_indices: Vec<usize>,
}

fn apply_legacy_reversal_exclusions_to_deck(
    data: &mut [Datum],
    excluded_keys: &HashSet<DatumKey>,
) -> bool {
    let mut changed = false;
    for datum in data {
        let datum_key = DatumKey::for_datum(datum);
        let legacy_key = DatumKey::for_legacy_content(datum);
        if (excluded_keys.contains(&datum_key) || excluded_keys.contains(&legacy_key))
            && !datum.is_reversal_prohibited()
        {
            *datum = datum.clone().with_reversal_prohibited(true);
            changed = true;
        }
    }
    changed
}

fn duplicate_prompt_card_count(data: &[Datum]) -> usize {
    let mut prompt_counts: HashMap<String, usize> = HashMap::new();
    for datum in data {
        *prompt_counts
            .entry(normalized_reverse_prompt(datum.prompt()))
            .or_default() += 1;
    }
    data.iter()
        .filter(|datum| prompt_counts[&normalized_reverse_prompt(datum.prompt())] > 1)
        .count()
}

fn duplicate_prompt_groups(data: &[Datum]) -> Vec<DuplicatePromptGroup> {
    let mut prompt_counts: HashMap<String, usize> = HashMap::new();
    for datum in data {
        *prompt_counts
            .entry(normalized_reverse_prompt(datum.prompt()))
            .or_default() += 1;
    }

    let mut seen_prompts = HashSet::new();
    let mut groups = Vec::new();
    for datum in data {
        let normalized_prompt = normalized_reverse_prompt(datum.prompt());
        if prompt_counts[&normalized_prompt] < 2 || !seen_prompts.insert(normalized_prompt.clone())
        {
            continue;
        }
        let candidates = data
            .iter()
            .enumerate()
            .filter(|(_, candidate)| {
                normalized_reverse_prompt(candidate.prompt()) == normalized_prompt
            })
            .map(|(index, candidate)| DuplicatePromptCandidate {
                datum_key: DatumKey::for_datum(candidate),
                card_number: index + 1,
                response: candidate.response().to_string(),
            })
            .collect();
        groups.push(DuplicatePromptGroup {
            normalized_prompt,
            prompt: datum.prompt().to_string(),
            candidates,
        });
    }
    groups
}

fn deck_datum_keys(data: &[Datum]) -> HashSet<DatumKey> {
    data.iter().map(DatumKey::for_datum).collect()
}

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

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DuplicatePromptGroup {
    pub(crate) normalized_prompt: String,
    pub(crate) prompt: String,
    pub(crate) candidates: Vec<DuplicatePromptCandidate>,
}

pub(crate) struct AppState {
    // The GUI is single-deck. AppState owns the loaded deck, its neighboring
    // review database, the current review session, and the ready-list view
    // state. Event handlers mutate this object and then render controls from it.
    pub(crate) localizer: Localizer,
    pub(crate) settings: AppSettings,
    pub(crate) deck_path: PathBuf,
    pub(crate) review_db_path: PathBuf,
    pub(crate) backup_dir: PathBuf,
    pub(crate) deck_metadata: DeckMetadata,
    pub(crate) data: Vec<Datum>,
    pub(crate) session: ReviewSession,
    pub(crate) selected_ready_key: Option<DatumKey>,
    pub(crate) selected_ready_index: Option<usize>,
    pub(crate) ready_card_filter: ReadyCardFilter,
    pub(crate) ready_card_text_filter: String,
    pub(crate) ready_card_sort: ReadyCardSort,
    pub(crate) ready_card_sort_direction: ReadyCardSortDirection,
    pub(crate) ready_visible_indices: RefCell<Vec<usize>>,
    pub(crate) ready_list_sync_in_progress: Cell<bool>,
    pub(crate) ready_due_notifications_suspended: bool,
    pub(crate) last_ready_due_notification_count: Option<usize>,
    pub(crate) ready_summary_warnings: Vec<String>,
    pub(crate) pending_warning_dialog: Option<String>,
    pub(crate) store: SqliteReviewStore,
    pub(crate) last_status: String,
    pub(crate) review_audio_player: RefCell<ReviewAudioPlayer>,
}

fn scheduler_config_with_new_card_limit_from_first_reviews(
    config: &SchedulerConfig,
    new_cards_per_day: usize,
    first_reviewed_by_key: HashMap<DatumKey, i64>,
) -> SchedulerConfig {
    let mut first_reviewed_by_parent: HashMap<DatumKey, i64> = HashMap::new();
    for (datum_key, reviewed_at) in first_reviewed_by_key {
        let parent_key = datum_key
            .parent_key_for_reverse()
            .unwrap_or_else(|| datum_key.clone());
        first_reviewed_by_parent
            .entry(parent_key)
            .and_modify(|existing| *existing = (*existing).min(reviewed_at))
            .or_insert(reviewed_at);
    }

    let introduction_window_start = config
        .now_unix_seconds()
        .saturating_sub(NEW_CARD_INTRODUCTION_WINDOW_SECONDS);
    let introduced_recently = first_reviewed_by_parent
        .values()
        .filter(|reviewed_at| **reviewed_at >= introduction_window_start)
        .count();
    let remaining_new_cards = new_cards_per_day.saturating_sub(introduced_recently);

    config.clone().with_new_card_introduction_limit(
        first_reviewed_by_parent.into_keys().collect(),
        remaining_new_cards,
    )
}

impl AppState {
    #[cfg(test)]
    pub(crate) fn load(
        deck_path: impl AsRef<Path>,
        review_db_path: impl AsRef<Path>,
    ) -> Result<Self, String> {
        let deck_path = deck_path.as_ref();
        Self::load_with_backup_dir(
            deck_path,
            review_db_path.as_ref(),
            deck_path
                .parent()
                .unwrap_or_else(|| Path::new("."))
                .join("backups"),
            Localizer::default(),
            AppSettings::default(),
        )
    }

    fn load_with_localizer(
        deck_path: impl AsRef<Path>,
        review_db_path: impl AsRef<Path>,
        localizer: Localizer,
        settings: AppSettings,
    ) -> Result<Self, String> {
        Self::load_with_backup_dir(
            deck_path,
            review_db_path,
            default_backup_dir(),
            localizer,
            settings,
        )
    }

    pub(crate) fn load_with_backup_dir(
        deck_path: impl AsRef<Path>,
        review_db_path: impl AsRef<Path>,
        backup_dir: impl AsRef<Path>,
        localizer: Localizer,
        settings: AppSettings,
    ) -> Result<Self, String> {
        let deck_path = deck_path.as_ref();
        let review_db_path = review_db_path.as_ref();
        let backup_dir = backup_dir.as_ref();
        let deck_text = fs::read_to_string(deck_path)
            .map_err(|source| localizer.could_not_read_deck_file(deck_path, &source.to_string()))?;
        let parsed_deck = parse_deck_file(&deck_text)
            .map_err(|source| localizer.could_not_parse_deck(deck_path, &source.to_string()))?;
        let mut deck_metadata = parsed_deck.metadata().clone();
        let parsed_data = parsed_deck.into_data();
        let store = SqliteReviewStore::open(review_db_path).map_err(|source| {
            localizer.could_not_open_review_database(review_db_path, &source.to_string())
        })?;
        let legacy_reversal_exclusions =
            store
                .legacy_reversal_excluded_datum_keys()
                .map_err(|source| {
                    localizer.could_not_open_review_database(review_db_path, &source.to_string())
                })?;
        let next_card_id_needs_repair =
            deck_needs_next_card_id_metadata_repair(&parsed_data, &store, &deck_metadata).map_err(
                |source| {
                    localizer.could_not_open_review_database(review_db_path, &source.to_string())
                },
            )?;
        let needs_deck_migration = !deck_metadata.has_header()
            || deck_needs_card_ids(&parsed_data)
            || next_card_id_needs_repair
            || !legacy_reversal_exclusions.is_empty();
        if needs_deck_migration {
            apply_deck_backup_policy(deck_path, backup_dir, settings.deck_backup_policy)
                .map_err(|error| localizer.could_not_write_path(deck_path, &error.to_string()))?;
        }
        let mut changed = deck_metadata.ensure_v2_header();
        let (mut data, card_id_changed) =
            enumerate_deck_cards(parsed_data, &store, &mut deck_metadata).map_err(|source| {
                localizer.could_not_open_review_database(review_db_path, &source.to_string())
            })?;
        if card_id_changed {
            changed = true;
        }
        if apply_legacy_reversal_exclusions_to_deck(&mut data, &legacy_reversal_exclusions) {
            changed = true;
        }
        if changed {
            let deck_text = format_deck_with_metadata(&deck_metadata, &data)
                .map_err(|error| localizer.could_not_save_deck(&error.to_string()))?;
            write_text_file_durably(deck_path, &deck_text)
                .map_err(|error| localizer.could_not_write_path(deck_path, &error.to_string()))?;
        }
        migrate_pending_legacy_content_keys(&data, &store).map_err(|source| {
            localizer.could_not_open_review_database(review_db_path, &source.to_string())
        })?;
        let prohibited_keys: Vec<_> = data
            .iter()
            .filter(|datum| datum.is_reversal_prohibited())
            .map(DatumKey::for_datum)
            .collect();
        if !prohibited_keys.is_empty() {
            store
                .set_datums_reversible(&prohibited_keys, false)
                .map_err(|source| {
                    localizer.could_not_open_review_database(review_db_path, &source.to_string())
                })?;
        }
        store.verify_deck_consistency(&data).map_err(|source| {
            localizer.could_not_open_review_database(review_db_path, &source.to_string())
        })?;
        if let Ok((now_unix_seconds, _)) = current_time_and_seed() {
            store
                .clear_inactive_card_states(now_unix_seconds)
                .map_err(|source| {
                    localizer.could_not_open_review_database(review_db_path, &source.to_string())
                })?;
            let deck_keys = deck_datum_keys(&data);
            store
                .record_card_first_seen(deck_keys.iter(), now_unix_seconds)
                .map_err(|source| {
                    localizer.could_not_open_review_database(review_db_path, &source.to_string())
                })?;
        }
        if !legacy_reversal_exclusions.is_empty() {
            store.clear_legacy_reversal_exclusions().map_err(|source| {
                localizer.could_not_open_review_database(review_db_path, &source.to_string())
            })?;
        }

        let automatic_full_backup_warning = if settings.automatic_full_deck_backup_policy
            == AutomaticFullDeckBackupPolicy::Daily
        {
            match current_time_and_seed() {
                Ok((now_unix_seconds, _)) => {
                    match write_automatic_deck_bundle_backup_with_review_database_if_due(
                        deck_path,
                        review_db_path,
                        &store,
                        backup_dir,
                        now_unix_seconds,
                    ) {
                        Ok(_) => None,
                        Err(error) => {
                            Some(localizer.could_not_write_automatic_full_deck_backup_status(
                                deck_path,
                                backup_dir,
                                &error.to_string(),
                            ))
                        }
                    }
                }
                Err(error) => Some(localizer.could_not_write_automatic_full_deck_backup_status(
                    deck_path,
                    backup_dir,
                    &error.to_string(),
                )),
            }
        } else {
            None
        };

        let duplicate_prompt_count = duplicate_prompt_card_count(&data);
        let selected_ready_key = data.first().map(DatumKey::for_datum);
        let ready_visible_indices = (0..data.len()).collect();
        let mut last_status = if duplicate_prompt_count > 0 {
            localizer.duplicate_prompts_warning(duplicate_prompt_count)
        } else {
            String::new()
        };
        let ready_summary_warnings: Vec<String> =
            automatic_full_backup_warning.iter().cloned().collect();
        last_status = append_status_warning(last_status, automatic_full_backup_warning);
        let state = Self {
            localizer,
            settings,
            deck_path: deck_path.to_path_buf(),
            review_db_path: review_db_path.to_path_buf(),
            backup_dir: backup_dir.to_path_buf(),
            deck_metadata,
            selected_ready_key,
            selected_ready_index: if data.is_empty() { None } else { Some(0) },
            data,
            session: ReviewSession::Ready,
            ready_card_filter: ReadyCardFilter::All,
            ready_card_text_filter: String::new(),
            ready_card_sort: ReadyCardSort::DeckOrder,
            ready_card_sort_direction: ReadyCardSort::DeckOrder.default_direction(),
            ready_visible_indices: RefCell::new(ready_visible_indices),
            ready_list_sync_in_progress: Cell::new(false),
            ready_due_notifications_suspended: false,
            last_ready_due_notification_count: None,
            ready_summary_warnings,
            pending_warning_dialog: None,
            store,
            last_status,
            review_audio_player: RefCell::new(ReviewAudioPlayer::default()),
        };
        Ok(state)
    }

    pub(crate) fn take_pending_warning_dialog(&mut self) -> Option<String> {
        self.pending_warning_dialog.take()
    }

    #[cfg(test)]
    pub(crate) fn load_deck(deck_path: impl AsRef<Path>) -> Result<Self, String> {
        let deck_path = deck_path.as_ref();
        Self::load_with_backup_dir(
            deck_path,
            review_db_path_for_deck(deck_path),
            deck_path
                .parent()
                .unwrap_or_else(|| Path::new("."))
                .join("backups"),
            Localizer::default(),
            AppSettings::default(),
        )
    }

    pub(crate) fn load_deck_with_localizer(
        deck_path: impl AsRef<Path>,
        localizer: Localizer,
        settings: AppSettings,
    ) -> Result<Self, String> {
        let deck_path = deck_path.as_ref();
        Self::load_with_localizer(
            deck_path,
            review_db_path_for_deck(deck_path),
            localizer,
            settings,
        )
    }

    pub(crate) fn load_reversible_card_keys(&self) -> Result<HashSet<DatumKey>, String> {
        self.store
            .reversible_datum_keys()
            .map_err(|error| error.to_string())
    }

    pub(crate) fn load_reversal_excluded_card_keys(&self) -> Result<HashSet<DatumKey>, String> {
        Ok(self
            .data
            .iter()
            .filter(|datum| datum.is_reversal_prohibited())
            .map(DatumKey::for_datum)
            .collect())
    }

    pub(crate) fn study_items(&self) -> Result<Vec<StudyItem>, String> {
        Ok(study_items_for_deck(
            &self.data,
            &self.load_reversible_card_keys()?,
        ))
    }

    pub(crate) fn fallback_study_items(&self) -> Vec<StudyItem> {
        study_items_for_deck(&self.data, &HashSet::new())
    }

    pub(crate) fn current_datum(&self) -> Option<Datum> {
        self.current_study_item().map(|item| item.datum().clone())
    }

    fn current_deck_index(&self) -> Option<usize> {
        self.current_study_item().map(|item| item.deck_index())
    }

    pub(crate) fn current_study_item(&self) -> Option<StudyItem> {
        let index = self.session.current_deck_index()?;
        self.study_items()
            .unwrap_or_else(|_| self.fallback_study_items())
            .get(index)
            .cloned()
    }

    #[cfg(test)]
    pub(crate) fn selected_ready_datum(&self) -> Option<&Datum> {
        self.selected_ready_deck_index()
            .and_then(|index| self.data.get(index))
    }

    #[cfg(test)]
    fn selected_ready_deck_index(&self) -> Option<usize> {
        self.selected_ready_study_item()
            .ok()
            .flatten()
            .map(|item| item.deck_index())
    }

    pub(crate) fn selected_ready_study_item(&self) -> Result<Option<StudyItem>, String> {
        let Some(selected_key) = &self.selected_ready_key else {
            return Ok(None);
        };
        Ok(self
            .study_items()?
            .into_iter()
            .find(|item| item.datum_key() == selected_key))
    }

    pub(crate) fn selected_ready_card_details_text(&self) -> Result<Option<String>, String> {
        let Some(item) = self.selected_ready_study_item()? else {
            return Ok(None);
        };
        let datum = item.datum();
        let reviews = self
            .store
            .reviews_for_datum_key(item.datum_key())
            .map_err(|error| error.to_string())?;
        let schedule = self
            .store
            .schedule_for_datum_key(item.datum_key())
            .map_err(|error| error.to_string())?;
        let suspended = self.load_suspended_card_keys()?;
        let flagged = self.load_flagged_card_keys()?;
        let deferred = self.load_deferred_card_keys()?;
        let non_reversible = self.load_reversal_excluded_card_keys()?;
        let reversible = self.load_reversible_card_keys()?;
        let parent_key = item.parent_datum_key();
        let card_state = CardDetailsState {
            is_suspended: suspended.contains(item.datum_key()),
            is_flagged: flagged.contains(item.datum_key()),
            is_deferred: deferred.contains(item.datum_key()),
            is_non_reversible: non_reversible.contains(&parent_key),
            is_reversible: !item.is_reverse() && reversible.contains(&parent_key),
            is_reverse: item.is_reverse(),
        };
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .unwrap_or(0);

        Ok(Some(card_details_text(
            self.localizer,
            item.datum_key(),
            datum,
            &reviews,
            schedule.as_ref(),
            card_state,
            now_unix_seconds,
        )))
    }

    #[cfg(test)]
    pub(crate) fn learning_dashboard_text(&self) -> Result<String, String> {
        Ok(learning_dashboard_markdown_text(
            &self.learning_dashboard_sections()?,
        ))
    }

    pub(crate) fn learning_dashboard_sections(
        &self,
    ) -> Result<Vec<LearningDashboardTextSection>, String> {
        let items = self.study_items()?;
        let schedules = self.load_study_item_schedules(&items)?;
        let suspended = self.load_suspended_study_item_keys(&items)?;
        let deferred = self.load_deferred_study_item_keys(&items)?;
        let mut reviews_by_key = HashMap::with_capacity(items.len());
        for item in &items {
            reviews_by_key.insert(
                item.datum_key().clone(),
                self.store
                    .reviews_for_datum_key(item.datum_key())
                    .map_err(|error| error.to_string())?,
            );
        }
        let dashboard_cards: Vec<_> = items
            .iter()
            .map(|item| LearningDashboardCard::new(item.datum_key().clone(), item.is_reverse()))
            .collect();
        let dashboard_card_keys = dashboard_cards
            .iter()
            .map(|card| card.datum_key().clone())
            .collect();
        let review_passes = self
            .store
            .review_pass_summaries_for_datum_keys(&dashboard_card_keys)
            .map_err(|error| error.to_string())?;
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .unwrap_or(0);
        let deck_card_keys = deck_datum_keys(&self.data);
        self.store
            .record_card_first_seen(deck_card_keys.iter(), now_unix_seconds)
            .map_err(|error| error.to_string())?;
        let first_seen_by_key = self
            .store
            .card_first_seen_for_datum_keys(&deck_card_keys)
            .map_err(|error| error.to_string())?;
        let report = learning_dashboard_report_with_calibration_rule(
            LearningDashboardReportInput {
                cards: &dashboard_cards,
                schedules: &schedules,
                suspended: &suspended,
                deferred: &deferred,
                reviews_by_key: &reviews_by_key,
                review_passes: &review_passes,
                first_seen_by_key: &first_seen_by_key,
                now_unix_seconds,
                new_cards_per_day: self.settings.new_cards_per_day,
            },
            self.settings.calibration_recall_rule,
        );

        Ok(learning_dashboard_sections(self.localizer, &report))
    }

    pub(crate) fn deck_properties_text(&self) -> String {
        deck_properties_text(self.localizer, &self.deck_metadata, &self.data)
    }

    #[cfg(test)]
    pub(crate) fn set_ready_card_filter(&mut self, filter: ReadyCardFilter) {
        self.ready_card_filter = filter;
        self.normalize_ready_selection_for_filter();
    }

    pub(crate) fn update_ready_card_filter_value(&mut self, filter: ReadyCardFilter) -> bool {
        if self.ready_card_filter == filter {
            return false;
        }
        self.ready_card_filter = filter;
        true
    }

    pub(crate) fn set_ready_card_text_filter(&mut self, text_filter: String) {
        self.ready_card_text_filter = text_filter;
        self.normalize_ready_selection_for_filter();
    }

    pub(crate) fn update_ready_card_text_filter_value(&mut self, text_filter: String) -> bool {
        if self.ready_card_text_filter == text_filter {
            return false;
        }
        self.ready_card_text_filter = text_filter;
        true
    }

    pub(crate) fn set_ready_card_sort(&mut self, sort: ReadyCardSort) {
        self.ready_card_sort = sort;
        self.ready_card_sort_direction = sort.default_direction();
        self.normalize_ready_selection_for_filter();
    }

    pub(crate) fn set_ready_card_sort_direction(&mut self, direction: ReadyCardSortDirection) {
        self.ready_card_sort_direction = direction;
        self.normalize_ready_selection_for_filter();
    }

    pub(crate) fn set_initial_ready_view_state(
        &mut self,
        filter: ReadyCardFilter,
        sort: ReadyCardSort,
        sort_direction: ReadyCardSortDirection,
    ) {
        self.ready_card_filter = filter;
        self.ready_card_sort = sort;
        self.ready_card_sort_direction = sort_direction;
        self.selected_ready_key = None;
        self.selected_ready_index = None;
        self.normalize_ready_selection_for_filter();
    }

    pub(crate) fn sync_ready_due_notification_baseline(&mut self) {
        self.last_ready_due_notification_count = match schedule_preview_if_started_now(self) {
            Ok(preview) => Some(preview.due_cards),
            Err(_) => None,
        };
    }

    pub(crate) fn suspend_ready_due_notifications(&mut self) {
        self.ready_due_notifications_suspended = true;
        self.sync_ready_due_notification_baseline();
    }

    pub(crate) fn resume_ready_due_notifications(&mut self) {
        self.ready_due_notifications_suspended = false;
        self.sync_ready_due_notification_baseline();
    }

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

    pub(crate) fn normalize_ready_selection_for_filter(&mut self) {
        let visible_indices = self.ready_visible_indices_for_current_filter();
        let items = self
            .study_items()
            .unwrap_or_else(|_| self.fallback_study_items());
        self.normalize_ready_selection_for_visible_items(&items, visible_indices);
    }

    pub(crate) fn normalize_ready_selection_for_visible_items(
        &mut self,
        items: &[StudyItem],
        visible_indices: Vec<usize>,
    ) {
        let selected_index = self.selected_ready_key.as_ref().and_then(|selected_key| {
            items
                .iter()
                .position(|item| item.datum_key() == selected_key)
        });
        self.selected_ready_index = normalize_ready_selection(selected_index, &visible_indices);
        self.selected_ready_key = self
            .selected_ready_index
            .and_then(|index| items.get(index))
            .map(|item| item.datum_key().clone());
        *self.ready_visible_indices.borrow_mut() = visible_indices;
    }

    pub(crate) fn ready_visible_indices_for_current_filter(&self) -> Vec<usize> {
        self.try_ready_visible_indices_for_current_filter()
            .unwrap_or_else(|_| {
                let items = self.fallback_study_items();
                (0..items.len())
                    .filter(|index| {
                        ready_card_matches_text_filter(
                            items[*index].datum(),
                            &self.ready_card_text_filter,
                        )
                    })
                    .collect()
            })
    }

    pub(crate) fn try_ready_visible_indices_for_current_filter(
        &self,
    ) -> Result<Vec<usize>, String> {
        let items = self.study_items()?;
        let schedules = self.load_study_item_schedules(&items)?;
        let suspended = self.load_suspended_study_item_keys(&items)?;
        let flagged = self.load_flagged_study_item_keys(&items)?;
        let deferred = self.load_deferred_study_item_keys(&items)?;
        let non_reversible = self.load_reversal_excluded_card_keys()?;
        let review_stats = self.load_study_item_review_stats(&items)?;
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .unwrap_or(0);
        let scheduled_study_due = scheduled_study_due_keys(
            self,
            &items,
            &schedules,
            &suspended,
            &deferred,
            now_unix_seconds,
            0,
        )?;
        let filter_state = ReadyStudyItemFilterState {
            schedules: &schedules,
            suspended: &suspended,
            flagged: &flagged,
            deferred: &deferred,
            non_reversible: &non_reversible,
            scheduled_study_due: Some(&scheduled_study_due),
            review_stats: Some(&review_stats),
            now_unix_seconds,
        };
        Ok(ready_study_item_indices_for_filter(
            &items,
            self.ready_card_filter,
            &self.ready_card_text_filter,
            self.ready_card_sort,
            self.ready_card_sort_direction,
            &filter_state,
        ))
    }

    fn ready_visible_study_item_keys_for_data(
        &self,
        data: &[Datum],
    ) -> Result<Vec<DatumKey>, String> {
        let reversible = self.load_reversible_card_keys()?;
        let items = study_items_for_deck(data, &reversible);
        let schedules = self.load_study_item_schedules(&items)?;
        let suspended = self.load_suspended_study_item_keys(&items)?;
        let flagged = self.load_flagged_study_item_keys(&items)?;
        let deferred = self.load_deferred_study_item_keys(&items)?;
        let non_reversible = self.load_reversal_excluded_card_keys()?;
        let review_stats = self.load_study_item_review_stats(&items)?;
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .unwrap_or(0);
        let scheduled_study_due = scheduled_study_due_keys(
            self,
            &items,
            &schedules,
            &suspended,
            &deferred,
            now_unix_seconds,
            0,
        )?;
        let filter_state = ReadyStudyItemFilterState {
            schedules: &schedules,
            suspended: &suspended,
            flagged: &flagged,
            deferred: &deferred,
            non_reversible: &non_reversible,
            scheduled_study_due: Some(&scheduled_study_due),
            review_stats: Some(&review_stats),
            now_unix_seconds,
        };
        Ok(ready_study_item_indices_for_filter(
            &items,
            self.ready_card_filter,
            &self.ready_card_text_filter,
            self.ready_card_sort,
            self.ready_card_sort_direction,
            &filter_state,
        )
        .into_iter()
        .filter_map(|index| items.get(index))
        .map(|item| item.datum_key().clone())
        .collect())
    }

    pub(crate) fn ready_shown_bulk_action_preview(
        &self,
        action: ReadyShownBulkAction,
    ) -> Result<ReadyShownBulkActionPreview, String> {
        let shown_keys = self.ready_visible_study_item_keys_for_data(&self.data)?;
        let affected_card_count = self
            .ready_shown_bulk_action_targets(action, &shown_keys)?
            .len();
        Ok(ReadyShownBulkActionPreview {
            shown_card_count: shown_keys.len(),
            affected_card_count,
        })
    }

    fn ready_shown_bulk_action_targets(
        &self,
        action: ReadyShownBulkAction,
        shown_keys: &[DatumKey],
    ) -> Result<Vec<DatumKey>, String> {
        let active_keys = match action {
            ReadyShownBulkAction::Flag | ReadyShownBulkAction::Unflag => {
                self.load_flagged_card_keys()?
            }
            ReadyShownBulkAction::Suspend | ReadyShownBulkAction::Unsuspend => {
                self.load_suspended_card_keys()?
            }
        };
        let should_be_active = matches!(
            action,
            ReadyShownBulkAction::Flag | ReadyShownBulkAction::Suspend
        );

        Ok(shown_keys
            .iter()
            .filter(|datum_key| active_keys.contains(*datum_key) != should_be_active)
            .cloned()
            .collect())
    }

    pub(crate) fn apply_ready_shown_bulk_action(
        &mut self,
        action: ReadyShownBulkAction,
    ) -> Result<usize, String> {
        self.ensure_card_modification_is_allowed()?;
        let shown_keys = self.ready_visible_study_item_keys_for_data(&self.data)?;
        let target_keys = self.ready_shown_bulk_action_targets(action, &shown_keys)?;
        if target_keys.is_empty() {
            return Ok(0);
        }

        match action {
            ReadyShownBulkAction::Flag => self
                .store
                .set_datums_flagged(&target_keys, true)
                .map_err(|error| error.to_string())?,
            ReadyShownBulkAction::Unflag => self
                .store
                .set_datums_flagged(&target_keys, false)
                .map_err(|error| error.to_string())?,
            ReadyShownBulkAction::Suspend => self
                .store
                .set_datums_suspended(&target_keys, true)
                .map_err(|error| error.to_string())?,
            ReadyShownBulkAction::Unsuspend => self
                .store
                .set_datums_suspended(&target_keys, false)
                .map_err(|error| error.to_string())?,
        }

        let affected_card_count = target_keys.len();
        self.last_status = match action {
            ReadyShownBulkAction::Flag => self
                .localizer
                .flagged_shown_cards_status(affected_card_count),
            ReadyShownBulkAction::Unflag => self
                .localizer
                .unflagged_shown_cards_status(affected_card_count),
            ReadyShownBulkAction::Suspend => self
                .localizer
                .suspended_shown_cards_status(affected_card_count),
            ReadyShownBulkAction::Unsuspend => self
                .localizer
                .unsuspended_shown_cards_status(affected_card_count),
        };
        self.normalize_ready_selection_for_filter();
        Ok(affected_card_count)
    }

    fn visible_selection_successor_candidates(
        &self,
        selected_key: &DatumKey,
    ) -> Result<Vec<DatumKey>, String> {
        let visible_keys = self.ready_visible_study_item_keys_for_data(&self.data)?;
        let Some(selected_position) = visible_keys.iter().position(|key| key == selected_key)
        else {
            return Ok(visible_keys);
        };

        Ok(visible_keys[selected_position + 1..]
            .iter()
            .chain(visible_keys[..selected_position].iter().rev())
            .cloned()
            .collect())
    }

    fn selected_visible_key_after_change(
        &self,
        data: &[Datum],
        candidates: &[DatumKey],
    ) -> Result<Option<DatumKey>, String> {
        let visible_keys = self.ready_visible_study_item_keys_for_data(data)?;
        Ok(candidates
            .iter()
            .find(|candidate| visible_keys.iter().any(|key| key == *candidate))
            .cloned()
            .or_else(|| visible_keys.first().cloned()))
    }

    #[cfg(test)]
    pub(crate) fn select_ready_index(&mut self, index: usize) {
        let items = self
            .study_items()
            .unwrap_or_else(|_| self.fallback_study_items());
        if let Some(item) = items.get(index) {
            self.selected_ready_index = Some(index);
            self.selected_ready_key = Some(item.datum_key().clone());
        }
    }

    pub(crate) fn start_cycle(&mut self) {
        match current_time_and_seed() {
            Ok((now_unix_seconds, filler_seed)) => {
                self.start_cycle_with_config(SchedulerConfig::new(
                    self.settings.minimum_cards_per_session,
                    now_unix_seconds,
                    filler_seed,
                ));
            }
            Err(error) => {
                self.last_status = self.localizer.could_not_start_scheduled_pass(&error);
            }
        }
    }

    pub(crate) fn start_cycle_with_config(&mut self, config: SchedulerConfig) {
        self.pending_warning_dialog = None;
        if self.data.is_empty() {
            self.last_status = self
                .localizer
                .no_cards_available_to_start_pass()
                .to_string();
            return;
        }

        if let Err(error) = self.clear_inactive_card_states(config.now_unix_seconds()) {
            self.session.reset();
            self.last_status = self.localizer.could_not_build_review_queue(&error);
            return;
        }

        let review_queue = match self.build_review_queue(&config) {
            Ok(review_queue) => review_queue,
            Err(error) => {
                self.session.reset();
                self.last_status = error;
                return;
            }
        };

        if review_queue.is_empty() {
            self.session.reset();
            self.last_status = self
                .localizer
                .no_cards_scheduled_to_start_pass(self.settings.minimum_cards_per_session);
            return;
        }

        let review_queue_len = review_queue.len();
        let study_item_count = self
            .study_items()
            .unwrap_or_else(|_| self.fallback_study_items())
            .len();
        self.start_study_session(
            review_queue,
            study_item_count,
            ReviewSessionKind::ScheduledStudy,
            config.filler_seed(),
            config.now_unix_seconds(),
            self.localizer.started_pass(review_queue_len),
        );
    }

    pub(crate) fn force_start_cycle(&mut self) {
        match current_time_and_seed() {
            Ok((now_unix_seconds, filler_seed)) => {
                self.force_start_cycle_with_seed(filler_seed, now_unix_seconds)
            }
            Err(error) => {
                self.last_status = self.localizer.could_not_start_scheduled_pass(&error);
            }
        }
    }

    pub(crate) fn force_start_cycle_with_seed(&mut self, seed: u64, now_unix_seconds: i64) {
        self.pending_warning_dialog = None;
        if self.data.is_empty() {
            self.last_status = self
                .localizer
                .no_cards_available_to_start_pass()
                .to_string();
            return;
        }

        if let Err(error) = self.clear_inactive_card_states(now_unix_seconds) {
            self.session.reset();
            self.last_status = self.localizer.could_not_build_review_queue(&error);
            return;
        }

        let items = match self.study_items() {
            Ok(items) => items,
            Err(error) => {
                self.last_status = self.localizer.could_not_build_review_queue(&error);
                return;
            }
        };
        let suspended = match self.load_suspended_study_item_keys(&items) {
            Ok(suspended) => suspended,
            Err(error) => {
                self.last_status = self.localizer.could_not_build_review_queue(&error);
                return;
            }
        };
        let deferred = match self.load_deferred_study_item_keys_at(&items, now_unix_seconds) {
            Ok(deferred) => deferred,
            Err(error) => {
                self.last_status = self.localizer.could_not_build_review_queue(&error);
                return;
            }
        };
        let mut review_queue: Vec<usize> = items
            .iter()
            .enumerate()
            .filter_map(|(index, item)| {
                (!suspended.contains(item.datum_key()) && !deferred.contains(item.datum_key()))
                    .then_some(index)
            })
            .collect();
        if review_queue.is_empty() {
            self.last_status = self
                .localizer
                .no_cards_available_to_start_pass()
                .to_string();
            return;
        }
        shuffle_indices(&mut review_queue, seed);
        let review_queue_len = review_queue.len();
        self.start_study_session(
            review_queue,
            items.len(),
            ReviewSessionKind::UnscheduledStudy,
            seed,
            now_unix_seconds,
            self.localizer.force_started_pass(review_queue_len),
        );
    }

    pub(crate) fn review_flagged_cards(&mut self) {
        match current_time_and_seed() {
            Ok((now_unix_seconds, seed)) => {
                self.review_flagged_cards_with_seed(seed, now_unix_seconds)
            }
            Err(error) => {
                self.last_status = self.localizer.could_not_build_review_queue(&error);
            }
        }
    }

    pub(crate) fn review_flagged_cards_with_seed(&mut self, seed: u64, now_unix_seconds: i64) {
        self.pending_warning_dialog = None;
        if self.data.is_empty() {
            self.last_status = self
                .localizer
                .no_flagged_cards_available_to_review()
                .to_string();
            return;
        }

        if let Err(error) = self.clear_inactive_card_states(now_unix_seconds) {
            self.session.reset();
            self.last_status = self.localizer.could_not_build_review_queue(&error);
            return;
        }

        let items = match self.study_items() {
            Ok(items) => items,
            Err(error) => {
                self.last_status = self.localizer.could_not_build_review_queue(&error);
                return;
            }
        };
        let mut review_queue = match self.available_flagged_review_queue(&items, now_unix_seconds) {
            Ok(review_queue) => review_queue,
            Err(error) => {
                self.last_status = self.localizer.could_not_build_review_queue(&error);
                return;
            }
        };
        if review_queue.is_empty() {
            self.last_status = self
                .localizer
                .no_flagged_cards_available_to_review()
                .to_string();
            return;
        }

        shuffle_indices(&mut review_queue, seed);
        let review_queue_len = review_queue.len();
        self.start_study_session(
            review_queue,
            items.len(),
            ReviewSessionKind::UnscheduledStudy,
            seed,
            now_unix_seconds,
            self.localizer.started_flagged_pass(review_queue_len),
        );
    }

    fn start_study_session(
        &mut self,
        review_queue: Vec<usize>,
        study_item_count: usize,
        kind: ReviewSessionKind,
        seed: u64,
        now_unix_seconds: i64,
        status: String,
    ) {
        self.session = ReviewSession::start_with_kind(
            review_queue,
            study_item_count,
            kind,
            seed,
            now_unix_seconds,
        )
        .expect("non-empty study queue checked before session start");
        self.last_ready_due_notification_count = None;
        let backup_warning = self.backup_review_database_on_session_start();
        self.pending_warning_dialog = backup_warning.clone();
        self.last_status = append_status_warning(status, backup_warning);
    }

    pub(crate) fn start_exam_pass(&mut self, request: ExamSizeRequest) -> Option<ExamPass> {
        match current_time_and_seed() {
            Ok((now_unix_seconds, filler_seed)) => {
                self.start_exam_pass_with_request(request, filler_seed, now_unix_seconds)
            }
            Err(error) => {
                self.last_status = self.localizer.could_not_build_review_queue(&error);
                None
            }
        }
    }

    pub(crate) fn start_exam_pass_with_request(
        &mut self,
        request: ExamSizeRequest,
        seed: u64,
        now_unix_seconds: i64,
    ) -> Option<ExamPass> {
        let StudyPassStartPreflight {
            items,
            active_indices,
        } = self.study_pass_start_preflight(now_unix_seconds)?;

        let (exam_pass, session_queue) =
            match build_exam_pass(&items, active_indices, request, seed, now_unix_seconds) {
                Ok(pass) => pass,
                Err(ExamBuildError::NoActiveCards) => {
                    self.note_no_study_mode_cards_available();
                    return None;
                }
            };

        self.start_study_mode_session(
            session_queue,
            items.len(),
            ReviewSessionKind::Exam,
            seed,
            now_unix_seconds,
            self.localizer
                .started_exam_pass(exam_pass.questions().len()),
        );

        Some(exam_pass)
    }

    #[cfg(test)]
    pub(crate) fn finish_exam_pass(
        &mut self,
        exam_pass: &ExamPass,
        answers: &[GradedExamAnswer],
        finished_at_unix_seconds: i64,
    ) -> ExamSummary {
        self.finish_exam_pass_with_timing(exam_pass, answers, finished_at_unix_seconds, None, false)
    }

    pub(crate) fn finish_exam_pass_with_timing(
        &mut self,
        exam_pass: &ExamPass,
        answers: &[GradedExamAnswer],
        finished_at_unix_seconds: i64,
        time_limit_minutes: Option<usize>,
        timed_out: bool,
    ) -> ExamSummary {
        let finished_at_unix_seconds =
            finished_at_unix_seconds.max(exam_pass.started_at_unix_seconds());
        let elapsed_seconds =
            finished_at_unix_seconds.saturating_sub(exam_pass.started_at_unix_seconds()) as u64;
        self.session.reset();
        self.normalize_ready_selection_for_filter();

        if answers.is_empty() {
            self.last_status = self.localizer.no_cards_reviewed_message().to_string();
        } else {
            self.last_status = self.localizer.completed_exam_pass(answers.len());
        }

        ExamSummary::from_answers_with_timing(
            exam_pass.questions().len(),
            answers,
            elapsed_seconds,
            time_limit_minutes,
            timed_out,
        )
    }

    pub(crate) fn start_quiz_pass(&mut self, request: QuizSizeRequest) -> Option<QuizPass> {
        match current_time_and_seed() {
            Ok((now_unix_seconds, filler_seed)) => {
                self.start_quiz_pass_with_request(request, filler_seed, now_unix_seconds)
            }
            Err(error) => {
                self.last_status = self.localizer.could_not_build_review_queue(&error);
                None
            }
        }
    }

    pub(crate) fn start_quiz_pass_with_request(
        &mut self,
        request: QuizSizeRequest,
        seed: u64,
        now_unix_seconds: i64,
    ) -> Option<QuizPass> {
        let StudyPassStartPreflight {
            items,
            active_indices,
        } = self.study_pass_start_preflight(now_unix_seconds)?;
        let (quiz_pass, session_queue) =
            match build_quiz_pass(&items, active_indices, request, seed, now_unix_seconds) {
                Ok(pass) => pass,
                Err(QuizBuildError::NoActiveCards) => {
                    self.note_no_study_mode_cards_available();
                    return None;
                }
                Err(QuizBuildError::NotEnoughDistinctAnswerChoices {
                    distinct_answer_choices,
                }) => {
                    self.session.reset();
                    self.last_status = self
                        .localizer
                        .not_enough_distinct_answer_choices_for_quiz(distinct_answer_choices);
                    return None;
                }
            };

        let question_count = quiz_pass.questions().len();
        self.start_study_mode_session(
            session_queue,
            items.len(),
            ReviewSessionKind::Quiz,
            seed,
            now_unix_seconds,
            self.localizer.started_quiz_pass(question_count),
        );

        Some(quiz_pass)
    }

    fn note_no_study_mode_cards_available(&mut self) {
        self.session.reset();
        self.last_status = self
            .localizer
            .no_cards_available_to_start_pass()
            .to_string();
    }

    fn start_study_mode_session(
        &mut self,
        session_queue: Vec<usize>,
        study_item_count: usize,
        kind: ReviewSessionKind,
        seed: u64,
        now_unix_seconds: i64,
        status: String,
    ) {
        self.session = ReviewSession::start_with_kind(
            session_queue,
            study_item_count,
            kind,
            seed,
            now_unix_seconds,
        )
        .expect("non-empty study-mode queue checked before session start");
        self.last_ready_due_notification_count = None;
        self.last_status = status;
    }

    fn study_pass_start_preflight(
        &mut self,
        now_unix_seconds: i64,
    ) -> Option<StudyPassStartPreflight> {
        self.pending_warning_dialog = None;
        if self.data.is_empty() {
            self.last_status = self
                .localizer
                .no_cards_available_to_start_pass()
                .to_string();
            return None;
        }

        if let Err(error) = self.clear_inactive_card_states(now_unix_seconds) {
            self.session.reset();
            self.last_status = self.localizer.could_not_build_review_queue(&error);
            return None;
        }

        let items = match self.study_items() {
            Ok(items) => items,
            Err(error) => {
                self.last_status = self.localizer.could_not_build_review_queue(&error);
                return None;
            }
        };
        let active_indices = match self.active_study_item_indices_at(&items, now_unix_seconds) {
            Ok(active_indices) => active_indices,
            Err(error) => {
                self.last_status = self.localizer.could_not_build_review_queue(&error);
                return None;
            }
        };

        Some(StudyPassStartPreflight {
            items,
            active_indices,
        })
    }

    pub(crate) fn finish_quiz_pass(
        &mut self,
        quiz_pass: &QuizPass,
        answers: &[QuizAnswer],
        finished_at_unix_seconds: i64,
    ) -> QuizSummary {
        let finished_at_unix_seconds =
            finished_at_unix_seconds.max(quiz_pass.started_at_unix_seconds());
        let elapsed_seconds =
            finished_at_unix_seconds.saturating_sub(quiz_pass.started_at_unix_seconds()) as u64;
        self.session.reset();
        self.normalize_ready_selection_for_filter();

        if answers.is_empty() {
            self.last_status = self.localizer.no_cards_reviewed_message().to_string();
        } else {
            self.last_status = self.localizer.completed_quiz_pass(answers.len());
        }

        QuizSummary::from_answers(quiz_pass.questions().len(), answers, elapsed_seconds)
    }

    fn backup_review_database_on_session_start(&self) -> Option<String> {
        if self.settings.review_db_backup_policy == ReviewDbBackupPolicy::OnSessionStart {
            return backup_review_database(&self.review_db_path, &self.backup_dir, &self.store)
                .err()
                .map(|error| {
                    self.localizer.could_not_backup_review_database_status(
                        &self.review_db_path.display().to_string(),
                        &self.backup_dir.display().to_string(),
                        &error.to_string(),
                    )
                });
        }
        None
    }

    fn backup_review_database_before_significant_change(&self) -> Result<(), String> {
        if self.settings.review_db_backup_policy == ReviewDbBackupPolicy::Off {
            return Ok(());
        }

        backup_review_database(&self.review_db_path, &self.backup_dir, &self.store)
            .map(|_| ())
            .map_err(|error| {
                self.localizer.could_not_backup_review_database(
                    &self.review_db_path.display().to_string(),
                    &self.backup_dir.display().to_string(),
                    &error.to_string(),
                )
            })
    }

    pub(crate) fn show_response(&mut self, guess: PromptGuess) {
        self.session.show_response(guess);
        self.last_status = self.localizer.response_shown_choose_feedback().to_string();
    }

    pub(crate) fn record_feedback_and_check_for_cycle_completion(
        &mut self,
        feedback: RecallFeedback,
    ) -> Option<ReviewCycleSummary> {
        let Some(item) = self.current_study_item() else {
            self.last_status = self.localizer.no_datum_available_to_record().to_string();
            return None;
        };

        let Some(guess) = self.session.response_guess() else {
            self.last_status = self
                .localizer
                .choose_prompt_guess_before_feedback()
                .to_string();
            return None;
        };

        let review = Review::new(guess, feedback);
        match self
            .store
            .record_review_for_datum_key_now(item.datum_key(), review.clone())
        {
            Ok(event) => {
                let progress = self
                    .session
                    .record_completed_review(
                        review,
                        feedback,
                        event.id(),
                        event.reviewed_at_unix_seconds(),
                    )
                    .expect("recording feedback requires a running review session");
                if let Some(mut summary) = progress.completed_summary {
                    let pass_warning = self.record_review_pass_summary(&summary).err();
                    summary.warning = pass_warning.clone();
                    self.last_status = self
                        .localizer
                        .recorded_review_completed(event.id(), progress.total_cards);
                    self.last_status =
                        append_status_warning(self.last_status.clone(), pass_warning);
                    Some(summary)
                } else {
                    self.last_status = self.localizer.recorded_review_for_card(
                        event.id(),
                        progress.completed_index,
                        progress.total_cards,
                    );
                    None
                }
            }
            Err(error) => {
                self.last_status = self.localizer.could_not_record_review(
                    &self.review_db_path.display().to_string(),
                    &error.to_string(),
                );
                None
            }
        }
    }

    pub(crate) fn end_current_pass(&mut self) -> Option<ReviewCycleSummary> {
        let ended_at_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .unwrap_or(0);
        let Some(mut summary) = self.session.end_current_pass(ended_at_unix_seconds) else {
            self.last_status = self.localizer.no_cards_available_for_review().to_string();
            return None;
        };
        let pass_warning = if summary.stats.reviewed_cards > 0 {
            self.record_review_pass_summary(&summary).err()
        } else {
            None
        };
        summary.warning = pass_warning.clone();
        self.last_status = self
            .localizer
            .ended_pass_early_after(summary.stats.reviewed_cards, summary.total_cards);
        self.last_status = append_status_warning(self.last_status.clone(), pass_warning);
        Some(summary)
    }

    fn record_review_pass_summary(&self, summary: &ReviewCycleSummary) -> Result<(), String> {
        let pass = ReviewPass::new(
            StoredReviewPassSummary::new(
                summary.started_at_unix_seconds,
                summary.finished_at_unix_seconds,
                summary.was_scheduled,
                summary.ended_early,
                summary.initial_cards,
                summary.total_cards,
                summary.stats.unique_reviewed_cards,
            ),
            summary.items.clone(),
        );
        self.store
            .record_review_pass(&pass)
            .map(|_| ())
            .map_err(|error| {
                self.localizer.could_not_record_review_pass_status(
                    &self.review_db_path.display().to_string(),
                    &error.to_string(),
                )
            })
    }

    pub(crate) fn restart_cycle(&mut self) {
        self.session.reset();
        self.normalize_ready_selection_for_filter();
        self.last_ready_due_notification_count = None;
        self.last_status = self.localizer.ready_to_start_new_pass(self.data.len());
    }

    pub(crate) fn add_card(&mut self, datum: Datum) -> Result<(), String> {
        self.ensure_card_modification_is_allowed()?;
        let mut updated_data = self.data.clone();
        let card_id = self.next_available_card_id()?;
        let selected_key = DatumKey::for_card_id(card_id);
        updated_data.push(datum.with_card_id(card_id));
        self.ensure_prompt_is_unique(&updated_data, updated_data.len() - 1)?;
        self.ensure_reversal_invariants(&updated_data)?;
        let total_cards = updated_data.len();
        self.replace_deck(
            updated_data,
            Some(selected_key),
            self.localizer.added_card_status(total_cards),
        )
    }

    pub(crate) fn edit_selected_card(&mut self, datum: Datum) -> Result<(), String> {
        self.ensure_card_modification_is_allowed()?;
        let Some(item) = self.selected_ready_study_item()? else {
            return Err(self.localizer.no_selected_card_to_edit().to_string());
        };
        let was_reverse_item = item.is_reverse();
        let selected_index = item.deck_index();

        let mut updated_data = self.data.clone();
        let existing_card_id = updated_data[selected_index].card_id();
        let existing_reversal_policy = updated_data[selected_index].reversal_policy();
        updated_data[selected_index] = if let Some(card_id) = existing_card_id {
            datum
                .with_card_id(card_id)
                .with_reversal_policy(existing_reversal_policy)
        } else {
            datum.with_reversal_policy(existing_reversal_policy)
        };
        self.ensure_prompt_is_unique(&updated_data, selected_index)?;
        self.ensure_reversal_invariants(&updated_data)?;
        let total_cards = updated_data.len();
        let parent_key = DatumKey::for_datum(&updated_data[selected_index]);
        let selected_key = if was_reverse_item {
            DatumKey::for_reverse_of(&parent_key)
        } else {
            parent_key
        };
        self.replace_deck(
            updated_data,
            Some(selected_key),
            self.localizer.saved_selected_card_status(total_cards),
        )
    }

    pub(crate) fn edit_current_revealed_card(&mut self, datum: Datum) -> Result<(), String> {
        if !matches!(self.session.phase(), ReviewPhase::Response { .. }) {
            return Err(self
                .localizer
                .choose_prompt_guess_before_feedback()
                .to_string());
        }
        let Some(deck_index) = self.current_deck_index() else {
            return Err(self.localizer.no_datum_available_to_record().to_string());
        };

        let mut updated_data = self.data.clone();
        let existing_card_id = updated_data[deck_index].card_id();
        let existing_reversal_policy = updated_data[deck_index].reversal_policy();
        updated_data[deck_index] = if let Some(card_id) = existing_card_id {
            datum
                .with_card_id(card_id)
                .with_reversal_policy(existing_reversal_policy)
        } else {
            datum.with_reversal_policy(existing_reversal_policy)
        };
        self.ensure_prompt_is_unique(&updated_data, deck_index)?;
        self.ensure_reversal_invariants(&updated_data)?;
        let mut deck_metadata = self.deck_metadata.clone();
        self.ensure_next_card_id_metadata_for_write(&mut deck_metadata, &updated_data)?;
        let deck_text = format_deck_with_metadata(&deck_metadata, &updated_data)
            .map_err(|error| self.localizer.could_not_save_deck(&error.to_string()))?;
        write_deck_file_durably(
            &self.deck_path,
            &self.backup_dir,
            self.settings.deck_backup_policy,
            &deck_text,
        )
        .map_err(|error| {
            self.localizer
                .could_not_write_path(&self.deck_path, &error.to_string())
        })?;

        self.deck_metadata = deck_metadata;
        self.data = updated_data;
        self.last_status = self.localizer.saved_revealed_card_status().to_string();
        Ok(())
    }

    pub(crate) fn update_deck_metadata(
        &mut self,
        mut deck_metadata: DeckMetadata,
    ) -> Result<(), String> {
        self.ensure_card_modification_is_allowed()?;
        deck_metadata.ensure_v2_header();
        self.ensure_next_card_id_metadata_for_write(&mut deck_metadata, &self.data)?;
        let deck_text = format_deck_with_metadata(&deck_metadata, &self.data)
            .map_err(|error| self.localizer.could_not_save_deck(&error.to_string()))?;
        write_deck_file_durably(
            &self.deck_path,
            &self.backup_dir,
            self.settings.deck_backup_policy,
            &deck_text,
        )
        .map_err(|error| {
            self.localizer
                .could_not_write_path(&self.deck_path, &error.to_string())
        })?;

        self.deck_metadata = deck_metadata;
        self.last_status = self.localizer.saved_deck_properties_status().to_string();
        Ok(())
    }

    pub(crate) fn duplicate_prompt_groups(&self) -> Vec<DuplicatePromptGroup> {
        duplicate_prompt_groups(&self.data)
    }

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

    pub(crate) fn duplicate_prompt_group_count(&self) -> usize {
        self.duplicate_prompt_groups().len()
    }

    pub(crate) fn resolve_duplicate_prompt_group(
        &mut self,
        normalized_prompt: &str,
        keep_key: &DatumKey,
    ) -> Result<usize, String> {
        self.ensure_card_modification_is_allowed()?;
        let matching_keys = self
            .data
            .iter()
            .filter(|datum| normalized_reverse_prompt(datum.prompt()) == normalized_prompt)
            .map(DatumKey::for_datum)
            .collect::<Vec<_>>();
        if matching_keys.len() < 2 {
            return Err(self.localizer.no_duplicate_prompts_to_resolve().to_string());
        }
        if !matching_keys.iter().any(|datum_key| datum_key == keep_key) {
            return Err(self.localizer.no_selected_card_to_edit().to_string());
        }

        let mut removed_count = 0;
        let updated_data = self
            .data
            .iter()
            .filter(|datum| {
                let should_remove = normalized_reverse_prompt(datum.prompt()) == normalized_prompt
                    && DatumKey::for_datum(datum) != *keep_key;
                if should_remove {
                    removed_count += 1;
                    false
                } else {
                    true
                }
            })
            .cloned()
            .collect::<Vec<_>>();

        self.replace_deck(
            updated_data,
            Some(keep_key.clone()),
            self.localizer
                .resolved_duplicate_prompts_status(removed_count),
        )?;
        Ok(removed_count)
    }

    pub(crate) fn remove_selected_card(&mut self) -> Result<(), String> {
        self.ensure_card_modification_is_allowed()?;
        let Some(item) = self.selected_ready_study_item()? else {
            return Err(self.localizer.no_selected_card_to_remove().to_string());
        };
        let selected_key = item.datum_key().clone();
        let selection_candidates = self.visible_selection_successor_candidates(&selected_key)?;
        if item.is_reverse() {
            let parent_key = item.parent_datum_key();
            let mut reverse_selection_candidates =
                Vec::with_capacity(selection_candidates.len() + 1);
            reverse_selection_candidates.push(parent_key.clone());
            reverse_selection_candidates.extend(
                selection_candidates
                    .into_iter()
                    .filter(|candidate| candidate != &parent_key),
            );
            self.store
                .set_datum_reversible(&parent_key, false)
                .map_err(|error| error.to_string())?;
            self.last_status = self.localizer.removed_reverse_card_status().to_string();
            self.selected_ready_key =
                self.selected_visible_key_after_change(&self.data, &reverse_selection_candidates)?;
            self.normalize_ready_selection_for_filter();
            return Ok(());
        }
        let selected_index = item.deck_index();

        let removed_card_number = selected_index + 1;
        let mut updated_data = self.data.clone();
        updated_data.remove(selected_index);
        let next_selected_key =
            self.selected_visible_key_after_change(&updated_data, &selection_candidates)?;

        if updated_data.is_empty() {
            self.replace_deck(
                updated_data,
                None,
                self.localizer.removed_last_card_status().to_string(),
            )
        } else {
            let total_cards = updated_data.len();
            self.replace_deck(
                updated_data,
                next_selected_key,
                self.localizer
                    .removed_card_status(removed_card_number, total_cards),
            )
        }
    }

    pub(crate) fn toggle_selected_card_suspension(&mut self) -> Result<(), String> {
        self.ensure_card_modification_is_allowed()?;
        let Some(item) = self.selected_ready_study_item()? else {
            return Err(self.localizer.no_selected_card_to_edit().to_string());
        };
        let datum_key = item.datum_key().clone();
        let suspended = self.load_suspended_card_keys()?;
        let should_suspend = !suspended.contains(&datum_key);
        self.store
            .set_datum_suspended(&datum_key, should_suspend)
            .map_err(|error| error.to_string())?;
        self.last_status = if should_suspend {
            self.localizer.suspended_card_status().to_string()
        } else {
            self.localizer.unsuspended_card_status().to_string()
        };
        self.normalize_ready_selection_for_filter();
        Ok(())
    }

    pub(crate) fn toggle_current_or_selected_card_flag(&mut self) -> Result<(), String> {
        let item = if self.session.is_ready() {
            self.selected_ready_study_item()?
        } else {
            self.current_study_item()
        };
        let Some(item) = item else {
            return Err(self.localizer.no_selected_card_to_edit().to_string());
        };
        let datum_key = item.datum_key().clone();
        let flagged = self.load_flagged_card_keys()?;
        let should_flag = !flagged.contains(&datum_key);
        self.store
            .set_datum_flagged(&datum_key, should_flag)
            .map_err(|error| error.to_string())?;
        self.last_status = if should_flag {
            self.localizer.flagged_card_status().to_string()
        } else {
            self.localizer.unflagged_card_status().to_string()
        };
        if self.session.is_ready() {
            self.normalize_ready_selection_for_filter();
        }
        Ok(())
    }

    pub(crate) fn toggle_selected_card_deferral(&mut self) -> Result<(), String> {
        self.ensure_card_modification_is_allowed()?;
        let Some(item) = self.selected_ready_study_item()? else {
            return Err(self.localizer.no_selected_card_to_edit().to_string());
        };
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .map_err(|error| error.to_string())?;
        let datum_key = item.datum_key().clone();
        let deferred = self.load_deferred_card_keys_at(now_unix_seconds)?;
        let should_defer = !deferred.contains(&datum_key);
        if should_defer {
            self.store
                .defer_datum_until(&datum_key, now_unix_seconds + DEFAULT_DEFER_SECONDS)
                .map_err(|error| error.to_string())?;
            self.last_status = self.localizer.deferred_card_status().to_string();
        } else {
            self.store
                .clear_datum_deferred(&datum_key)
                .map_err(|error| error.to_string())?;
            self.last_status = self.localizer.cleared_card_deferral_status().to_string();
        }
        self.normalize_ready_selection_for_filter();
        Ok(())
    }

    pub(crate) fn defer_current_review_card(&mut self) -> Result<ReviewDeferralProgress, String> {
        if !matches!(self.session.phase(), ReviewPhase::Prompt) {
            return Err(self
                .localizer
                .choose_prompt_guess_before_feedback()
                .to_string());
        }
        let Some(item) = self.current_study_item() else {
            return Err(self.localizer.no_datum_available_to_record().to_string());
        };
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .map_err(|error| error.to_string())?;
        let datum_key = item.datum_key().clone();
        self.store
            .defer_datum_until(&datum_key, now_unix_seconds + DEFAULT_DEFER_SECONDS)
            .map_err(|error| error.to_string())?;
        let Some(mut progress) = self.session.defer_current_prompt(now_unix_seconds) else {
            return Err(self
                .localizer
                .choose_prompt_guess_before_feedback()
                .to_string());
        };
        let pass_warning = match &mut progress {
            ReviewDeferralProgress::Finished {
                completed_summary: Some(summary),
            } => {
                let warning = self.record_review_pass_summary(summary).err();
                summary.warning = warning.clone();
                warning
            }
            ReviewDeferralProgress::Finished {
                completed_summary: None,
            }
            | ReviewDeferralProgress::Continuing => None,
        };
        self.last_status = append_status_warning(
            self.localizer.deferred_card_status().to_string(),
            pass_warning,
        );
        if progress.pass_finished() {
            self.normalize_ready_selection_for_filter();
        }
        Ok(progress)
    }

    pub(crate) fn toggle_selected_card_reversal(&mut self) -> Result<(), String> {
        self.ensure_card_modification_is_allowed()?;
        let Some(item) = self.selected_ready_study_item()? else {
            return Err(self.localizer.no_selected_card_to_edit().to_string());
        };

        if item.is_reverse() {
            let parent_key = item.parent_datum_key();
            self.store
                .set_datum_reversible(&parent_key, false)
                .map_err(|error| error.to_string())?;
            self.last_status = self.localizer.removed_reverse_card_status().to_string();
            self.selected_ready_key = Some(parent_key);
            self.normalize_ready_selection_for_filter();
            return Ok(());
        }

        let parent_key = item.parent_datum_key();
        let reversible = self.load_reversible_card_keys()?;
        if reversible.contains(&parent_key) {
            self.store
                .set_datum_reversible(&parent_key, false)
                .map_err(|error| error.to_string())?;
            self.last_status = self.localizer.removed_reverse_card_status().to_string();
        } else {
            let excluded = self.load_reversal_excluded_card_keys()?;
            if excluded.contains(&parent_key) {
                return Err(self.localizer.cannot_reverse_excluded_card().to_string());
            }
            if self.active_reversible_response_count_for_index(
                &self.data,
                &reversible,
                item.deck_index(),
            ) > 0
            {
                return Err(self
                    .localizer
                    .cannot_reverse_ambiguous_response()
                    .to_string());
            }
            self.store
                .set_datum_reversible(&parent_key, true)
                .map_err(|error| error.to_string())?;
            self.last_status = self.localizer.marked_card_reversible_status().to_string();
        }
        self.normalize_ready_selection_for_filter();
        Ok(())
    }

    pub(crate) fn toggle_selected_card_reversal_exclusion(&mut self) -> Result<(), String> {
        self.ensure_card_modification_is_allowed()?;
        let Some(item) = self.selected_ready_study_item()? else {
            return Err(self.localizer.no_selected_card_to_edit().to_string());
        };
        let parent_key = item.parent_datum_key();
        let excluded = self.load_reversal_excluded_card_keys()?;
        let should_exclude = !excluded.contains(&parent_key);
        if should_exclude {
            self.store
                .set_datum_reversible(&parent_key, false)
                .map_err(|error| error.to_string())?;
        }
        let status_message = if should_exclude {
            self.localizer
                .marked_card_non_reversible_status()
                .to_string()
        } else {
            self.localizer.allowed_card_reversal_status().to_string()
        };
        let mut updated_data = self.data.clone();
        let Some(datum) = updated_data.get_mut(item.deck_index()) else {
            return Err(self.localizer.no_selected_card_to_edit().to_string());
        };
        *datum = datum.clone().with_reversal_prohibited(should_exclude);
        let selected_key = if item.is_reverse() {
            Some(parent_key)
        } else {
            Some(item.datum_key().clone())
        };
        self.replace_deck(updated_data, selected_key, status_message)
    }

    pub(crate) fn eligible_reversible_card_count(&self) -> Result<usize, String> {
        let reversible = self.load_reversible_card_keys()?;
        let excluded = self.load_reversal_excluded_card_keys()?;
        Ok(reversible_candidate_indices(&self.data, &reversible, &excluded).len())
    }

    pub(crate) fn make_eligible_cards_reversible(&mut self) -> Result<usize, String> {
        self.ensure_card_modification_is_allowed()?;
        let reversible = self.load_reversible_card_keys()?;
        let excluded = self.load_reversal_excluded_card_keys()?;
        let candidate_indices = reversible_candidate_indices(&self.data, &reversible, &excluded);
        if !candidate_indices.is_empty() {
            self.backup_review_database_before_significant_change()?;
        }
        let candidate_keys = candidate_indices
            .iter()
            .map(|index| DatumKey::for_datum(&self.data[*index]))
            .collect::<Vec<_>>();
        self.store
            .set_datums_reversible_in_bulk(&candidate_keys)
            .map_err(|error| error.to_string())?;
        let updated_count = candidate_indices.len();
        self.last_status = self.localizer.marked_cards_reversible_status(updated_count);
        self.normalize_ready_selection_for_filter();
        Ok(updated_count)
    }

    pub(crate) fn latest_bulk_reversal_count(&self) -> Result<usize, String> {
        self.store
            .latest_bulk_reversal_count()
            .map_err(|error| error.to_string())
    }

    pub(crate) fn undo_latest_bulk_reversal(&mut self) -> Result<usize, String> {
        self.ensure_card_modification_is_allowed()?;
        let card_count = self.latest_bulk_reversal_count()?;
        if card_count == 0 {
            return Err(self.localizer.no_bulk_reversal_to_undo().to_string());
        }
        self.backup_review_database_before_significant_change()?;
        let removed_count = self
            .store
            .remove_latest_bulk_reversal()
            .map_err(|error| error.to_string())?;
        self.last_status = self
            .localizer
            .removed_latest_bulk_reversal_status(removed_count);
        self.normalize_ready_selection_for_filter();
        Ok(removed_count)
    }

    fn active_review_database_keys(
        &self,
    ) -> Result<(HashSet<DatumKey>, HashSet<DatumKey>), String> {
        let parent_keys: HashSet<DatumKey> = self.data.iter().map(DatumKey::for_datum).collect();
        let reversible_keys = self.load_reversible_card_keys()?;
        let mut active_keys = parent_keys.clone();
        for datum_key in reversible_keys {
            if parent_keys.contains(&datum_key) {
                active_keys.insert(DatumKey::for_reverse_of(&datum_key));
            }
        }
        Ok((active_keys, parent_keys))
    }

    pub(crate) fn clear_inactive_card_states(
        &self,
        now_unix_seconds: i64,
    ) -> Result<usize, String> {
        self.store
            .clear_inactive_card_states(now_unix_seconds)
            .map_err(|error| error.to_string())
    }

    pub(crate) fn scrub_review_database(&mut self) -> Result<usize, String> {
        self.ensure_card_modification_is_allowed()?;
        self.persist_next_card_id_metadata()?;
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .map_err(|error| error.to_string())?;
        self.backup_review_database_before_significant_change()?;
        let cleared_state_rows = self.clear_inactive_card_states(now_unix_seconds)?;
        let (active_keys, parent_keys) = self.active_review_database_keys()?;
        let summary = self
            .store
            .scrub_inactive_data(&active_keys, &parent_keys)
            .map_err(|error| error.to_string())?;
        self.store
            .verify_deck_consistency(&self.data)
            .map_err(|error| error.to_string())?;
        let removed_rows = summary.total_removed_rows() + cleared_state_rows;
        self.last_status = self.localizer.scrubbed_review_database_status(removed_rows);
        Ok(removed_rows)
    }

    pub(crate) fn reclaim_database_space(&mut self) -> Result<(), String> {
        self.ensure_card_modification_is_allowed()?;
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .map_err(|error| error.to_string())?;
        self.clear_inactive_card_states(now_unix_seconds)?;
        self.store
            .reclaim_space()
            .map_err(|error| error.to_string())?;
        self.last_status = self.localizer.reclaimed_database_space_status().to_string();
        Ok(())
    }

    fn replace_deck(
        &mut self,
        updated_data: Vec<Datum>,
        selected_ready_key: Option<DatumKey>,
        status_message: String,
    ) -> Result<(), String> {
        let mut deck_metadata = self.deck_metadata.clone();
        self.ensure_next_card_id_metadata_for_write(&mut deck_metadata, &updated_data)?;
        let deck_text = format_deck_with_metadata(&deck_metadata, &updated_data)
            .map_err(|error| self.localizer.could_not_save_deck(&error.to_string()))?;
        write_deck_file_durably(
            &self.deck_path,
            &self.backup_dir,
            self.settings.deck_backup_policy,
            &deck_text,
        )
        .map_err(|error| {
            self.localizer
                .could_not_write_path(&self.deck_path, &error.to_string())
        })?;

        self.deck_metadata = deck_metadata;
        self.data = updated_data;
        self.session.reset();
        self.selected_ready_key = selected_ready_key;
        self.normalize_ready_selection_for_filter();
        self.last_status = status_message;
        Ok(())
    }

    fn ensure_card_modification_is_allowed(&self) -> Result<(), String> {
        if self.session.is_ready() {
            Ok(())
        } else {
            Err(self
                .localizer
                .card_modification_only_before_cycle_starts()
                .to_string())
        }
    }

    fn ensure_prompt_is_unique(&self, data: &[Datum], selected_index: usize) -> Result<(), String> {
        let Some(selected) = data.get(selected_index) else {
            return Err(self.localizer.no_datum_available_to_record().to_string());
        };
        let selected_prompt = normalized_reverse_prompt(selected.prompt());
        let duplicate_exists = data.iter().enumerate().any(|(index, datum)| {
            index != selected_index && normalized_reverse_prompt(datum.prompt()) == selected_prompt
        });
        if duplicate_exists {
            Err(self.localizer.duplicate_card_prompt().to_string())
        } else {
            Ok(())
        }
    }

    fn ensure_reversal_invariants(&self, data: &[Datum]) -> Result<(), String> {
        let reversible = self.load_reversible_card_keys()?;
        for (index, datum) in data.iter().enumerate() {
            if reversible.contains(&DatumKey::for_datum(datum))
                && self.active_reversible_response_count_for_index(data, &reversible, index) != 1
            {
                return Err(self
                    .localizer
                    .cannot_reverse_ambiguous_response()
                    .to_string());
            }
        }
        Ok(())
    }

    fn active_reversible_response_count_for_index(
        &self,
        data: &[Datum],
        reversible: &HashSet<DatumKey>,
        selected_index: usize,
    ) -> usize {
        let Some(selected) = data.get(selected_index) else {
            return 0;
        };
        let selected_response = normalized_reverse_prompt(selected.response());
        data.iter()
            .filter(|datum| {
                reversible.contains(&DatumKey::for_datum(datum))
                    && normalized_reverse_prompt(datum.response()) == selected_response
            })
            .count()
    }

    fn build_review_queue(&self, config: &SchedulerConfig) -> Result<Vec<usize>, String> {
        let items = self
            .study_items()
            .map_err(|error| self.localizer.could_not_build_review_queue(&error))?;
        let schedules = self
            .load_study_item_schedules(&items)
            .map_err(|error| self.localizer.could_not_build_review_queue(&error))?;
        let suspended = self
            .load_suspended_study_item_keys(&items)
            .map_err(|error| self.localizer.could_not_build_review_queue(&error))?;
        let deferred = self
            .load_deferred_study_item_keys_at(&items, config.now_unix_seconds())
            .map_err(|error| self.localizer.could_not_build_review_queue(&error))?;
        let active_indices: Vec<usize> = items
            .iter()
            .enumerate()
            .filter_map(|(index, item)| {
                (!suspended.contains(item.datum_key()) && !deferred.contains(item.datum_key()))
                    .then_some(index)
            })
            .collect();
        let active_data: Vec<(DatumKey, &Datum)> = active_indices
            .iter()
            .map(|index| (items[*index].datum_key().clone(), items[*index].datum()))
            .collect();
        let config = self.scheduler_config_with_new_card_limit(&items, &active_indices, config)?;

        Ok(schedule_session_for_keys(&active_data, &schedules, config)
            .into_iter()
            .map(|card| active_indices[card.deck_index()])
            .collect())
    }

    pub(crate) fn scheduler_config_with_new_card_limit(
        &self,
        items: &[StudyItem],
        active_indices: &[usize],
        config: &SchedulerConfig,
    ) -> Result<SchedulerConfig, String> {
        let active_item_keys: HashSet<DatumKey> = active_indices
            .iter()
            .map(|index| items[*index].datum_key().clone())
            .collect();
        let first_reviewed_by_key = self
            .store
            .first_reviewed_at_for_datum_keys(&active_item_keys)
            .map_err(|error| error.to_string())?;
        Ok(scheduler_config_with_new_card_limit_from_first_reviews(
            config,
            self.settings.new_cards_per_day,
            first_reviewed_by_key,
        ))
    }

    fn active_study_item_indices_at(
        &self,
        items: &[StudyItem],
        now_unix_seconds: i64,
    ) -> Result<Vec<usize>, String> {
        let suspended = self.load_suspended_study_item_keys(items)?;
        let deferred = self.load_deferred_study_item_keys_at(items, now_unix_seconds)?;
        Ok(items
            .iter()
            .enumerate()
            .filter_map(|(index, item)| {
                (!suspended.contains(item.datum_key()) && !deferred.contains(item.datum_key()))
                    .then_some(index)
            })
            .collect())
    }

    pub(crate) fn load_study_item_schedules(
        &self,
        items: &[StudyItem],
    ) -> Result<HashMap<DatumKey, CardSchedule>, String> {
        let mut schedules = HashMap::with_capacity(items.len());
        for item in items {
            let Some(schedule) = self
                .store
                .schedule_for_datum_key(item.datum_key())
                .map_err(|error| error.to_string())?
            else {
                continue;
            };
            schedules.insert(item.datum_key().clone(), schedule);
        }

        Ok(schedules)
    }

    pub(crate) fn load_study_item_review_stats(
        &self,
        items: &[StudyItem],
    ) -> Result<HashMap<DatumKey, ReadyStudyItemReviewStats>, String> {
        let mut review_stats = HashMap::with_capacity(items.len());
        for item in items {
            let reviews = self
                .store
                .reviews_for_datum_key(item.datum_key())
                .map_err(|error| error.to_string())?;
            review_stats.insert(
                item.datum_key().clone(),
                ReadyStudyItemReviewStats::from_reviews(
                    &reviews,
                    self.settings.calibration_recall_rule,
                ),
            );
        }

        Ok(review_stats)
    }

    pub(crate) fn load_suspended_card_keys(&self) -> Result<HashSet<DatumKey>, String> {
        self.store
            .suspended_datum_keys()
            .map_err(|error| error.to_string())
    }

    pub(crate) fn load_suspended_study_item_keys(
        &self,
        items: &[StudyItem],
    ) -> Result<HashSet<DatumKey>, String> {
        let suspended = self.load_suspended_card_keys()?;
        Ok(items
            .iter()
            .filter_map(|item| {
                suspended
                    .contains(item.datum_key())
                    .then_some(item.datum_key().clone())
            })
            .collect())
    }

    pub(crate) fn load_flagged_card_keys(&self) -> Result<HashSet<DatumKey>, String> {
        self.store
            .flagged_datum_keys()
            .map_err(|error| error.to_string())
    }

    pub(crate) fn load_flagged_study_item_keys(
        &self,
        items: &[StudyItem],
    ) -> Result<HashSet<DatumKey>, String> {
        let flagged = self.load_flagged_card_keys()?;
        Ok(items
            .iter()
            .filter_map(|item| {
                flagged
                    .contains(item.datum_key())
                    .then_some(item.datum_key().clone())
            })
            .collect())
    }

    fn load_deferred_card_keys_at(
        &self,
        now_unix_seconds: i64,
    ) -> Result<HashSet<DatumKey>, String> {
        self.store
            .deferred_datum_keys(now_unix_seconds)
            .map_err(|error| error.to_string())
    }

    pub(crate) fn load_deferred_card_keys(&self) -> Result<HashSet<DatumKey>, String> {
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .unwrap_or(0);
        self.load_deferred_card_keys_at(now_unix_seconds)
    }

    pub(crate) fn load_deferred_study_item_keys_at(
        &self,
        items: &[StudyItem],
        now_unix_seconds: i64,
    ) -> Result<HashSet<DatumKey>, String> {
        let deferred = self.load_deferred_card_keys_at(now_unix_seconds)?;
        Ok(items
            .iter()
            .filter_map(|item| {
                deferred
                    .contains(item.datum_key())
                    .then_some(item.datum_key().clone())
            })
            .collect())
    }

    pub(crate) fn load_deferred_study_item_keys(
        &self,
        items: &[StudyItem],
    ) -> Result<HashSet<DatumKey>, String> {
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .unwrap_or(0);
        self.load_deferred_study_item_keys_at(items, now_unix_seconds)
    }

    fn available_flagged_review_queue(
        &self,
        items: &[StudyItem],
        now_unix_seconds: i64,
    ) -> Result<Vec<usize>, String> {
        let flagged = self.load_flagged_study_item_keys(items)?;
        let suspended = self.load_suspended_study_item_keys(items)?;
        let deferred = self.load_deferred_study_item_keys_at(items, now_unix_seconds)?;
        Ok(items
            .iter()
            .enumerate()
            .filter_map(|(index, item)| {
                (flagged.contains(item.datum_key())
                    && !suspended.contains(item.datum_key())
                    && !deferred.contains(item.datum_key()))
                .then_some(index)
            })
            .collect())
    }

    pub(crate) fn available_flagged_card_count_at(
        &self,
        now_unix_seconds: i64,
    ) -> Result<usize, String> {
        let items = self.study_items()?;
        Ok(self
            .available_flagged_review_queue(&items, now_unix_seconds)?
            .len())
    }

    pub(crate) fn available_flagged_card_count(&self) -> Result<usize, String> {
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .unwrap_or(0);
        self.available_flagged_card_count_at(now_unix_seconds)
    }

    pub(crate) fn active_card_count(&self) -> Result<usize, String> {
        let items = self.study_items()?;
        let now_unix_seconds = current_time_and_seed()
            .map(|(now_unix_seconds, _)| now_unix_seconds)
            .unwrap_or(0);
        Ok(self
            .active_study_item_indices_at(&items, now_unix_seconds)?
            .len())
    }

    pub(crate) fn has_reverse_study_items(&self) -> Result<bool, String> {
        Ok(self.study_items()?.iter().any(StudyItem::is_reverse))
    }

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

    fn next_available_card_id(&self) -> Result<u64, String> {
        let used_ids =
            card_ids_used_by_deck_and_store(&self.data, &self.store).map_err(|error| {
                self.localizer
                    .could_not_build_review_queue(&error.to_string())
            })?;

        next_monotonic_card_id(&used_ids, &self.deck_metadata).map_err(|error| error.to_string())
    }

    fn ensure_next_card_id_metadata_for_write(
        &self,
        metadata: &mut DeckMetadata,
        updated_data: &[Datum],
    ) -> Result<(), String> {
        let mut used_ids =
            card_ids_used_by_deck_and_store(&self.data, &self.store).map_err(|error| {
                self.localizer
                    .could_not_build_review_queue(&error.to_string())
            })?;
        for card_id in updated_data.iter().filter_map(Datum::card_id) {
            used_ids.insert(card_id);
        }
        ensure_next_card_id_metadata(metadata, &used_ids).map_err(|error| error.to_string())?;
        Ok(())
    }

    fn persist_next_card_id_metadata(&mut self) -> Result<(), String> {
        let mut deck_metadata = self.deck_metadata.clone();
        self.ensure_next_card_id_metadata_for_write(&mut deck_metadata, &self.data)?;
        if deck_metadata == self.deck_metadata {
            return Ok(());
        }

        let deck_text = format_deck_with_metadata(&deck_metadata, &self.data)
            .map_err(|error| self.localizer.could_not_save_deck(&error.to_string()))?;
        write_deck_file_durably(
            &self.deck_path,
            &self.backup_dir,
            self.settings.deck_backup_policy,
            &deck_text,
        )
        .map_err(|error| {
            self.localizer
                .could_not_write_path(&self.deck_path, &error.to_string())
        })?;
        self.deck_metadata = deck_metadata;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use cosimo::{ScheduledSelectionKind, schedule_session_for_keys};
    use proptest::prelude::*;

    fn new_card_datum(card_id: u64) -> Datum {
        Datum::new(format!("Prompt {card_id}"), format!("Response {card_id}"))
            .unwrap()
            .with_card_id(card_id)
    }

    fn scheduled_new_candidate_count(config: SchedulerConfig, count: usize) -> usize {
        let data = (0..count)
            .map(|index| new_card_datum(10_000 + index as u64))
            .collect::<Vec<_>>();
        let keyed_data = data
            .iter()
            .map(|datum| (DatumKey::for_datum(datum), datum))
            .collect::<Vec<_>>();
        let scheduled = schedule_session_for_keys(&keyed_data, &HashMap::new(), config);

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

    #[test]
    fn new_card_daily_limit_rolls_over_after_twenty_four_hours() {
        let now = 5_000_000;
        let base_config = SchedulerConfig::new(0, now, 17);
        let boundary_config = scheduler_config_with_new_card_limit_from_first_reviews(
            &base_config,
            1,
            HashMap::from([(
                DatumKey::for_card_id(1),
                now - NEW_CARD_INTRODUCTION_WINDOW_SECONDS,
            )]),
        );
        let older_config = scheduler_config_with_new_card_limit_from_first_reviews(
            &base_config,
            1,
            HashMap::from([(
                DatumKey::for_card_id(1),
                now - NEW_CARD_INTRODUCTION_WINDOW_SECONDS - 1,
            )]),
        );

        assert_eq!(scheduled_new_candidate_count(boundary_config, 3), 0);
        assert_eq!(scheduled_new_candidate_count(older_config, 3), 1);
    }

    #[test]
    fn new_card_daily_limit_collapses_reverse_cards_to_parent_introduction() {
        let now = 5_000_000;
        let base_config = SchedulerConfig::new(0, now, 17);
        let parent_key = DatumKey::for_card_id(1);
        let reverse_key = DatumKey::for_reverse_of(&parent_key);
        let old_parent_recent_reverse_config =
            scheduler_config_with_new_card_limit_from_first_reviews(
                &base_config,
                1,
                HashMap::from([
                    (
                        parent_key.clone(),
                        now - NEW_CARD_INTRODUCTION_WINDOW_SECONDS - 1,
                    ),
                    (reverse_key.clone(), now - 60),
                ]),
            );
        let reverse_only_recent_config = scheduler_config_with_new_card_limit_from_first_reviews(
            &base_config,
            1,
            HashMap::from([(reverse_key, now - 60)]),
        );

        assert_eq!(
            scheduled_new_candidate_count(old_parent_recent_reverse_config, 3),
            1
        );
        assert_eq!(
            scheduled_new_candidate_count(reverse_only_recent_config, 3),
            0
        );
    }

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

        #[test]
        fn new_card_daily_limit_subtracts_recent_parent_introductions(
            review_offsets in prop::collection::vec(
                0i64..=(2 * NEW_CARD_INTRODUCTION_WINDOW_SECONDS),
                0usize..40,
            ),
            new_cards_per_day in 0usize..40,
        ) {
            let now = 5_000_000;
            let first_reviewed_by_key = review_offsets
                .iter()
                .enumerate()
                .map(|(index, offset)| {
                    (DatumKey::for_card_id((index + 1) as u64), now - *offset)
                })
                .collect::<HashMap<_, _>>();
            let introduced_recently = review_offsets
                .iter()
                .filter(|offset| **offset <= NEW_CARD_INTRODUCTION_WINDOW_SECONDS)
                .count();
            let expected_remaining = new_cards_per_day.saturating_sub(introduced_recently);
            let config = scheduler_config_with_new_card_limit_from_first_reviews(
                &SchedulerConfig::new(0, now, 17),
                new_cards_per_day,
                first_reviewed_by_key,
            );

            prop_assert_eq!(
                scheduled_new_candidate_count(config, 50),
                expected_remaining.min(50)
            );
        }
    }
}
